diff --git a/src/arts/active-app/test/ecosystem-cross-actor-isolation.test.ts b/src/arts/active-app/test/ecosystem-cross-actor-isolation.test.ts new file mode 100644 index 0000000..ef6fdb6 --- /dev/null +++ b/src/arts/active-app/test/ecosystem-cross-actor-isolation.test.ts @@ -0,0 +1,255 @@ +/** + * Compound ecosystem test: cross-actor data isolation. The audit's + * P1 transversal finding said the unit tests pass per module but + * nothing proves the canonical scenario: + * + * "User A logs in, caches private data; user A logs out, user B + * logs in; B doesn't see anything that belonged to A." + * + * Wires real `createActiveCache` + `createEngineSession` + a + * behaviour-only `perm` double + the `applyStandardOrca` preset, then + * drives the realistic flow `adopt(A) → revoke → adopt(B)` to confirm + * the cache/perm/connection reactions fire on the canonical lifecycle + * transitions (`identity.changed` whenever the session state moves + * between `none/anonymous/identified`, `revoked` on logout). + * + * NOTE — `SESSION_EVENT_IDENTITY_CHANGED` only fires when the session + * **identity state** transitions (none ↔ anonymous ↔ identified). It + * does *not* fire for in-place `adopt(A) → adopt(B)` between two + * identified users; the framework's contract is that "switching user" + * always goes through a logout. Tests below model exactly that. + * + * `connections` is exercised through the same preset to confirm + * `reauthenticateAll()` / `closeAll()` run; we don't open real + * sockets — a stub is enough. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { applyStandardOrca } from '../presets/index.ts'; +import { createActiveCache } from '$cache/active-cache.svelte'; +import { createEngineSession } from '$session'; +import { createSvelteEngineBus, type EngineBus } from '$bus'; +import { createEngineLogger, type EngineLogger } from '$logger'; +import { createEngineOrca, type EngineOrca } from '$orca'; +import { createActiveTimers, type ActiveTimers } from '$timer/active-timers.svelte'; +import { + SESSION_EVENT_IDENTITY_CHANGED, + SESSION_EVENT_REVOKED +} from '$session'; +import type { ActiveCache } from '$cache/types'; +import type { ActiveConnections } from '$connection/types'; +import type { ActivePerms } from '$perm/types'; +import type { ActiveSession, Session } from '$session/types'; + +interface User { + readonly id: string; +} + +interface FakeAppCore { + Logger: EngineLogger; + Bus: EngineBus>; + Timers: ActiveTimers; + Orca: EngineOrca; + cache: ActiveCache; + perm: ActivePerms; + connections: ActiveConnections; + session: ActiveSession; + dispose(): void; +} + +function buildApp(): FakeAppCore { + const Logger = createEngineLogger({}); + const Timers = createActiveTimers({ logger: Logger }); + const Bus = createSvelteEngineBus>({ + logger: Logger, + clock: Timers.clock + }); + const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger }); + const cache = createActiveCache({ logger: Logger, clock: { now: () => Timers.clock.now() } }); + + // Stub `perm`: in-memory map keyed by `actorId`. `invalidate()` clears + // it; `check(action)` reads the current snapshot. The realistic + // permission engine is exercised by `arts/perm` tests; here we only + // need observable state to prove cross-actor isolation. + const permState = { actorId: null as string | null }; + const perm = { + invalidate: vi.fn(() => { + permState.actorId = null; + }), + __currentActor: () => permState.actorId, + __seedActor: (id: string) => { + permState.actorId = id; + } + } as unknown as ActivePerms & { + __currentActor: () => string | null; + __seedActor: (id: string) => void; + }; + + const connections = { + reauthenticateAll: vi.fn(async () => []), + closeAll: vi.fn() + } as unknown as ActiveConnections; + + const session = createEngineSession({ + logger: Logger, + bus: Bus + }) as unknown as ActiveSession; + + return { + Logger, + Bus, + Timers, + Orca, + cache, + perm, + connections, + session, + dispose() { + Orca.dispose(); + Bus.dispose(); + Timers.dispose(); + Logger.dispose(); + } + }; +} + +// Drain microtasks + setTimeout(0) until orca reports idle. Bus events +// schedule orca runs through `queueMicrotask`/`drainQueue`, and those runs +// chain async work (cache.clear, perm.invalidate, …). Waiting on a fixed +// number of rounds is flaky; this loop polls the engine's own `running` +// flag, capped at `maxRounds` so a stuck run can't hang the test. +async function flush(orca: EngineOrca, maxRounds = 50): Promise { + for (let i = 0; i < maxRounds; i++) { + await new Promise((r) => queueMicrotask(() => r(undefined))); + await new Promise((r) => setTimeout(r, 0)); + if (!orca.running) { + // One more round so a freshly enqueued downstream run gets a + // chance to start before we read state. + await new Promise((r) => queueMicrotask(() => r(undefined))); + await new Promise((r) => setTimeout(r, 0)); + if (!orca.running) return; + } + } +} + +const NOW = 1_700_000_000_000; +const ONE_HOUR = 60 * 60 * 1000; + +function sessionFor(user: User, expiresInMs = ONE_HOUR): Session { + return { user, issuedAt: NOW, expiresAt: NOW + expiresInMs }; +} + +describe('ecosystem — cross-actor isolation', () => { + let app: FakeAppCore; + let detachOrca: () => void; + + beforeEach(() => { + app = buildApp(); + detachOrca = applyStandardOrca(app); + }); + + afterEach(() => { + detachOrca(); + app.dispose(); + }); + + it('logout → re-login: B sees no cache or perm state from A', async () => { + // User A logs in. Drain orca reactions to the initial null → A + // transition before we mutate the cache. + await app.session.adopt(sessionFor({ id: 'user-A' })); + await flush(app.Orca); + (app.perm as ActivePerms & { __seedActor: (id: string) => void }).__seedActor('user-A'); + + // Cache something private for A. + await app.cache.set(['user-A:profile'], { name: 'Ana' }, { scope: 'public' }); + expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toEqual({ name: 'Ana' }); + + const permApi = app.perm as ActivePerms & { + __currentActor: () => string | null; + }; + expect(permApi.__currentActor()).toBe('user-A'); + + // Logout: identity transitions identified → none. Fires + // SESSION_EVENT_IDENTITY_CHANGED (cache.clear, perm.invalidate, + // connections.reauth) plus SESSION_EVENT_REVOKED (closeAll). + await app.session.revoke(); + await flush(app.Orca); + + expect(permApi.__currentActor()).toBeNull(); + expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined(); + expect(app.connections.closeAll).toHaveBeenCalled(); + + // User B logs in. None → identified fires identity-changed again. + // Even without re-asserting cleanup, the previous step proved the + // invariant: nothing belonging to A survives into B's session. + await app.session.adopt(sessionFor({ id: 'user-B' })); + await flush(app.Orca); + + expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined(); + }); + + it('revoke clears cache and closes connections', async () => { + await app.session.adopt(sessionFor({ id: 'user-A' })); + await flush(app.Orca); + await app.cache.set(['user-A:doc'], { title: 'Privado' }, { scope: 'public' }); + + // Revoke the session. The session art emits `SESSION_EVENT_REVOKED` + // (which the orca preset wires to `connections.closeAll()`) plus + // `SESSION_EVENT_IDENTITY_CHANGED` (user-A → null) which clears the + // cache via the same identity-change reaction. + await app.session.revoke(); + await flush(app.Orca); + + expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined(); + expect(app.connections.closeAll).toHaveBeenCalled(); + }); + + it('reauthenticateAll fires only on identity-state transitions', async () => { + // First adopt: none → identified → reauth fires once. + await app.session.adopt(sessionFor({ id: 'user-A' })); + await flush(app.Orca); + expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1); + + // adopt(user-B) on top of an active identified session does NOT + // transition the identity state (still `identified`), so no + // extra reauth — that is the framework's documented contract. + await app.session.adopt(sessionFor({ id: 'user-B' })); + await flush(app.Orca); + expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1); + + // Logout + re-login: identified → none → identified counts as two + // transitions, so reauth fires twice more (3 total). + await app.session.revoke(); + await flush(app.Orca); + await app.session.adopt(sessionFor({ id: 'user-C' })); + await flush(app.Orca); + expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(3); + }); + + it('detaching the preset stops cross-actor reactions', async () => { + // Initial adopt + revoke runs the reactions (cache cleared on + // identity-state transition). + await app.session.adopt(sessionFor({ id: 'user-A' })); + await flush(app.Orca); + await app.cache.set(['user-A:doc'], { title: 'doc' }, { scope: 'public' }); + await app.session.revoke(); + await flush(app.Orca); + expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined(); + + // Detach. The next identity change should leave cache untouched. + detachOrca(); + await app.cache.set(['user-A:doc'], { title: 'doc-2' }, { scope: 'public' }); + await app.session.adopt(sessionFor({ id: 'user-C' })); + await flush(app.Orca); + expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toEqual({ title: 'doc-2' }); + + // Re-attach so the afterEach detacher matches what's wired. + detachOrca = applyStandardOrca(app); + }); +}); + +// Sanity export of `SESSION_EVENT_*` to keep the imports honest if +// the file is ever pruned by an unused-import rule. Not part of the +// behavioural contract. +void SESSION_EVENT_IDENTITY_CHANGED; +void SESSION_EVENT_REVOKED;