// --------------------------------------------------------------------------- // Imports (after mocks) // --------------------------------------------------------------------------- import { db as testDb } from '../../../src/db/database'; import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { revokeUserSessions } from '../../../src/mcp/sessionManager'; import { EphemeralTokenService } from '../../../src/nest/auth-core/ephemeral-token.service'; import { createEphemeralToken } from '../../../src/nest/auth-core/ephemeral-tokens'; import { TokenService } from '../../../src/nest/tokens/token.service'; import { TokensModule } from '../../../src/nest/tokens/tokens.module'; import { asLegacyResult } from '../../helpers/domain-error'; import { createUser } from '../../helpers/factories'; import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; import { expectRegisteredProvider } from '../../helpers/module-providers'; import { resetTestDb } from '../../helpers/test-db'; import { createTestMcpTokensRepo, createTestUsersRepo } from '../../helpers/test-uow'; import { sharedTestOrm } from '../../helpers/test-uow'; import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; /** * token.service.test.ts * * DB-centric unit tests for TokenService against a real in-memory SQLite * database. The cases moved here with the methods, out of auth.service.test.ts; * the AUTH-DB-* / AUTH-BR-* case IDs are preserved so the history stays * greppable. Constructed directly (no TestingModule, repo convention). */ // --------------------------------------------------------------------------- // vi.hoisted: build the real in-memory DB and the module mock before any import // --------------------------------------------------------------------------- vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); const mock = { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => undefined, isOwner: () => false, }; return mock; }); vi.mock('../../../src/nest/auth-core/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); /** The token row, or null once it is gone. */ async function tokenRow(id: number | string) { return findRow(await sharedTestOrm(testDb), McpTokens, { id: Number(id) }); } // Schema first, then the ORM-backed repositories `TokenService` now takes // (Plan 3b Task 2) — `sharedTestOrm` (behind `createTestMcpTokensRepo`/ // `createTestUsersRepo`) shares this suite's own better-sqlite3 handle, so // the tables must already exist before it initialises. let svc: TokenService; beforeAll(async () => { svc = new TokenService( await createTestMcpTokensRepo(testDb), await createTestUsersRepo(testDb), new EphemeralTokenService(), ); }); beforeEach(() => { resetTestDb(testDb); vi.clearAllMocks(); }); afterAll(() => { testDb.close(); }); // --------------------------------------------------------------------------- // MCP tokens — the user-facing half // --------------------------------------------------------------------------- describe('MCP token service', () => { it('AUTH-DB-041: createMcpToken returns 400 when name is missing', async () => { const { user } = createUser(testDb); const result = await asLegacyResult(svc.createMcpToken(user.id, undefined)); expect(result.status).toBe(400); }); it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', async () => { const { user } = createUser(testDb); const result = await asLegacyResult(svc.createMcpToken(user.id, 'a'.repeat(101))); expect(result.status).toBe(400); }); it('AUTH-DB-043: createMcpToken creates token and returns raw_token', async () => { const { user } = createUser(testDb); const result = await asLegacyResult(svc.createMcpToken(user.id, 'My Token')); expect(result.token).toBeDefined(); expect((result.token as any).raw_token).toMatch(/^trek_/); }); it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', async () => { const { user } = createUser(testDb); for (let i = 0; i < 10; i++) { await insertRow(await sharedTestOrm(testDb), McpTokens, { user: user.id, name: `Token ${i}`, token_hash: `hash${i}`, token_prefix: `trek_prefix${i}`, }); } const result = await asLegacyResult(svc.createMcpToken(user.id, 'One More')); expect(result.status).toBe(400); }); it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', async () => { const { user } = createUser(testDb); const result = await asLegacyResult(svc.deleteMcpToken(user.id, '99999')); expect(result.status).toBe(404); }); it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(user.id, 'Deletable Token')); const tokenId = String((created.token as any).id); const result = await asLegacyResult(svc.deleteMcpToken(user.id, tokenId)); expect(result).toEqual({ success: true }); const row = await tokenRow(tokenId); expect(row).toBeNull(); }); it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(user.id, 'sweep-down')); const tokenId = String((created.token as { id: number }).id); vi.mocked(revokeUserSessions).mockImplementationOnce(() => { throw new Error('sweep down'); }); expect(await asLegacyResult(svc.deleteMcpToken(user.id, tokenId))).toEqual({ success: true }); expect(await tokenRow(tokenId)).toBeNull(); }); it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); await asLegacyResult(svc.createMcpToken(user.id, 'mine')); await asLegacyResult(svc.createMcpToken(other.id, 'theirs')); const mine = (await svc.listMcpTokens(user.id)) as Record[]; expect(mine).toHaveLength(1); expect(mine[0].name).toBe('mine'); expect(mine[0]).not.toHaveProperty('token_hash'); }); it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(other.id, 'not-yours')); const tokenId = String((created.token as { id: number }).id); expect(await asLegacyResult(svc.deleteMcpToken(user.id, tokenId))).toEqual({ error: 'Token not found', status: 404, }); expect(await tokenRow(tokenId)).not.toBeNull(); }); it('TOKEN-017: deleteMcpToken 404s (not 500) on a non-numeric id — Plan 3b Task 2 review, F1', async () => { const { user } = createUser(testDb); expect(await asLegacyResult(svc.deleteMcpToken(user.id, 'abc'))).toEqual({ error: 'Token not found', status: 404 }); }); it('TOKEN-018: deleteMcpToken 404s on a hex-shaped id — Number("0x10") is 16, a safe integer a bare Number() conversion would accept, but SQLite affinity never would (Plan 3b Task 2 review, F1)', async () => { const { user } = createUser(testDb); expect(await asLegacyResult(svc.deleteMcpToken(user.id, '0x10'))).toEqual({ error: 'Token not found', status: 404, }); }); }); // --------------------------------------------------------------------------- // API keys — the same table, a different door // // An MCP token drives every assistant tool; an API key reads trips over HTTP. // The kind is in the WHERE clause of every lookup, so these tests are the ones // that would catch a credential quietly opening the wrong surface. // --------------------------------------------------------------------------- describe('API key service', () => { it('TOKEN-010: an API key does not verify as an MCP token', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createApiToken(user.id, 'dawarich')); const raw = (created.token as { raw_token: string }).raw_token; expect((await svc.verifyApiToken(raw))?.id).toBe(user.id); expect(await svc.verifyMcpToken(raw)).toBeNull(); }); it('TOKEN-011: an MCP token does not verify as an API key', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(user.id, 'claude')); const raw = (created.token as { raw_token: string }).raw_token; expect((await svc.verifyMcpToken(raw))?.id).toBe(user.id); expect(await svc.verifyApiToken(raw)).toBeNull(); }); it('TOKEN-012: each list shows only its own kind', async () => { const { user } = createUser(testDb); await asLegacyResult(svc.createMcpToken(user.id, 'claude')); await asLegacyResult(svc.createApiToken(user.id, 'dawarich')); const mcp = (await svc.listMcpTokens(user.id)) as Record[]; const api = (await svc.listApiTokens(user.id)) as Record[]; expect(mcp.map((t) => t.name)).toEqual(['claude']); expect(api.map((t) => t.name)).toEqual(['dawarich']); }); it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', async () => { const { user } = createUser(testDb); const mcpId = String((await asLegacyResult(svc.createMcpToken(user.id, 'claude'))).token!.id as number); const apiId = String((await asLegacyResult(svc.createApiToken(user.id, 'dawarich'))).token!.id as number); expect(await asLegacyResult(svc.deleteApiToken(user.id, mcpId))).toEqual({ error: 'Token not found', status: 404 }); expect(await asLegacyResult(svc.deleteMcpToken(user.id, apiId))).toEqual({ error: 'Token not found', status: 404 }); // Neither row was touched: a wrong-kind delete must not be a way to revoke // someone's assistant access from the API-key screen. expect(await tokenRow(mcpId)).not.toBeNull(); expect(await tokenRow(apiId)).not.toBeNull(); }); it('TOKEN-014: the ten-token ceiling counts each kind on its own', async () => { const { user } = createUser(testDb); for (let i = 0; i < 10; i += 1) await asLegacyResult(svc.createMcpToken(user.id, `mcp-${i}`)); expect((await asLegacyResult(svc.createMcpToken(user.id, 'one-too-many'))).status).toBe(400); // A full MCP shelf must not lock the user out of minting an API key. expect((await asLegacyResult(svc.createApiToken(user.id, 'dawarich'))).status).toBeUndefined(); }); it('TOKEN-015: an API key is stored hashed, with only a prefix in the clear', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createApiToken(user.id, 'dawarich')); const raw = (created.token as { raw_token: string }).raw_token; const row = (await findRow(await sharedTestOrm(testDb), McpTokens, { user: user.id }))!; expect(row.kind).toBe('api'); expect(row.token_hash).not.toBe(raw); expect(raw.startsWith(row.token_prefix)).toBe(true); }); it('TOKEN-016: verifying an API key records last_used_at, so a stale key is visible', async () => { const { user } = createUser(testDb); const raw = (await asLegacyResult(svc.createApiToken(user.id, 'dawarich'))).token!.raw_token as string; const before = (await svc.listApiTokens(user.id)) as Record[]; expect(before[0].last_used_at).toBeNull(); await svc.verifyApiToken(raw); const after = (await svc.listApiTokens(user.id)) as Record[]; expect(after[0].last_used_at).not.toBeNull(); }); }); // --------------------------------------------------------------------------- // MCP tokens — the admin half // // Same table, no user scope. These came from AdminService, which only owned // them because the admin route lived there. // --------------------------------------------------------------------------- describe('MCP token service (admin view)', () => { it('ADMIN-SVC-068 — listAllMcpTokens returns empty array initially', async () => { const result = (await svc.listAllMcpTokens()) as any[]; expect(Array.isArray(result)).toBe(true); expect(result).toHaveLength(0); }); it('ADMIN-SVC-069 — adminDeleteMcpToken returns 404 for non-existent token', async () => { const result = (await asLegacyResult(svc.adminDeleteMcpToken('99999'))) as any; expect(result.status).toBe(404); expect(result.error).toBeDefined(); }); it('TOKEN-003: listAllMcpTokens spans users and carries the owner username', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); await asLegacyResult(svc.createMcpToken(user.id, 'a')); await asLegacyResult(svc.createMcpToken(other.id, 'b')); // `listAllMcpTokens` returns the repository's own typed // `McpTokenWithUsernameRow[]` now (Plan 3b Task 2) — no cast needed // (Task 2 review, F7: the earlier `as unknown as Record[]` was a double cast kept only to compile against the old // `unknown` return type). const all = await svc.listAllMcpTokens(); expect(all).toHaveLength(2); expect(all.every((t) => typeof t.username === 'string')).toBe(true); expect(all.every((t) => !('token_hash' in t))).toBe(true); }); it('TOKEN-004: adminDeleteMcpToken removes any user token and revokes that user', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(user.id, 'admin-killed')); const tokenId = String((created.token as { id: number }).id); expect(await asLegacyResult(svc.adminDeleteMcpToken(tokenId))).toEqual({}); expect(await tokenRow(tokenId)).toBeNull(); expect(revokeUserSessions).toHaveBeenCalledWith(user.id); }); it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', async () => { expect(await asLegacyResult(svc.adminDeleteMcpToken('99999'))).toEqual({ error: 'Token not found', status: 404 }); expect(revokeUserSessions).not.toHaveBeenCalled(); }); it('TOKEN-019: adminDeleteMcpToken 404s (not 500) on a non-numeric id, without revoking anyone — Plan 3b Task 2 review, F1', async () => { expect(await asLegacyResult(svc.adminDeleteMcpToken('abc'))).toEqual({ error: 'Token not found', status: 404 }); expect(revokeUserSessions).not.toHaveBeenCalled(); }); it('TOKEN-020: adminDeleteMcpToken 404s on a hex-shaped id (Plan 3b Task 2 review, F1)', async () => { expect(await asLegacyResult(svc.adminDeleteMcpToken('0x10'))).toEqual({ error: 'Token not found', status: 404 }); expect(revokeUserSessions).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Ephemeral tokens // --------------------------------------------------------------------------- describe('ephemeral tokens', () => { it('AUTH-DB-086: createResourceToken rejects a non-download purpose and 503s when the store is down', () => { const { user } = createUser(testDb); expect(svc.createResourceToken(user.id, 'exfiltrate')).toEqual({ error: 'Invalid purpose', status: 400 }); expect(svc.createResourceToken(user.id, 'download')).toEqual({ error: 'Service unavailable', status: 503 }); vi.mocked(createEphemeralToken).mockReturnValueOnce('tok-1'); expect(svc.createResourceToken(user.id, 'download')).toEqual({ token: 'tok-1' }); }); it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', async () => { const { user } = createUser(testDb); vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); expect(await asLegacyResult(svc.createWsToken(user.id))).toEqual({ token: 'ws-tok' }); }); it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', async () => { const { user } = createUser(testDb); await updateRows(await sharedTestOrm(testDb), Users, { id: user.id }, { password_version: 7 }); vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); await asLegacyResult(svc.createWsToken(user.id)); expect(createEphemeralToken).toHaveBeenCalledWith(user.id, 'ws', { pv: 7 }); }); it('TOKEN-007: createWsToken falls back to pv 0 for a user row without one', async () => { vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); await asLegacyResult(svc.createWsToken(99999)); expect(createEphemeralToken).toHaveBeenCalledWith(99999, 'ws', { pv: 0 }); }); it('TOKEN-008: createWsToken reports 503 when the store refuses', async () => { const { user } = createUser(testDb); vi.mocked(createEphemeralToken).mockReturnValueOnce(null as unknown as string); expect(await asLegacyResult(svc.createWsToken(user.id))).toEqual({ error: 'Service unavailable', status: 503 }); }); }); // --------------------------------------------------------------------------- // Verification // --------------------------------------------------------------------------- describe('verifyMcpToken', () => { it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(user.id, 'bridge-case')); const raw = (created.token as { raw_token: string }).raw_token; const resolved = await svc.verifyMcpToken(raw); expect(resolved?.id).toBe(user.id); expect(await svc.verifyMcpToken('trek_no_such_token')).toBeNull(); }); it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(user.id, 'stamped')); const raw = (created.token as { raw_token: string }).raw_token; const id = (created.token as { id: number }).id; expect((await tokenRow(id))!.last_used_at).toBeNull(); await svc.verifyMcpToken(raw); expect((await tokenRow(id))!.last_used_at).not.toBeNull(); await svc.verifyMcpToken('trek_wrong'); expect(await countRows(await sharedTestOrm(testDb), McpTokens)).toBe(1); }); it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => { const { user } = createUser(testDb); const created = await asLegacyResult(svc.createMcpToken(user.id, 'lean')); const raw = (created.token as { raw_token: string }).raw_token; const resolved = (await svc.verifyMcpToken(raw)) as unknown as Record; expect(Object.keys(resolved).sort()).toEqual(['email', 'id', 'role', 'username']); }); }); describe('TokensModule', () => { it('TOKEN-011: registers TokenService, without which every consumer fails to resolve', () => { expectRegisteredProvider(TokensModule, TokenService); }); });