Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/backend/services/auth/AuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,108 @@ describe('AuthService (integration)', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});

// ── Revocation flow ────────────────────────────────────────

it('revokeSession on a worker session — authenticate returns reauth.session_revoked', async () => {
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const { token, session } =
await authService.createWorkerSessionToken(user, workerName);
const sessionUuid = (session as { uuid: string }).uuid;

await authService.revokeSession(sessionUuid);

const result = await authService.authenticate(token);
expect(result.actor).toBeUndefined();
expect(result.reauth).toEqual({
reason: 'session_revoked',
auth_id: user.uuid,
});
});

it('createWorkerSessionToken after revoke mints a new session uuid (composite cache invalidates)', async () => {
// Pre-fix, the worker composite cache could short-circuit
// back to the revoked row. Verify cache invalidation runs on
// revoke so the re-create produces a fresh row.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await authService.createWorkerSessionToken(
user,
workerName,
);
const firstUuid = (first.session as { uuid: string }).uuid;
await authService.revokeSession(firstUuid);

const second = await authService.createWorkerSessionToken(
user,
workerName,
);
const secondUuid = (second.session as { uuid: string }).uuid;
expect(secondUuid).not.toBe(firstUuid);

// The new JWT authenticates; the old one does not.
const oldResult = await authService.authenticate(first.token);
const newResult = await authService.authenticate(second.token);
expect(oldResult.actor).toBeUndefined();
expect(newResult.actor?.user.uuid).toBe(user.uuid);
});

it('createWorkerAppToken after revoke mints a new session uuid', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const firstJwt = await authService.createWorkerAppToken(
actor,
appUid,
workerName,
);
const firstDecoded = server.services.token.verify(
'auth',
firstJwt,
) as { session_uid: string };
await authService.revokeSession(firstDecoded.session_uid);

const secondJwt = await authService.createWorkerAppToken(
actor,
appUid,
workerName,
);
const secondDecoded = server.services.token.verify(
'auth',
secondJwt,
) as { session_uid: string };
expect(secondDecoded.session_uid).not.toBe(
firstDecoded.session_uid,
);
});

it('removeSessionByToken on a worker token soft-revokes the row', async () => {
// The logout / signout path lands here. Worker JWTs carry
// type='session' so the same code path applies; verify it
// flips revoked_at and authenticate stops resolving the actor.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const { token, session } =
await authService.createWorkerSessionToken(user, workerName);
const sessionUuid = (session as { uuid: string }).uuid;

await authService.removeSessionByToken(token);

const result = await authService.authenticate(token);
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('session_revoked');

// Row still present, just soft-revoked.
const rows = (await server.clients.db.read(
'SELECT `revoked_at` FROM `sessions` WHERE `uuid` = ? LIMIT 1',
[sessionUuid],
)) as Array<{ revoked_at: number | null }>;
expect(rows[0]?.revoked_at).not.toBeNull();
});
});

describe('appUidFromOrigin', () => {
Expand Down
223 changes: 223 additions & 0 deletions src/backend/stores/session/SessionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { PuterServer } from '../../server.ts';
import {
APP_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
WORKER_WINDOW_SECONDS,
} from './SessionStore.js';

describe('SessionStore', () => {
Expand Down Expand Up @@ -359,6 +360,228 @@ describe('SessionStore', () => {
});
});

describe('getOrCreateWorker', () => {
it('creates a kind="worker" row tagged meta.worker / meta.worker_name', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const row = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
auth_id: user.uuid,
});
expect(row).toBeTruthy();
expect(row.kind).toBe('worker');
expect(row.app_uid).toBe(appUid);
expect(row.user_id).toBe(user.id);
expect(row.parent_session_id).toBeNull();
expect(row.meta.worker).toBe(true);
expect(row.meta.worker_name).toBe(workerName);

// Sliding window seeded for worker — bound matches the live
// WORKER_WINDOW_SECONDS constant so a future bump to the window
// doesn't silently fail this assertion.
const now = Math.floor(Date.now() / 1000);
expect(row.expires_at).toBeGreaterThan(now);
expect(row.expires_at).toBeLessThanOrEqual(
now + WORKER_WINDOW_SECONDS + 5,
);

const raw = await rawRow(row.uuid);
expect(raw.kind).toBe('worker');
expect(raw.app_uid).toBe(appUid);
});

it('is idempotent on (user, app, worker_name)', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const a = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
const b = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(a.uuid).toBe(b.uuid);
});

it('is idempotent on (user, worker_name) when app_uid is null (user-scoped)', async () => {
// The partial unique index uses IFNULL(app_uid, '') so two
// user-scoped (app_uid=null) workers with the same worker_name
// still dedupe. Without IFNULL, SQLite would treat the NULLs as
// distinct per the SQL standard and let duplicates through.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const a = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
const b = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
expect(a.uuid).toBe(b.uuid);
expect(a.app_uid).toBeNull();
});

