feat(kanban): expose redacted sync diagnostics

req: operations/001

req: security/002

req: sync/016
This commit is contained in:
slhx agent
2026-07-13 20:06:25 +02:00
parent fbc0c9db30
commit f1dc6edb7a
5 changed files with 188 additions and 11 deletions
+138 -7
View File
@@ -290,7 +290,7 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
assert_eq!(proof["uploadSequence"], "1");
assert_eq!(proof["ackSequence"], "1");
assert_eq!(proof["canonicalColumn"], "done");
assert_eq!(proof["status"], "Command sync-actor:1 acknowledged in done.");
assert_eq!(proof["status"], "Queued change acknowledged in done.");
assert!(proof["error"].is_null());
let queue_count = driver
@@ -580,14 +580,13 @@ async fn account_partition_hides_replay_and_export_until_owner_returns() -> WebD
.await?;
let authorized = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); return { sequence: root.getAttribute('data-sync-ack-sequence'), command: root.getAttribute('data-sync-ack-command-id'), column: root.getAttribute('data-sync-canonical-column'), pending: root.getAttribute('data-sync-pending-count') }",
"const root = document.querySelector('[data-kanban-sync]'); return { sequence: root.getAttribute('data-sync-ack-sequence'), column: root.getAttribute('data-sync-canonical-column'), pending: root.getAttribute('data-sync-pending-count') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(authorized["sequence"], "1");
assert_eq!(authorized["command"], "auth:1");
assert_eq!(authorized["column"], "done");
assert_eq!(authorized["pending"], "0");
assert_eq!(command_count(&driver).await?, 0);
@@ -1106,7 +1105,7 @@ async fn upload_backpressure_keeps_pending_work_visible_and_recoverable() -> Web
assert_eq!(recovered["total"], "3");
assert_eq!(recovered["maxInFlight"], "1");
assert_eq!(recovered["sequence"], "3");
assert_eq!(recovered["status"], "Command pressure:3 acknowledged in done.");
assert_eq!(recovered["status"], "Queued change acknowledged in done.");
assert_eq!(command_count(&driver).await?, 0);
Ok(())
}
@@ -1242,7 +1241,7 @@ async fn two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_appl
assert_eq!(takeover["attempts"], "1");
assert_eq!(takeover["pending"], "0");
assert_eq!(takeover["sequence"], "1");
assert_eq!(takeover["status"], "Command tabs:1 acknowledged in done.");
assert_eq!(takeover["status"], "Queued change acknowledged in done.");
assert_eq!(command_count(&driver).await?, 0);
driver
@@ -1390,7 +1389,7 @@ async fn exhausted_offline_retries_keep_command_until_later_reconnect() -> WebDr
assert_eq!(converged["attempts"], "1");
assert_eq!(converged["sequence"], "1");
assert_eq!(converged["column"], "done");
assert_eq!(converged["status"], "Command offline-actor:1 acknowledged in done.");
assert_eq!(converged["status"], "Queued change acknowledged in done.");
assert!(converged["error"].is_null());
assert_eq!(command_count(&driver).await?, 0);
@@ -1605,7 +1604,7 @@ async fn missing_history_rebase_and_user_conflict_resolution_preserve_suffix() -
.await?;
let conflicted = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), uploadSequence: root.getAttribute('data-sync-upload-sequence'), snapshotSequence: root.getAttribute('data-sync-snapshot-sequence'), pending: root.getAttribute('data-sync-pending-count'), rebasePending: root.getAttribute('data-sync-rebase-pending-count'), decision: root.getAttribute('data-sync-rebase-decision'), reason: root.getAttribute('data-sync-rebase-reason'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), resolutionDisabled: root.querySelector('[data-sync-use-canonical]').disabled, status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }",
"const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), uploadSequence: root.getAttribute('data-sync-upload-sequence'), snapshotSequence: root.getAttribute('data-sync-snapshot-sequence'), pending: root.getAttribute('data-sync-pending-count'), rebasePending: root.getAttribute('data-sync-rebase-pending-count'), decision: root.getAttribute('data-sync-rebase-decision'), reason: root.getAttribute('data-sync-rebase-reason'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), resolutionDisabled: root.querySelector('[data-sync-use-canonical]').disabled, diagnosticConflicts: root.getAttribute('data-sync-diag-conflicts'), diagnosticVisible: root.querySelector('[data-sync-diagnostics]').textContent, status: root.querySelector('[role=status]').textContent, error: root.getAttribute('data-sync-error') }",
Vec::new(),
)
.await?
@@ -1620,6 +1619,8 @@ async fn missing_history_rebase_and_user_conflict_resolution_preserve_suffix() -
assert_eq!(conflicted["reason"], "canonical-state-diverged");
assert_eq!(conflicted["canonicalColumn"], "doing");
assert_eq!(conflicted["resolutionDisabled"], false);
assert_eq!(conflicted["diagnosticConflicts"], "1");
assert!(conflicted["diagnosticVisible"].as_str().unwrap().ends_with("conflicts 1; rejections 0."));
assert_eq!(
conflicted["status"],
"Canonical snapshot 4 conflicts with history:3 (canonical-state-diverged); the pending command remains queued."
@@ -1704,6 +1705,132 @@ async fn missing_history_rebase_and_user_conflict_resolution_preserve_suffix() -
result.and(quit)
}
#[tokio::test]
async fn redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material(
) -> WebDriverResult<()> {
// test req: operations/001 req: security/002 req: sync/016 req: sync/021
let app_port = available_port();
let app_addr = format!("127.0.0.1:{app_port}");
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
app_command
.env("HEMX_KANBAN_ADDR", &app_addr)
.env("HEMX_KANBAN_SYNC_FAILURES", "3");
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
.expect("start ready hemx-kanban");
let webdriver_port = available_port();
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT)
.expect("start ready geckodriver");
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&format!("http://{app_addr}/")).await?;
let seeded = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 3);
open.onupgradeneeded = () => {
const database = open.result;
const commands = database.createObjectStore('commands', { keyPath: 'id' });
commands.createIndex('byAccountPartition', 'accountPartition');
database.createObjectStore('meta');
};
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'diag-secret-command', schemaVersion: 2, accountPartition: 'demo:demo',
actor: 'private-actor', session: 'super-secret-session-token', causal: 1,
queuedAt: Date.now() - 15000, kind: 'reorder_card', cardId: '1', targetColumn: 'done',
eventKind: 'click', key: null, privatePayload: 'customer-secret-payload',
});
tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name });
};
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(seeded["seeded"], true);
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
wait_until(
&driver,
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'offline' && root?.getAttribute('data-sync-pending-count') === '1'",
)
.await?;
let queued = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); const names = ['data-sync-diag-queue-count','data-sync-diag-oldest-age-bucket','data-sync-diag-cursor','data-sync-diag-ack-latency-bucket','data-sync-diag-conflicts','data-sync-diag-rejections']; const diagnostics = Object.fromEntries(names.map((name) => [name, root.getAttribute(name)])); return { diagnostics, visible: root.querySelector('[data-sync-diagnostics]').textContent }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(
queued["diagnostics"],
serde_json::json!({
"data-sync-diag-queue-count": "1",
"data-sync-diag-oldest-age-bucket": "10s-1m",
"data-sync-diag-cursor": "0",
"data-sync-diag-ack-latency-bucket": "none",
"data-sync-diag-conflicts": "0",
"data-sync-diag-rejections": "0"
})
);
assert!(queued["visible"].as_str().unwrap().contains("Queue 1; oldest 10s-1m; cursor 0; acknowledgement none"));
driver.find(By::Css("[data-sync-retry]")).await?.click().await?;
wait_until(
&driver,
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'",
)
.await?;
let proof = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); const names = ['data-sync-diag-queue-count','data-sync-diag-oldest-age-bucket','data-sync-diag-cursor','data-sync-diag-ack-latency-bucket','data-sync-diag-conflicts','data-sync-diag-rejections']; const diagnostics = Object.fromEntries(names.map((name) => [name, root.getAttribute(name)])); return { diagnostics, visible: root.querySelector('[data-sync-diagnostics]').textContent, rootHtml: root.outerHTML, cookies: document.cookie }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(proof["diagnostics"]["data-sync-diag-queue-count"], "0");
assert_eq!(proof["diagnostics"]["data-sync-diag-oldest-age-bucket"], "empty");
assert_eq!(proof["diagnostics"]["data-sync-diag-cursor"], "1");
assert_eq!(proof["diagnostics"]["data-sync-diag-conflicts"], "0");
assert_eq!(proof["diagnostics"]["data-sync-diag-rejections"], "0");
assert!(matches!(
proof["diagnostics"]["data-sync-diag-ack-latency-bucket"].as_str(),
Some("lt-50ms" | "50ms-250ms" | "250ms-1s" | "gte-1s")
));
let visible = proof["visible"].as_str().unwrap();
assert!(visible.contains("Queue 0; oldest empty; cursor 1; acknowledgement"));
assert!(visible.len() < 120);
let exposed = format!("{}\n{}\n{}\n{}", queued["visible"], proof["visible"], proof["rootHtml"], proof["cookies"]);
for secret in [
"diag-secret-command",
"private-actor",
"super-secret-session-token",
"customer-secret-payload",
"cardId",
"privatePayload",
] {
assert!(!exposed.contains(secret), "diagnostics leaked {secret}: {exposed}");
}
Ok(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test]
async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> WebDriverResult<()> {
// test req: sync/009 req: sync/010 req: sync/011 req: sync/016
@@ -1841,6 +1968,8 @@ async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> Web
resolution: root.getAttribute('data-sync-conflict-resolution'),
resolutionDisabled: root.querySelector('[data-sync-keep-local]').disabled,
pending: root.getAttribute('data-sync-pending-count'),
diagnosticRejections: root.getAttribute('data-sync-diag-rejections'),
diagnosticVisible: root.querySelector('[data-sync-diagnostics]').textContent,
rejections: window.__keepRejections,
commands: request.result.sort((left, right) => left.causal - right.causal).map(({ id, causal, cardId }) => ({ id, causal, cardId })),
});
@@ -1854,6 +1983,8 @@ async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> Web
assert_eq!(rejected["resolution"], "keep-local-rejected");
assert_eq!(rejected["resolutionDisabled"], false);
assert_eq!(rejected["pending"], "2");
assert_eq!(rejected["diagnosticRejections"], "1");
assert!(rejected["diagnosticVisible"].as_str().unwrap().ends_with("conflicts 1; rejections 1."));
assert_eq!(rejected["rejections"], 1);
assert_eq!(
rejected["commands"],