From bf5fe9a2f8fcfae3a1d368d755a416b5862ded3c Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 27 May 2026 12:20:45 -0700 Subject: [PATCH] tests: more tests for session and auth --- src/backend/services/auth/AuthService.test.ts | 102 ++++++++ .../stores/session/SessionStore.test.ts | 223 ++++++++++++++++++ 2 files changed, 325 insertions(+) diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 71d86360c9..74fcf92311 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -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', () => { diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts index dcbf9c0783..06b99524b7 100644 --- a/src/backend/stores/session/SessionStore.test.ts +++ b/src/backend/stores/session/SessionStore.test.ts @@ -24,6 +24,7 @@ import { PuterServer } from '../../server.ts'; import { APP_WINDOW_SECONDS, WEB_WINDOW_SECONDS, + WORKER_WINDOW_SECONDS, } from './SessionStore.js'; describe('SessionStore', () => { @@ -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();