it('mints distinct rows for different worker_names under the same (user, app)', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const a = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}-a`,
});
const b = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}-b`,
});
expect(a.uuid).not.toBe(b.uuid);
});

it('does NOT collide with an interactive kind="app" row for the same (user, app)', async () => {
// Worker rows are intentionally carved out of the
// idx_sessions_user_app_active index (which is WHERE kind='app').
// An app session and a worker session for the same (user, app)
// must coexist.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const appRow = await target.getOrCreateApp(user.id, appUid);
const workerRow = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}`,
});
expect(appRow.uuid).not.toBe(workerRow.uuid);
expect(appRow.kind).toBe('app');
expect(workerRow.kind).toBe('worker');
});

it('converges to a single row under concurrent racers', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const results = await Promise.all(
Array.from({ length: 10 }, () =>
target.getOrCreateWorker(user.id, { appUid, workerName }),
),
);
const uuids = new Set(results.map((r: { uuid: string }) => r.uuid));
expect(uuids.size).toBe(1);

// And only one row ever made it into the table.
const rows = await server.clients.db.read(
"SELECT `uuid` FROM `sessions` WHERE `user_id` = ? AND `kind` = 'worker' AND `app_uid` = ?",
[user.id, appUid],
);
expect(rows).toHaveLength(1);
});

it('returns null when called with falsy inputs', async () => {
expect(
await target.getOrCreateWorker(null, { workerName: 'wk' }),
).toBeNull();
expect(await target.getOrCreateWorker(1, {})).toBeNull();
expect(
await target.getOrCreateWorker(1, { workerName: '' }),
).toBeNull();
});

it('mints a new row after the previous one was revoked', async () => {
// After revoke, the partial-unique index has no active row
// for (user_id, app_uid, worker_name), so the next call mints
// a fresh uuid instead of being short-circuited by the
// composite cache or by re-SELECTing the revoked row.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
await target.removeByUuid(first.uuid);
const second = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(second.uuid).not.toBe(first.uuid);
expect(second.kind).toBe('worker');
});

it('revokeCascade invalidates the worker composite cache', async () => {
// Mirrors the app cascade-invalidation test. First call primes
// the worker composite cache; cascading revoke must drop it so
// the next call doesn't serve the revoked row.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
await target.revokeCascade(first.uuid);
const second = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(second.uuid).not.toBe(first.uuid);
});
});

describe('error propagation (no silent swallow)', () => {
// INSERT-IGNORE used to mask every constraint violation, not just
// the partial-unique-index conflict the `getOrCreate*` paths rely
// on for idempotency. These tests pin the post-fix behavior: real
// schema errors throw, the unique-key conflict path still no-ops.

it('create() with an unsupported kind throws (CHECK constraint surfaces)', async () => {
// The `sessions.kind` column carries a CHECK constraint
// restricting it to the known set. A bogus kind must throw
// rather than be silently coerced or swallowed.
const user = await makeUser();
await expect(
target.create(user.id, { kind: 'not-a-real-kind' }),
).rejects.toThrow();
});

it('create() accepts kind="worker" (regression: post-migration the CHECK allows it)', async () => {
// Sanity check that migration 0056 actually relaxed the CHECK.
// Pre-migration this threw `CHECK constraint failed`.
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'worker',
meta: { worker: true, worker_name: 'direct' },
});
expect(session.kind).toBe('worker');
});

it('create() with a duplicate (user_id, app_uid) on kind="app" throws (UNIQUE surfaces)', async () => {
// `create()` runs with ignoreConflict=false, so the partial
// unique index `idx_sessions_user_app_active` fires loudly.
// This is the path that pre-fix `INSERT IGNORE` was silently
// collapsing — verify it now throws as expected.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
await target.create(user.id, { kind: 'app', app_uid: appUid });
await expect(
target.create(user.id, { kind: 'app', app_uid: appUid }),
).rejects.toThrow();
});

it('getOrCreateApp swallows the partial-unique conflict (idempotent get-or-create)', async () => {
// Counterpart to the previous test. The same INSERT path
// routed through getOrCreateApp (ignoreConflict=true) must
// still no-op the unique-key conflict and return the existing
// row instead of throwing.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const first = await target.getOrCreateApp(user.id, appUid);
await expect(
target.getOrCreateApp(user.id, appUid),
).resolves.toMatchObject({ uuid: first.uuid });
});
});

describe('touch slides expires_at per kind', () => {
it('extends expires_at on web sessions', async () => {
const user = await makeUser();
Expand Down
Loading