mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(client): logging & error-visibility hardening
- tauri-plugin-log: rotating Rust log file in the app-log dir so shipped users can retrieve proxy/TLS/TOFU diagnostics (a release build detaches the console) - log the health-check failure cause; log persist failures in save_settings / store_cert_fingerprint; add a TOFU cert-pin accept/change audit trail; log http/livekit proxy-loop panics instead of swallowing them - stop persisting the raw WS frame content and the auth token prefix to disk - drain the pre-init in-memory log buffer so bootstrap logs reach disk - surface the server X-Request-Id on API errors for cross-tier correlation Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+61
-49
@@ -32,6 +32,23 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||
|
||||
[[package]]
|
||||
name = "android_logger"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_filter",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -1256,27 +1273,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "2.0.0"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
|
||||
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1340,6 +1344,15 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fern"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -2350,30 +2363,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2802,6 +2791,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
@@ -3013,7 +3011,6 @@ name = "owncord-client"
|
||||
version = "1.1.0-alpha.2"
|
||||
dependencies = [
|
||||
"device_query",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"keyring",
|
||||
"log",
|
||||
@@ -3029,6 +3026,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
@@ -3431,15 +3429,6 @@ version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -5073,6 +5062,27 @@ dependencies = [
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"fern",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"swift-rs",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
@@ -5442,7 +5452,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
|
||||
@@ -48,7 +48,10 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
|
||||
webpki-roots = "1"
|
||||
ring = "0.17"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
# Writes Rust logs to a rotating file under the OS app-log dir (alongside the
|
||||
# TS client-logs) so a shipped user can retrieve them — a release build detaches
|
||||
# the console, so stdout/stderr logging is otherwise unreachable.
|
||||
tauri-plugin-log = "2"
|
||||
keyring = "3"
|
||||
rfd = { version = "0.16", default-features = false }
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ pub fn get_settings(app: tauri::AppHandle) -> Result<Value, String> {
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
|
||||
/// Log a command failure Rust-side — so it lands in the retrievable log file —
|
||||
/// and return the same message unchanged to the JS caller.
|
||||
fn log_cmd_err(cmd: &str, msg: String) -> String {
|
||||
log::warn!("{cmd}: {msg}");
|
||||
msg
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> {
|
||||
if !is_settings_key_allowed(&key) {
|
||||
@@ -55,12 +62,12 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result
|
||||
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("failed to open settings store: {e}"))?;
|
||||
.map_err(|e| log_cmd_err("save_settings", format!("failed to open settings store: {e}")))?;
|
||||
|
||||
store.set(&key, value);
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("failed to persist settings: {e}"))?;
|
||||
.map_err(|e| log_cmd_err("save_settings", format!("failed to persist settings: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -102,9 +109,9 @@ pub fn store_cert_fingerprint(
|
||||
}
|
||||
}
|
||||
|
||||
let store = app
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
let store = app.store(CERTS_STORE).map_err(|e| {
|
||||
log_cmd_err("store_cert_fingerprint", format!("failed to open certs store: {e}"))
|
||||
})?;
|
||||
|
||||
// Capture old value before mutating so we can restore it if save fails.
|
||||
let old_value = store.get(&host);
|
||||
@@ -117,7 +124,10 @@ pub fn store_cert_fingerprint(
|
||||
Some(v) => { store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
}
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
return Err(log_cmd_err(
|
||||
"store_cert_fingerprint",
|
||||
format!("failed to persist cert fingerprint: {e}"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -107,12 +107,21 @@ pub async fn start_http_proxy<R: Runtime>(
|
||||
.port();
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
tokio::spawn(run_proxy_loop(
|
||||
let loop_handle = tokio::spawn(run_proxy_loop(
|
||||
app.clone(),
|
||||
listener,
|
||||
remote_host.clone(),
|
||||
shutdown_rx,
|
||||
));
|
||||
// Watch the loop so a panic is logged instead of vanishing silently (which
|
||||
// would leave JS with a stale cached port and no error).
|
||||
tokio::spawn(async move {
|
||||
match loop_handle.await {
|
||||
Ok(()) => info!("[http_proxy] proxy loop exited"),
|
||||
Err(e) if e.is_panic() => error!("[http_proxy] proxy loop panicked: {e:?}"),
|
||||
Err(e) => warn!("[http_proxy] proxy loop join error: {e:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
"[http_proxy] tunnel started on 127.0.0.1:{} → {}",
|
||||
|
||||
@@ -9,6 +9,26 @@ mod tray;
|
||||
mod update_commands;
|
||||
mod ws_proxy;
|
||||
|
||||
/// Map the RUST_LOG env var to a global level filter for the log plugin.
|
||||
/// ponytail: only the simple global form is honoured ("debug", "info", …);
|
||||
/// per-module directives like "ws_proxy=debug" fall back to Info. Add a real
|
||||
/// parser only if per-module control is actually needed.
|
||||
fn log_level_from_env() -> log::LevelFilter {
|
||||
match std::env::var("RUST_LOG")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"trace" => log::LevelFilter::Trace,
|
||||
"debug" => log::LevelFilter::Debug,
|
||||
"warn" => log::LevelFilter::Warn,
|
||||
"error" => log::LevelFilter::Error,
|
||||
"off" => log::LevelFilter::Off,
|
||||
_ => log::LevelFilter::Info,
|
||||
}
|
||||
}
|
||||
|
||||
// Only used by the desktop-only single-instance closure below.
|
||||
#[cfg(desktop)]
|
||||
use tauri::Manager;
|
||||
@@ -31,6 +51,24 @@ pub fn run() {
|
||||
}));
|
||||
|
||||
let builder = builder
|
||||
// Log plugin registered early so logging is available to everything
|
||||
// after it. Writes to stdout (dev) and a rotating file in the OS
|
||||
// app-log dir so a shipped user — whose release build has no console —
|
||||
// can retrieve logs.
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.target(tauri_plugin_log::Target::new(
|
||||
tauri_plugin_log::TargetKind::Stdout,
|
||||
))
|
||||
.target(tauri_plugin_log::Target::new(
|
||||
tauri_plugin_log::TargetKind::LogDir {
|
||||
file_name: Some("owncord-client".into()),
|
||||
},
|
||||
))
|
||||
.level(log_level_from_env())
|
||||
.max_file_size(10_000_000) // 10 MB rolling file (default 40 KB is too small)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
@@ -88,14 +126,7 @@ pub fn run() {
|
||||
commands::open_devtools,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Initialize Rust logging (controlled by RUST_LOG env var, defaults to info).
|
||||
// try_init avoids panic if another logger (e.g. a Tauri plugin) registered first.
|
||||
let _ = env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("info"),
|
||||
)
|
||||
.format_timestamp_millis()
|
||||
.try_init();
|
||||
|
||||
// Rust logging is initialized by tauri_plugin_log (registered above).
|
||||
tray::create_tray(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -134,7 +134,15 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let host = remote_host.clone();
|
||||
tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
|
||||
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
|
||||
// Watch the loop so a panic is logged instead of vanishing silently.
|
||||
tokio::spawn(async move {
|
||||
match loop_handle.await {
|
||||
Ok(()) => info!("[livekit_proxy] proxy loop exited"),
|
||||
Err(e) if e.is_panic() => error!("[livekit_proxy] proxy loop panicked: {e:?}"),
|
||||
Err(e) => warn!("[livekit_proxy] proxy loop join error: {e:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host);
|
||||
|
||||
|
||||
@@ -307,13 +307,17 @@ pub fn accept_cert_fingerprint<R: Runtime>(
|
||||
return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into());
|
||||
}
|
||||
|
||||
let store = app
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
let store = app.store(CERTS_STORE).map_err(|e| {
|
||||
log::warn!("[ws_proxy] accept_cert_fingerprint: failed to open certs store: {e}");
|
||||
format!("failed to open certs store: {e}")
|
||||
})?;
|
||||
|
||||
// Capture old value before mutating so we can restore it if save fails.
|
||||
let old_value = store.get(&host);
|
||||
store.set(&host, Value::String(fingerprint));
|
||||
// A pin that replaces a *different* existing fingerprint is security-
|
||||
// significant (cert rotation — or a MITM the user just accepted).
|
||||
let changed = matches!(&old_value, Some(Value::String(s)) if *s != fingerprint);
|
||||
store.set(&host, Value::String(fingerprint.clone()));
|
||||
if let Err(e) = store.save() {
|
||||
// Restore previous in-memory state: put back old fingerprint if one
|
||||
// existed, or delete if there was none. Without this, the new
|
||||
@@ -323,7 +327,14 @@ pub fn accept_cert_fingerprint<R: Runtime>(
|
||||
Some(v) => { store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
}
|
||||
log::warn!("[ws_proxy] accept_cert_fingerprint: failed to persist pin for {host}: {e}");
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
}
|
||||
// Fingerprints are public cert hashes — safe to log; this is the TOFU audit trail.
|
||||
if changed {
|
||||
log::warn!("[ws_proxy] cert pin CHANGED for {host} -> {fingerprint}");
|
||||
} else {
|
||||
log::info!("[ws_proxy] cert pin accepted for {host} -> {fingerprint}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
status: res.status,
|
||||
code: err.error,
|
||||
message: err.message,
|
||||
// Server echoes its request ID in this header — logging it lets a
|
||||
// client-side failure be matched to the server's log line for it.
|
||||
reqId: res.headers.get("x-request-id") ?? undefined,
|
||||
});
|
||||
throw new ApiClientError(res.status, err.error, err.message);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { appLogDir, join } from "@tauri-apps/api/path";
|
||||
import { mkdir, writeTextFile, readDir, remove, exists } from "@tauri-apps/plugin-fs";
|
||||
import { type LogEntry, addLogListener, createLogger } from "./logger";
|
||||
import { type LogEntry, addLogListener, createLogger, getLogBuffer } from "./logger";
|
||||
|
||||
const log = createLogger("logPersistence");
|
||||
const MAX_LOG_FILES = 5;
|
||||
@@ -131,7 +131,18 @@ export async function initLogPersistence(): Promise<() => void> {
|
||||
currentDate = today();
|
||||
initialized = true;
|
||||
|
||||
// Persist entries logged before this listener attached (the bootstrap
|
||||
// window) so a startup-time problem lands on disk, not just in the
|
||||
// in-memory ring. Runs synchronously right before addLogListener, so there
|
||||
// is no gap and no double-capture.
|
||||
for (const entry of getLogBuffer()) {
|
||||
buffer.push(JSON.stringify(entry));
|
||||
}
|
||||
|
||||
const removeListener = addLogListener(onLogEntry);
|
||||
if (buffer.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeListener(); // stop receiving new entries first
|
||||
|
||||
@@ -220,7 +220,9 @@ export function createWsClient() {
|
||||
try {
|
||||
parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string; seq?: number };
|
||||
} catch {
|
||||
log.warn("Failed to parse WS message", { data: raw });
|
||||
// Log the size only — `raw` is the decrypted frame (chat plaintext,
|
||||
// usernames) and this line is persisted to the on-disk log.
|
||||
log.warn("Failed to parse WS message", { bytes: raw.length });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,8 +491,11 @@ export function createWsClient() {
|
||||
if (msg.includes("channel full")) {
|
||||
// Outbound channel is saturated — surface the drop to listeners so an
|
||||
// optimistic row fails with retry instead of silently losing the send.
|
||||
// Log id + size only — a slice of `json` can contain the auth
|
||||
// envelope's bearer token, and this line is persisted to disk.
|
||||
log.warn("ws_send: outbound channel full, message dropped (backpressure)", {
|
||||
messagePreview: json.slice(0, 120),
|
||||
id,
|
||||
bytes: json.length,
|
||||
});
|
||||
notifySendFailure(id, "NETWORK");
|
||||
} else {
|
||||
|
||||
@@ -226,7 +226,10 @@ function runHealthChecks(
|
||||
version: health.version ?? null,
|
||||
onlineUsers: health.online_users ?? null,
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Record why the check failed (TLS/cert-pin/network) — otherwise a
|
||||
// "can't connect" report has no logged cause to diagnose.
|
||||
log.warn("health check failed", { host: profile.host, error: String(err) });
|
||||
connectPage.updateHealthStatus(profile.host, {
|
||||
status: "offline",
|
||||
latencyMs: null,
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
mockRemove,
|
||||
mockReadTextFile,
|
||||
mockAddLogListener,
|
||||
mockGetLogBuffer,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAppLogDir: vi.fn().mockResolvedValue("/mock/logs"),
|
||||
mockJoin: vi.fn((...parts: string[]) => parts.join("/")),
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
mockRemove: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadTextFile: vi.fn().mockResolvedValue(""),
|
||||
mockAddLogListener: vi.fn(),
|
||||
mockGetLogBuffer: vi.fn(() => [] as unknown[]),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/path", () => ({
|
||||
@@ -41,6 +43,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
|
||||
vi.mock("@lib/logger", () => ({
|
||||
addLogListener: mockAddLogListener,
|
||||
getLogBuffer: mockGetLogBuffer,
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -102,6 +105,7 @@ describe("log persistence", () => {
|
||||
mockRemove.mockReset().mockResolvedValue(undefined);
|
||||
mockReadTextFile.mockReset().mockResolvedValue("");
|
||||
mockAddLogListener.mockReset();
|
||||
mockGetLogBuffer.mockReset().mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -148,6 +152,20 @@ describe("log persistence", () => {
|
||||
expect(typeof mockAddLogListener.mock.calls[0]![0]).toBe("function");
|
||||
});
|
||||
|
||||
it("persists entries buffered before init (bootstrap drain)", async () => {
|
||||
mockGetLogBuffer.mockReturnValue([makeEntry({ message: "bootstrap-line" })]);
|
||||
const { initLogPersistence } = await freshImport();
|
||||
captureListener();
|
||||
await initLogPersistence();
|
||||
|
||||
// The drain scheduled a flush; advance past the 2000ms debounce.
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
expect(mockWriteTextFile).toHaveBeenCalled();
|
||||
const written = mockWriteTextFile.mock.calls.map((c) => String(c[1])).join("");
|
||||
expect(written).toContain("bootstrap-line");
|
||||
});
|
||||
|
||||
it("returns a no-op cleanup if already initialized", async () => {
|
||||
const { initLogPersistence } = await freshImport();
|
||||
captureListener();
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Import after mocks are set up
|
||||
import { createWsClient, toConnectionStatus } from "../../src/lib/ws";
|
||||
import { addLogListener, type LogEntry } from "../../src/lib/logger";
|
||||
|
||||
/** Simulate Tauri emitting an event to JS */
|
||||
function emitTauriEvent(event: string, payload: unknown): void {
|
||||
@@ -291,6 +292,23 @@ describe("WebSocket Client (Tauri proxy)", () => {
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not log raw frame content on parse failure (no plaintext leak)", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
const entries: LogEntry[] = [];
|
||||
const remove = addLogListener((e) => entries.push(e));
|
||||
const secret = "SUPER_SECRET_eyJhbGciOiJIUzI1NiJ9";
|
||||
emitTauriEvent("ws-message", secret + " not-json{{{");
|
||||
remove();
|
||||
|
||||
// The decrypted frame must never reach the (on-disk-persisted) log...
|
||||
expect(JSON.stringify(entries)).not.toContain(secret);
|
||||
// ...but the parse failure is still recorded so it stays debuggable.
|
||||
expect(entries.some((e) => e.message.includes("Failed to parse WS message"))).toBe(true);
|
||||
});
|
||||
|
||||
it("disconnect prevents reconnect", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
Reference in New Issue
Block a user