Bloque E — compound ecosystem cross-actor isolation test

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
dev 5 months ago
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…
Cancel
Save

Powered by TurnKey Linux.