feat(kanban): reauthorize queued replay

req: sync/019

req: security/004

req: auth/005

req: operations/002
This commit is contained in:
slhx agent
2026-07-13 18:18:52 +02:00
parent a17de85b4f
commit 6de4dcb73b
4 changed files with 366 additions and 17 deletions
+193
View File
@@ -372,6 +372,199 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
result.and(quit)
}
#[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
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_SESSION_ALICE_ALPHA_EDITOR",
"test-token-alice-alpha-editor",
)
.env(
"HEMX_KANBAN_SESSION_BOB_ALPHA_VIEWER",
"test-token-bob-alpha-viewer",
)
.env(
"HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR",
"test-token-carol-beta-editor",
);
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];
document.cookie = 'hemx_kanban_session=test-token-carol-beta-editor; Path=/; SameSite=Strict';
const open = indexedDB.open('hemx-kanban-v1', 2);
open.onupgradeneeded = () => {
const database = open.result;
if (!database.objectStoreNames.contains('commands')) database.createObjectStore('commands', { keyPath: 'id' });
if (!database.objectStoreNames.contains('meta')) database.createObjectStore('meta');
};
open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'auth:1', schemaVersion: 2, actor: 'alice-device', session: 'enqueue-session',
causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done',
eventKind: 'click', key: null, enqueuedPrincipal: 'alice', enqueuedTenant: 'alpha',
});
tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name });
};
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(seeded["seeded"], true, "failed to seed auth queue: {seeded}");
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
wait_until(
&driver,
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
)
.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 }",
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["leakedId"], false);
assert_eq!(
cross_tenant["status"],
"Current session cannot access local queued work. Sign back into the owning account to continue."
);
assert_eq!(command_count(&driver).await?, 1);
driver
.execute(
"document.cookie = 'hemx_kanban_session=; Path=/; Max-Age=0; SameSite=Strict'; return true;",
Vec::new(),
)
.await?;
driver.refresh().await?;
wait_until(
&driver,
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
)
.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') }",
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!(command_count(&driver).await?, 1);
let before_authorized = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
fetch('/').then((response) => response.text()).then((html) => {
const page = new DOMParser().parseFromString(html, 'text/html');
done(page.querySelector('[data-key="1"]').closest('section').querySelector('h2').textContent);
}).catch((error) => done(`error:${error}`));
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(before_authorized, "Backlog");
driver
.execute(
"document.cookie = 'hemx_kanban_session=test-token-bob-alpha-viewer; Path=/; SameSite=Strict'; return true;",
Vec::new(),
)
.await?;
driver.refresh().await?;
wait_until(
&driver,
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
)
.await?;
let stale_permission = 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') }",
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!(command_count(&driver).await?, 1);
driver
.execute(
"document.cookie = 'hemx_kanban_session=test-token-alice-alpha-editor; Path=/; SameSite=Strict'; return true;",
Vec::new(),
)
.await?;
driver.refresh().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 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') }",
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);
Ok(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test]
async fn schema_upgrade_preserves_queued_order_and_local_intent() -> WebDriverResult<()> {
// test req: sync/004 req: sync/010 req: sync/014