feat(kanban): partition local queues by account

req: sync/020

req: auth/005

req: security/004

req: operations/002
This commit is contained in:
slhx agent
2026-07-13 19:00:04 +02:00
parent 6de4dcb73b
commit 3ef84e7331
7 changed files with 239 additions and 95 deletions
+82 -49
View File
@@ -242,8 +242,8 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'sync-actor:1', schemaVersion: 1, actor: 'sync-actor', session: 'sync-session',
causal: 1, kind: 'reorder_card', cardId: '1', eventKind: 'click', key: null,
id: 'sync-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'sync-actor', session: 'sync-session',
causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null,
});
tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name });
@@ -319,7 +319,7 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'sync-actor:1', schemaVersion: 2, actor: 'sync-actor', session: 'sync-session',
id: 'sync-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'sync-actor', session: 'sync-session',
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
});
tx.oncomplete = () => done({ seeded: true });
@@ -373,9 +373,8 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
}
#[tokio::test]
async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_work(
) -> WebDriverResult<()> {
// test req: sync/019 req: security/004 req: auth/005 req: operations/002
async fn account_partition_hides_replay_and_export_until_owner_returns() -> WebDriverResult<()> {
// test req: sync/020 req: security/004 req: auth/005 req: operations/002
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"));
@@ -392,7 +391,8 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
.env(
"HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR",
"test-token-carol-beta-editor",
);
)
.env("HEMX_KANBAN_SYNC_FAILURES", "3");
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
.expect("start ready hemx-kanban");
@@ -422,7 +422,7 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'auth:1', schemaVersion: 2, actor: 'alice-device', session: 'enqueue-session',
id: 'auth:1', schemaVersion: 2, accountPartition: 'alpha:alice', actor: 'alice-device', session: 'enqueue-session',
causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done',
eventKind: 'click', key: null, enqueuedPrincipal: 'alice', enqueuedTenant: 'alpha',
});
@@ -440,30 +440,23 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
wait_until(
&driver,
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'",
)
.await?;
let cross_tenant = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); const retry = root.querySelector('[data-sync-retry]'); return { phase: root.getAttribute('data-sync-phase'), kind: root.getAttribute('data-sync-error-kind'), errorStatus: root.getAttribute('data-sync-error-status'), pending: root.getAttribute('data-sync-pending-count'), redacted: root.getAttribute('data-sync-redacted-pending'), reason: root.getAttribute('data-sync-error-reason'), rejectedId: root.getAttribute('data-sync-rejected-command-id'), retryDisabled: retry.disabled, leakedId: document.body.textContent.includes('auth:1'), status: root.querySelector('[role=status]').textContent }",
"const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), account: root.getAttribute('data-sync-account-partition'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), leakedId: document.body.textContent.includes('auth:1'), status: root.querySelector('[role=status]').textContent }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(cross_tenant["phase"], "authorization-denied");
assert_eq!(cross_tenant["kind"], "authorization-denial");
assert_eq!(cross_tenant["errorStatus"], "403");
assert_eq!(cross_tenant["pending"], "redacted");
assert_eq!(cross_tenant["redacted"], "true");
assert!(cross_tenant["reason"].is_null());
assert!(cross_tenant["rejectedId"].is_null());
assert_eq!(cross_tenant["retryDisabled"], true);
assert_eq!(cross_tenant["phase"], "idle");
assert_eq!(cross_tenant["account"], "beta:carol");
assert_eq!(cross_tenant["pending"], "0");
assert!(cross_tenant["attempts"].is_null());
assert_eq!(cross_tenant["leakedId"], false);
assert_eq!(
cross_tenant["status"],
"Current session cannot access local queued work. Sign back into the owning account to continue."
);
assert_eq!(cross_tenant["status"], "No pending commands.");
assert_eq!(command_count(&driver).await?, 1);
driver
@@ -475,20 +468,20 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
driver.refresh().await?;
wait_until(
&driver,
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'failed'",
)
.await?;
let signed_out = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); return { kind: root.getAttribute('data-sync-error-kind'), status: root.getAttribute('data-sync-error-status'), pending: root.getAttribute('data-sync-pending-count') }",
"const root = document.querySelector('[data-kanban-sync]'); return { error: root.getAttribute('data-sync-error'), pending: root.getAttribute('data-sync-pending-count'), account: root.getAttribute('data-sync-account-partition') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(signed_out["kind"], "authorization-denial");
assert_eq!(signed_out["status"], "401");
assert_eq!(signed_out["pending"], "redacted");
assert_eq!(signed_out["error"], "account context failed with 401");
assert!(signed_out["pending"].is_null());
assert!(signed_out["account"].is_null());
assert_eq!(command_count(&driver).await?, 1);
let before_authorized = driver
@@ -516,30 +509,70 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
driver.refresh().await?;
wait_until(
&driver,
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'",
)
.await?;
let stale_permission = driver
let switched_user = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); return { kind: root.getAttribute('data-sync-error-kind'), status: root.getAttribute('data-sync-error-status'), pending: root.getAttribute('data-sync-pending-count'), rejectedId: root.getAttribute('data-sync-rejected-command-id') }",
"const root = document.querySelector('[data-kanban-sync]'); return { account: root.getAttribute('data-sync-account-partition'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), leakedId: document.body.textContent.includes('auth:1') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(stale_permission["kind"], "authorization-denial");
assert_eq!(stale_permission["status"], "403");
assert_eq!(stale_permission["pending"], "redacted");
assert!(stale_permission["rejectedId"].is_null());
assert_eq!(switched_user["account"], "alpha:bob");
assert_eq!(switched_user["pending"], "0");
assert!(switched_user["attempts"].is_null());
assert_eq!(switched_user["leakedId"], false);
assert_eq!(command_count(&driver).await?, 1);
let export_boundary = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); const button = root.querySelector('[data-sync-export]'); button.click(); return { exportDisabled: button.disabled, exported: root.getAttribute('data-sync-exported-count'), leakedId: document.body.textContent.includes('auth:1') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(export_boundary["exportDisabled"], true);
assert!(export_boundary["exported"].is_null());
assert_eq!(export_boundary["leakedId"], false);
driver
.execute(
"document.cookie = 'hemx_kanban_session=test-token-alice-alpha-editor; Path=/; SameSite=Strict'; return true;",
Vec::new(),
)
.await?;
driver.refresh().await?;
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?;
driver
.execute(
"window.__exportPayload = null; const create = URL.createObjectURL; URL.createObjectURL = (blob) => { blob.text().then((text) => { window.__exportPayload = JSON.parse(text); }); return create(blob); }; return true;",
Vec::new(),
)
.await?;
driver.find(By::Css("[data-sync-export]")).await?.click().await?;
wait_until(&driver, "return window.__exportPayload !== null").await?;
let owner_export = driver
.execute(
"const root = document.querySelector('[data-kanban-sync]'); return { payload: window.__exportPayload, exported: root.getAttribute('data-sync-exported-count') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(owner_export["exported"], "1");
assert_eq!(owner_export["payload"]["accountPartition"], "alpha:alice");
assert_eq!(owner_export["payload"]["commands"].as_array().unwrap().len(), 1);
assert_eq!(owner_export["payload"]["commands"][0]["id"], "auth:1");
assert_eq!(owner_export["payload"]["commands"][0]["accountPartition"], "alpha:alice");
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'",
@@ -655,15 +688,15 @@ async fn schema_upgrade_preserves_queued_order_and_local_intent() -> WebDriverRe
.await?
.json()
.clone();
assert_eq!(migrated["databaseVersion"], 2);
assert_eq!(migrated["databaseVersion"], 3);
assert_eq!(migrated["commandSchema"], "2");
assert_eq!(migrated["migrationFrom"], "1");
assert_eq!(migrated["migrationTo"], "2");
assert_eq!(migrated["migrationTo"], "3");
assert_eq!(migrated["migratedCount"], "3");
assert_eq!(migrated["pending"], "3");
assert_eq!(
migrated["receipt"],
serde_json::json!({ "from": 1, "to": 2, "migrated": 3 })
serde_json::json!({ "from": 1, "to": 3, "migrated": 3 })
);
assert_eq!(
migrated["commands"],
@@ -760,8 +793,8 @@ async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebD
const commands = tx.objectStore('commands');
for (const [causal, cardId] of [[1, '1'], [2, '999'], [3, '2']]) {
commands.add({
id: `mixed:${causal}`, schemaVersion: 1, actor: 'mixed', session: 'mixed-session',
causal, kind: 'reorder_card', cardId, eventKind: 'click', key: null,
id: `mixed:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'mixed', session: 'mixed-session',
causal, kind: 'reorder_card', cardId, targetColumn: 'done', eventKind: 'click', key: null,
});
}
tx.oncomplete = () => done({ seeded: true });
@@ -888,8 +921,8 @@ async fn upload_backpressure_keeps_pending_work_visible_and_recoverable() -> Web
const commands = tx.objectStore('commands');
for (let causal = 1; causal <= 3; causal += 1) {
commands.add({
id: `pressure:${causal}`, schemaVersion: 1, actor: 'pressure', session: 'pressure-session',
causal, kind: 'reorder_card', cardId: String(causal), eventKind: 'click', key: null,
id: `pressure:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'pressure', session: 'pressure-session',
causal, kind: 'reorder_card', cardId: String(causal), targetColumn: 'done', eventKind: 'click', key: null,
});
}
tx.oncomplete = () => done({ seeded: true });
@@ -997,8 +1030,8 @@ async fn two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_appl
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'tabs:1', schemaVersion: 1, actor: 'tabs', session: 'tabs-session',
causal: 1, kind: 'reorder_card', cardId: '1', eventKind: 'click', key: null,
id: 'tabs:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'tabs', session: 'tabs-session',
causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null,
});
tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name });
@@ -1168,8 +1201,8 @@ async fn exhausted_offline_retries_keep_command_until_later_reconnect() -> WebDr
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'offline-actor:1', schemaVersion: 1, actor: 'offline-actor', session: 'offline-session',
causal: 1, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
id: 'offline-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'offline-actor', session: 'offline-session',
causal: 1, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
});
tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name });
@@ -1309,8 +1342,8 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'history:2', schemaVersion: 1, actor: 'history', session: 'history-session',
causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
id: 'history:2', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session',
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
});
tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name });
@@ -1421,7 +1454,7 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'history:3', schemaVersion: 2, actor: 'history', session: 'history-session',
id: 'history:3', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session',
causal: 3, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
});
tx.oncomplete = () => done({ seeded: true });