Files
TREK/server/tests/unit/nest/token.service.test.ts
T
2026-10-06 21:50:59 +02:00

407 lines
18 KiB
TypeScript

// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { db as testDb } from '../../../src/db/database';
import { revokeUserSessions } from '../../../src/mcp/sessionManager';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { createEphemeralToken } from '../../../src/nest/auth/ephemeral-tokens';
import { TokenService } from '../../../src/nest/tokens/token.service';
import { TokensModule } from '../../../src/nest/tokens/tokens.module';
import { createUser } from '../../helpers/factories';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { resetTestDb } from '../../helpers/test-db';
import { createTestMcpTokensRepo, createTestUsersRepo } 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/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
// 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 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 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 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++) {
testDb
.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)')
.run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
}
const result = await 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 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 svc.createMcpToken(user.id, 'Deletable Token');
const tokenId = String((created.token as any).id);
const result = await svc.deleteMcpToken(user.id, tokenId);
expect(result).toEqual({ success: true });
const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId);
expect(row).toBeUndefined();
});
it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => {
const { user } = createUser(testDb);
const created = await 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 svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true });
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined();
});
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 svc.createMcpToken(user.id, 'mine');
await svc.createMcpToken(other.id, 'theirs');
const mine = (await svc.listMcpTokens(user.id)) as Record<string, unknown>[];
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 svc.createMcpToken(other.id, 'not-yours');
const tokenId = String((created.token as { id: number }).id);
expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ error: 'Token not found', status: 404 });
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeDefined();
});
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 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 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 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 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 svc.createMcpToken(user.id, 'claude');
await svc.createApiToken(user.id, 'dawarich');
const mcp = (await svc.listMcpTokens(user.id)) as Record<string, unknown>[];
const api = (await svc.listApiTokens(user.id)) as Record<string, unknown>[];
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 svc.createMcpToken(user.id, 'claude')).token!.id as number);
const apiId = String((await svc.createApiToken(user.id, 'dawarich')).token!.id as number);
expect(await svc.deleteApiToken(user.id, mcpId)).toEqual({ error: 'Token not found', status: 404 });
expect(await 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(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(mcpId)).toBeDefined();
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(apiId)).toBeDefined();
});
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 svc.createMcpToken(user.id, `mcp-${i}`);
expect((await 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 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 svc.createApiToken(user.id, 'dawarich');
const raw = (created.token as { raw_token: string }).raw_token;
const row = testDb
.prepare('SELECT token_hash, token_prefix, kind FROM mcp_tokens WHERE user_id = ?')
.get(user.id) as { token_hash: string; token_prefix: string; kind: string };
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 svc.createApiToken(user.id, 'dawarich')).token!.raw_token as string;
const before = (await svc.listApiTokens(user.id)) as Record<string, unknown>[];
expect(before[0].last_used_at).toBeNull();
await svc.verifyApiToken(raw);
const after = (await svc.listApiTokens(user.id)) as Record<string, unknown>[];
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 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 svc.createMcpToken(user.id, 'a');
await 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<string,
// unknown>[]` 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 svc.createMcpToken(user.id, 'admin-killed');
const tokenId = String((created.token as { id: number }).id);
expect(await svc.adminDeleteMcpToken(tokenId)).toEqual({});
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined();
expect(revokeUserSessions).toHaveBeenCalledWith(user.id);
});
it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', async () => {
expect(await 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 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 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 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);
testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id);
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
await 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 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 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 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 svc.createMcpToken(user.id, 'stamped');
const raw = (created.token as { raw_token: string }).raw_token;
const id = (created.token as { id: number }).id;
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null })
.last_used_at,
).toBeNull();
await svc.verifyMcpToken(raw);
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null })
.last_used_at,
).not.toBeNull();
await svc.verifyMcpToken('trek_wrong');
expect(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens').get()).toEqual({ c: 1 });
});
it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => {
const { user } = createUser(testDb);
const created = await 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<string, unknown>;
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);
});
});