feat(kanban): partition local queues by account
req: sync/020 req: auth/005 req: security/004 req: operations/002
This commit is contained in:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user