mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
402 lines
19 KiB
TypeScript
402 lines
19 KiB
TypeScript
/**
|
|
* 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() }));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Imports (after mocks)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { asLegacyResult } from '../../helpers/domain-error';
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser } from '../../helpers/factories';
|
|
import { TokenService } from '../../../src/nest/tokens/token.service';
|
|
import { TokensModule } from '../../../src/nest/tokens/tokens.module';
|
|
import { createEphemeralToken } from '../../../src/nest/auth-core/ephemeral-tokens';
|
|
import { revokeUserSessions } from '../../../src/mcp/sessionManager';
|
|
import { expectRegisteredProvider } from '../../helpers/module-providers';
|
|
import { EphemeralTokenService } from '../../../src/nest/auth-core/ephemeral-token.service';
|
|
import { createTestMcpTokensRepo, createTestUsersRepo } from '../../helpers/test-uow';
|
|
import { sharedTestOrm } from '../../helpers/test-uow';
|
|
import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
|
import { McpTokens } from '../../../src/db/entities/McpTokens.entity';
|
|
import { Users } from '../../../src/db/entities/Users.entity';
|
|
/** 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<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 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<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 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<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 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<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 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<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);
|
|
});
|
|
});
|