Wires real `createActiveCache` + `createEngineSession` + a stubbed `perm`
+ stubbed `connections` + `applyStandardOrca`, then drives the canonical
A→logout→B flow to confirm cache/perm/connection reactions fire on
session lifecycle transitions.
Findings while writing the test, documented in the file header:
- `SESSION_EVENT_IDENTITY_CHANGED` only fires on identity-state
transitions (`none` ↔ `anonymous` ↔ `identified`), not on in-place
`adopt(A) → adopt(B)`. The realistic cross-actor flow is therefore
`adopt → revoke → adopt`, which the suite exercises end-to-end.
- The orca dispatches reactions through `void (async () => { ... })()`
microtask runs; flushing fixed rounds is flaky. The test polls
`Orca.running` until idle, capped to avoid hangs.
Coverage: B sees no cache/perm of A after re-login; revoke clears the
cache + closes connections; reauth fires only on identity-state
transitions; detaching the preset stops the reactions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
parent
c4b843ceca
commit
22aab00b7d
@ -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<Record<string, unknown>>;
|
||||
Timers: ActiveTimers;
|
||||
Orca: EngineOrca;
|
||||
cache: ActiveCache;
|
||||
perm: ActivePerms;
|
||||
connections: ActiveConnections;
|
||||
session: ActiveSession<User>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function buildApp(): FakeAppCore {
|
||||
const Logger = createEngineLogger({});
|
||||
const Timers = createActiveTimers({ logger: Logger });
|
||||
const Bus = createSvelteEngineBus<Record<string, unknown>>({
|
||||
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<User>({
|
||||
logger: Logger,
|
||||
bus: Bus
|
||||
}) as unknown as ActiveSession<User>;
|
||||
|
||||
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<void> {
|
||||
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<User> {
|
||||
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;
|
||||
Loading…
Reference in new issue