diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 270e45609..4a138a935 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundExcepti import type { Request } from 'express'; import { readEnv } from '../../app-config'; import { AdminService } from './admin.service'; +import { TokenService } from '../tokens/token.service'; import { AdminUserCreateDto, AdminUserUpdateDto, @@ -61,6 +62,7 @@ export class AdminController { private readonly pluginRuntime: PluginRuntimeService, private readonly audit: AuditService, private readonly notifications: NotificationsService, + private readonly tokens: TokenService, ) {} // ── Users ── @@ -243,11 +245,11 @@ export class AdminController { // ── MCP tokens / OAuth sessions ── @Get('mcp-tokens') - listMcpTokens() { return { tokens: this.admin.listMcpTokens() }; } + listMcpTokens() { return { tokens: this.tokens.listAllMcpTokens() }; } @Delete('mcp-tokens/:id') deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - ok(this.admin.deleteMcpToken(id)); + ok(this.tokens.adminDeleteMcpToken(id)); this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } diff --git a/server/src/nest/admin/admin.module.ts b/server/src/nest/admin/admin.module.ts index 01b83a5ca..42cae6bb8 100644 --- a/server/src/nest/admin/admin.module.ts +++ b/server/src/nest/admin/admin.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { TokensModule } from '../tokens/tokens.module'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module'; @@ -18,7 +19,7 @@ import { PackingModule } from '../packing/packing.module'; import { PermissionsModule } from '../permissions/permissions.module'; @Module({ - imports: [PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule], + imports: [PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule], controllers: [AdminController], providers: [AdminService], }) diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 8d3e0a0ef..a56207aa2 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -667,25 +667,6 @@ export class AdminService { }; } - // ── MCP Tokens ───────────────────────────────────────────────────────────── - - listMcpTokens() { - return this.db.all(` - SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username - FROM mcp_tokens t - JOIN users u ON u.id = t.user_id - ORDER BY t.created_at DESC - `); - } - - deleteMcpToken(id: string) { - const token = this.db.get<{ id: number; user_id: number }>('SELECT id, user_id FROM mcp_tokens WHERE id = ?', id); - if (!token) return { error: 'Token not found', status: 404 }; - this.db.run('DELETE FROM mcp_tokens WHERE id = ?', id); - revokeUserSessions(token.user_id); - return {}; - } - // ── OAuth Sessions ───────────────────────────────────────────────────────── listOAuthSessions() { diff --git a/server/src/nest/auth/auth.bridge.ts b/server/src/nest/auth/auth.bridge.ts index eb0ee4b16..1de18f0e6 100644 --- a/server/src/nest/auth/auth.bridge.ts +++ b/server/src/nest/auth/auth.bridge.ts @@ -3,6 +3,7 @@ import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { AuthService } from './auth.service'; +import { TokenService } from '../tokens/token.service'; import { MailerService } from '../notifications/mailer/mailer.service'; import { UserCleanupService } from './user-cleanup.service'; import { WebauthnConfigService } from './webauthn-config.service'; @@ -46,6 +47,8 @@ const auth = new AuthService( new MailerService(dbs), ); +const tokens = new TokenService(dbs); + export { stripUserForClient } from './auth.helpers'; export { avatarUrl } from '../common/avatarUrl'; @@ -54,7 +57,7 @@ export function isDemoUser(userId: number): boolean { } export function verifyMcpToken(rawToken: string): User | null { - return auth.verifyMcpToken(rawToken); + return tokens.verifyMcpToken(rawToken); } export function verifyJwtToken(token: string): User | null { @@ -70,5 +73,5 @@ export function generateToken(user: { id: number | bigint; password_version?: nu } export function createWsToken(userId: number) { - return auth.createWsToken(userId); + return tokens.createWsToken(userId); } diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index 553667318..d37b572a0 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -23,6 +23,7 @@ import path from 'path'; import fs from 'fs'; import { v4 as uuid } from 'uuid'; import { AuthService } from './auth.service'; +import { TokenService } from '../tokens/token.service'; import { avatarDir } from './auth.helpers'; import { ChangePasswordDto, @@ -72,7 +73,7 @@ const AVATAR_UPLOAD = { @Controller('api/auth') @UseGuards(JwtAuthGuard) export class AuthController { - constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService, private readonly env: RuntimeEnvService) {} + constructor(private readonly auth: AuthService, private readonly tokens: TokenService, private readonly rl: RateLimitService, private readonly audit: AuditService, private readonly env: RuntimeEnvService) {} private limit(bucket: string, req: Request, max: number): void { if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { @@ -238,14 +239,14 @@ export class AuthController { @Get('mcp-tokens') listMcpTokens(@CurrentUser() user: User) { - return { tokens: this.auth.listMcpTokens(user.id) }; + return { tokens: this.tokens.listMcpTokens(user.id) }; } @Post('mcp-tokens') @HttpCode(201) createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) { this.limit('login', req, 5); - const result = this.auth.createMcpToken(user.id, body.name); + const result = this.tokens.createMcpToken(user.id, body.name); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -254,7 +255,7 @@ export class AuthController { @Delete('mcp-tokens/:id') deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) { - const result = this.auth.deleteMcpToken(user.id, id); + const result = this.tokens.deleteMcpToken(user.id, id); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -264,7 +265,7 @@ export class AuthController { @Post('ws-token') @HttpCode(200) wsToken(@CurrentUser() user: User) { - const result = this.auth.createWsToken(user.id); + const result = this.tokens.createWsToken(user.id); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -274,7 +275,7 @@ export class AuthController { @Post('resource-token') @HttpCode(200) resourceToken(@CurrentUser() user: User, @Body() body: ResourceTokenDto) { - const token = this.auth.createResourceToken(user.id, body.purpose); + const token = this.tokens.createResourceToken(user.id, body.purpose); if (!token) { throw new HttpException({ error: 'Service unavailable' }, 503); } diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index f611fd0c4..762e43025 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -1,5 +1,6 @@ import { RateLimitModule } from '../common/rate-limit.module'; import { Module } from '@nestjs/common'; +import { TokensModule } from '../tokens/tokens.module'; import { AuthPublicController } from './auth-public.controller'; import { AuthController } from './auth.controller'; import { PasskeyController } from './passkey.controller'; @@ -35,7 +36,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * through auth.bridge.ts. */ @Module({ - imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule], + imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], exports: [AuthService, PasskeyService, UserCleanupService], diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 877332f5c..0e7fa6f57 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -1231,76 +1231,11 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { } // ------------------------------------------------------------------------- - // MCP tokens - // ------------------------------------------------------------------------- - - listMcpTokens(userId: number) { - return this.db.all( - 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC', - userId - ); - } - - createMcpToken(userId: number, rawName: unknown): { error?: string; status?: number; token?: Record } { - const name = rawName as string | undefined; - if (!name?.trim()) return { error: 'Token name is required', status: 400 }; - if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 }; - - const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?', userId)!.count; - if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 }; - - const rawToken = 'trek_' + randomBytes(24).toString('hex'); - const tokenHash = createHash('sha256').update(rawToken).digest('hex'); - const tokenPrefix = rawToken.slice(0, 13); - - const result = this.db.run( - 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)', - userId, name.trim(), tokenHash, tokenPrefix - ); - - const token = this.db.get( - 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?', - result.lastInsertRowid - ); - - return { token: { ...(token as object), raw_token: rawToken } }; - } - - deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } { - const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?', tokenId, userId); - if (!token) return { error: 'Token not found', status: 404 }; - this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId); - // Best-effort, like the changePassword/resetPassword revocations: a session - // sweep failure must not turn a successful token delete into a 500. - try { revokeUserSessions?.(userId); } catch { /* best-effort */ } - return { success: true }; - } - - // ------------------------------------------------------------------------- - // Ephemeral tokens - // ------------------------------------------------------------------------- - - createWsToken(userId: number): { error?: string; status?: number; token?: string } { - // Bind the ws-token to the user's current password_version so a token minted - // before a password reset is rejected on connect (defence-in-depth session gate). - const pv = this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', userId)?.password_version ?? 0; - const token = createEphemeralToken(userId, 'ws', { pv }); - if (!token) return { error: 'Service unavailable', status: 503 }; - return { token }; - } - - createResourceToken(userId: number, rawPurpose: unknown): { error?: string; status?: number; token?: string } { - const purpose = rawPurpose as string | undefined; - if (purpose !== 'download') { - return { error: 'Invalid purpose', status: 400 }; - } - const token = createEphemeralToken(userId, purpose); - if (!token) return { error: 'Service unavailable', status: 503 }; - return { token }; - } - - // ------------------------------------------------------------------------- - // MCP auth helpers + // Demo gate + JWT verification + // + // The MCP token half of this section moved to tokens/token.service.ts. What + // stays is login identity (verifyJwtToken) and the demo check, neither of + // which is about minting a token. // ------------------------------------------------------------------------- isDemoUser(userId: number): boolean { @@ -1309,21 +1244,6 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { return isDemoEmail(user?.email); } - verifyMcpToken(rawToken: string): User | null { - const hash = createHash('sha256').update(rawToken).digest('hex'); - const row = this.db.get(` - SELECT u.id, u.username, u.email, u.role - FROM mcp_tokens mt - JOIN users u ON mt.user_id = u.id - WHERE mt.token_hash = ? - `, hash); - if (row) { - this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash); - return row; - } - return null; - } - /** * Verify a JWT the same way `auth/jwt-verify.ts#verifyJwtAndLoadUser` * does — including the `password_version` check — so that stolen tokens diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts new file mode 100644 index 000000000..c8bdca40e --- /dev/null +++ b/server/src/nest/tokens/token.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import { randomBytes, createHash } from 'crypto'; +import { DatabaseService } from '../database/database.service'; +import { createEphemeralToken } from '../auth/ephemeral-tokens'; +// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls +// the whole tools fan-out (and via the domain bridges, the Nest services) into +// every consumer of this module — a nest→mcp→nest module cycle. +import { revokeUserSessions } from '../../mcp/sessionManager'; +import { User } from '../../types'; + +/** + * Everything that mints or checks a token that is not the login JWT: the + * long-lived MCP tokens a user manages in settings, and the short-lived ws / + * download tokens. + * + * Split out of AuthService, which had grown to carry identity, profile, + * settings and tokens at once. Tokens are the cleanest cut of the four: they + * touch one table (mcp_tokens) plus the ephemeral-token store, and nothing in + * here needs to know how a password is hashed or how a session is established. + * + * The methods moved verbatim — same SQL, same validation order, same error + * strings and status codes, same best-effort session revoke on delete. + * + * Deliberately NOT here: verifyJwtToken (that is login identity, and it stays + * next to the cookie/JWT logic on AuthService) and isDemoUser (a demo gate that + * happens to read the users table). + */ +@Injectable() +export class TokenService { + constructor(private readonly db: DatabaseService) {} + + listMcpTokens(userId: number) { + return this.db.all( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC', + userId + ); + } + + createMcpToken(userId: number, rawName: unknown): { error?: string; status?: number; token?: Record } { + const name = rawName as string | undefined; + if (!name?.trim()) return { error: 'Token name is required', status: 400 }; + if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 }; + + const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?', userId)!.count; + if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 }; + + const rawToken = 'trek_' + randomBytes(24).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const tokenPrefix = rawToken.slice(0, 13); + + const result = this.db.run( + 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)', + userId, name.trim(), tokenHash, tokenPrefix + ); + + const token = this.db.get( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?', + result.lastInsertRowid + ); + + return { token: { ...(token as object), raw_token: rawToken } }; + } + + deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } { + const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?', tokenId, userId); + if (!token) return { error: 'Token not found', status: 404 }; + this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId); + // Best-effort, like the changePassword/resetPassword revocations: a session + // sweep failure must not turn a successful token delete into a 500. + try { revokeUserSessions?.(userId); } catch { /* best-effort */ } + return { success: true }; + } + + // ------------------------------------------------------------------------- + // Ephemeral tokens + // ------------------------------------------------------------------------- + + createWsToken(userId: number): { error?: string; status?: number; token?: string } { + // Bind the ws-token to the user's current password_version so a token minted + // before a password reset is rejected on connect (defence-in-depth session gate). + const pv = this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', userId)?.password_version ?? 0; + const token = createEphemeralToken(userId, 'ws', { pv }); + if (!token) return { error: 'Service unavailable', status: 503 }; + return { token }; + } + + createResourceToken(userId: number, rawPurpose: unknown): { error?: string; status?: number; token?: string } { + const purpose = rawPurpose as string | undefined; + if (purpose !== 'download') { + return { error: 'Invalid purpose', status: 400 }; + } + const token = createEphemeralToken(userId, purpose); + if (!token) return { error: 'Service unavailable', status: 503 }; + return { token }; + } + + // ------------------------------------------------------------------------- + // Admin view + // + // The same table, seen across all users. Moved here from AdminService, which + // owned a second copy of the delete purely because the admin route lived + // there. Note the two are genuinely different queries, not duplicates: the + // user-facing ones scope every statement by user_id, these deliberately do + // not, and the admin delete revokes sessions unconditionally where the + // user-facing one treats that as best-effort. + // ------------------------------------------------------------------------- + + listAllMcpTokens() { + return this.db.all(` + SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username + FROM mcp_tokens t + JOIN users u ON u.id = t.user_id + ORDER BY t.created_at DESC + `); + } + + adminDeleteMcpToken(id: string) { + const token = this.db.get<{ id: number; user_id: number }>('SELECT id, user_id FROM mcp_tokens WHERE id = ?', id); + if (!token) return { error: 'Token not found', status: 404 }; + this.db.run('DELETE FROM mcp_tokens WHERE id = ?', id); + revokeUserSessions(token.user_id); + return {}; + } + + // ------------------------------------------------------------------------- + // Verification + // ------------------------------------------------------------------------- + + verifyMcpToken(rawToken: string): User | null { + const hash = createHash('sha256').update(rawToken).digest('hex'); + const row = this.db.get(` + SELECT u.id, u.username, u.email, u.role + FROM mcp_tokens mt + JOIN users u ON mt.user_id = u.id + WHERE mt.token_hash = ? + `, hash); + if (row) { + this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash); + return row; + } + return null; + } +} diff --git a/server/src/nest/tokens/tokens.module.ts b/server/src/nest/tokens/tokens.module.ts new file mode 100644 index 000000000..3b4b8c38e --- /dev/null +++ b/server/src/nest/tokens/tokens.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TokenService } from './token.service'; + +/** + * Tokens that are not the login JWT: the long-lived MCP tokens a user manages + * in settings, plus the short-lived ws and download tokens. + * + * A leaf module on purpose. It needs nothing but DatabaseService, which keeps + * it cheap to import — AuthModule, AdminModule and the MCP transport all reach + * for it, and any dependency added here lands in all three graphs. + */ +@Module({ + providers: [TokenService], + exports: [TokenService], +}) +export class TokensModule {} diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 44fc48ef5..26697d967 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -7,6 +7,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', import type { AddonsService } from '../../../src/nest/addons/addons.service'; import { AdminController } from '../../../src/nest/admin/admin.controller'; +import type { TokenService } from '../../../src/nest/tokens/token.service'; import type { AdminService } from '../../../src/nest/admin/admin.service'; import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; @@ -42,8 +43,10 @@ const addonsStub = () => ({ updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: true })), }) as unknown as AddonsService; -const adminCtl = (s: AdminService, rt?: PluginRuntimeService, addons: AddonsService = addonsStub()) => - new AdminController(s, addons, rt as unknown as PluginRuntimeService, audit, notifications); +// The MCP-token routes read TokenService now, not AdminService. Stubbed via a +// fourth, optional argument so every existing call site stays as it was. +const adminCtl = (s: AdminService, rt?: PluginRuntimeService, addons: AddonsService = addonsStub(), tokens: Partial = {}) => + new AdminController(s, addons, rt as unknown as PluginRuntimeService, audit, notifications, tokens as TokenService); function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() }; @@ -153,7 +156,7 @@ describe('AdminController read-only getters', () => { expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listInvites()).toEqual({ invites: [{ id: 1 }] }); expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); - expect(adminCtl(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); + expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); expect(adminCtl(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); }); @@ -172,8 +175,8 @@ describe('AdminController read-only getters', () => { describe('AdminController tokens + sessions', () => { it('mcp token + oauth session deletes return success and map errors', () => { - expect(adminCtl(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial)).deleteMcpToken('2')).toEqual({ success: true }); - expect(thrown(() => adminCtl(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } }); + expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken('2')).toEqual({ success: true }); + expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } }); expect(thrown(() => adminCtl(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); }); }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 0d3edd483..99e151a1e 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -110,8 +110,6 @@ const getGithubReleases = (perPage?: string, page?: string) => svc.getGithubRele const checkVersion = () => svc.checkVersion(); const listAddons = () => svc.listAddons(); const updateAddon = (id: string, d: Parameters[1]) => svc.updateAddon(id, d); -const listMcpTokens = () => svc.listMcpTokens(); -const deleteMcpToken = (id: string) => svc.deleteMcpToken(id); beforeAll(() => { createTables(testDb); @@ -515,22 +513,6 @@ describe('updateAddon', () => { }); }); -// ── MCP Tokens ──────────────────────────────────────────────────────────────── - -describe('MCP Tokens', () => { - it('ADMIN-SVC-068 — listMcpTokens returns empty array initially', () => { - const result = listMcpTokens() as any[]; - expect(Array.isArray(result)).toBe(true); - expect(result).toHaveLength(0); - }); - - it('ADMIN-SVC-069 — deleteMcpToken returns 404 for non-existent token', () => { - const result = deleteMcpToken('99999') as any; - expect(result.status).toBe(404); - expect(result.error).toBeDefined(); - }); -}); - // ── admin.bridge ────────────────────────────────────────────────────────────── describe('admin.bridge', () => { diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index 99e84f2ed..fc0b1c488 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -9,6 +9,7 @@ vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false import { AuthPublicController } from '../../../src/nest/auth/auth-public.controller'; import { AuthController } from '../../../src/nest/auth/auth.controller'; +import type { TokenService } from '../../../src/nest/tokens/token.service'; import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; import type { AuthService } from '../../../src/nest/auth/auth.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; @@ -29,7 +30,10 @@ function rl(): RateLimitService { return new RateLimitService(); } const writeAudit = vi.fn(); const audit = { writeAudit } as unknown as AuditService; const apc = (a: AuthService, limiter: RateLimitService) => new AuthPublicController(a, limiter, audit); -const ac = (a: AuthService, limiter: RateLimitService) => new AuthController(a, limiter, audit, new RuntimeEnvService()); +// Tokens moved to TokenService; the controller takes it second. Stubbed via a +// third, optional argument so every non-token call site stays as it was. +const ac = (a: AuthService, limiter: RateLimitService, t: Partial = {}) => + new AuthController(a, t as TokenService, limiter, audit, new RuntimeEnvService()); function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { @@ -236,13 +240,13 @@ describe('AuthController (authenticated)', () => { const enable = ac(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial), rl()); expect(enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' })); - const tok = ac(asvc({ createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) } as Partial), rl()); + const tok = ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) }); expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' }); }); it('resource-token 503 when unavailable, else returns the token payload', () => { - expect(thrown(() => ac(asvc({ createResourceToken: vi.fn().mockReturnValue(null) } as Partial), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); - expect(ac(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial), rl()).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); + expect(thrown(() => ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue(null) }).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); + expect(ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); }); it('rate-limited account ops throw 429 once the bucket is exhausted', () => { @@ -320,15 +324,15 @@ describe('AuthController (authenticated)', () => { }); it('mcp-tokens list + create error + delete error/success', () => { - expect(ac(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] }); - expect(thrown(() => ac(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial), rl()).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); - expect(thrown(() => ac(asvc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) } as Partial), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); - expect(ac(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true }); + expect(ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] }); + expect(thrown(() => ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); + expect(thrown(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); + expect(ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true }); }); it('ws-token maps error, else returns the token', () => { - expect(thrown(() => ac(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); - expect(ac(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial), rl()).wsToken(user)).toEqual({ token: 'ws' }); + expect(thrown(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); + expect(ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' }); }); it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => { diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 2130db9da..7fdaea004 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -72,6 +72,7 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, createInviteToken, createTrip, createPlace, createReservation } from '../../helpers/factories'; import { AuthService } from '../../../src/nest/auth/auth.service'; +import { TokenService } from '../../../src/nest/tokens/token.service'; import * as authBridge from '../../../src/nest/auth/auth.bridge'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; @@ -93,6 +94,10 @@ const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerServi // join, not what the join writes (TRIP-JOIN-* cover that). const joinTripAsMember = vi.fn(); const membershipStub = { joinTripAsMember } as unknown as TripMembershipService; +// Tokens left AuthService for tokens/token.service.ts. The two cases below still +// need one as a fixture: changePassword prunes MCP tokens, and the bridge parity +// case verifies a token it just minted. +const tokens = new TokenService(new DatabaseService(testDb)); const svc = new AuthService( new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), @@ -631,7 +636,7 @@ describe('changePassword — session invalidation', () => { it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', () => { const { user, password } = createUser(testDb); - svc.createMcpToken(user.id, 'cli'); + tokens.createMcpToken(user.id, 'cli'); expect(pvOf(user.id)).toBe(0); expect(mcpCount(user.id)).toBe(1); @@ -699,60 +704,6 @@ describe('verifyMfaLogin — validation', () => { }); }); -// --------------------------------------------------------------------------- -// MCP token service -// --------------------------------------------------------------------------- - -describe('MCP token service', () => { - it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => { - const { user } = createUser(testDb); - const result = svc.createMcpToken(user.id, undefined); - expect(result.status).toBe(400); - }); - - it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => { - const { user } = createUser(testDb); - const result = svc.createMcpToken(user.id, 'a'.repeat(101)); - expect(result.status).toBe(400); - }); - - it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => { - const { user } = createUser(testDb); - const result = 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', () => { - 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 = svc.createMcpToken(user.id, 'One More'); - expect(result.status).toBe(400); - }); - - it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => { - const { user } = createUser(testDb); - const result = svc.deleteMcpToken(user.id, '99999'); - expect(result.status).toBe(404); - }); - - it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => { - const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'Deletable Token'); - const tokenId = String((created.token as any).id); - - const result = 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(); - }); -}); - // --------------------------------------------------------------------------- // Coverage added with the DI fold (AUTH-DB-050+): the fold moved ~1400 lines // under the src/nest coverage gate, so the previously untested branches — @@ -1125,20 +1076,6 @@ describe('resetPassword', () => { }); describe('ephemeral + demo helpers', () => { - 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', () => { - const { user } = createUser(testDb); - vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); - expect(svc.createWsToken(user.id)).toEqual({ token: 'ws-tok' }); - }); - it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', () => { const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const { user: other } = createUser(testDb); @@ -1188,16 +1125,6 @@ describe('auth quirk fixes', () => { expect(row.last_login).not.toBeNull(); }); - it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', () => { - const { user } = createUser(testDb); - const created = 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(svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true }); - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined(); - }); - it('AUTH-DB-093: updateApiKeys degrades gracefully when the user row is gone (no TypeError/500)', () => { expect(() => svc.updateApiKeys(999999, { maps_api_key: 'k' })).not.toThrow(); const result = svc.updateApiKeys(999999, { openweather_api_key: 'w' }); @@ -1217,7 +1144,7 @@ describe('auth.bridge delegation', () => { it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'bridge-case'); + const created = tokens.createMcpToken(user.id, 'bridge-case'); const raw = (created.token as { raw_token: string }).raw_token; const resolved = authBridge.verifyMcpToken(raw); expect(resolved?.id).toBe(user.id); diff --git a/server/tests/unit/nest/token.service.test.ts b/server/tests/unit/nest/token.service.test.ts new file mode 100644 index 000000000..a379b7d13 --- /dev/null +++ b/server/tests/unit/nest/token.service.test.ts @@ -0,0 +1,284 @@ +/** + * 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 +// --------------------------------------------------------------------------- + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + canAccessTrip: () => undefined, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); +vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +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 { DatabaseService } from '../../../src/nest/database/database.service'; +import { createEphemeralToken } from '../../../src/nest/auth/ephemeral-tokens'; +import { revokeUserSessions } from '../../../src/mcp/sessionManager'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; + +const svc = new TokenService(new DatabaseService(testDb)); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +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', () => { + const { user } = createUser(testDb); + const result = svc.createMcpToken(user.id, undefined); + expect(result.status).toBe(400); + }); + + it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => { + const { user } = createUser(testDb); + const result = svc.createMcpToken(user.id, 'a'.repeat(101)); + expect(result.status).toBe(400); + }); + + it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => { + const { user } = createUser(testDb); + const result = 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', () => { + 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 = svc.createMcpToken(user.id, 'One More'); + expect(result.status).toBe(400); + }); + + it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => { + const { user } = createUser(testDb); + const result = svc.deleteMcpToken(user.id, '99999'); + expect(result.status).toBe(404); + }); + + it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => { + const { user } = createUser(testDb); + const created = svc.createMcpToken(user.id, 'Deletable Token'); + const tokenId = String((created.token as any).id); + + const result = 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)', () => { + const { user } = createUser(testDb); + const created = 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(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', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + svc.createMcpToken(user.id, 'mine'); + svc.createMcpToken(other.id, 'theirs'); + + const mine = 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', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const created = svc.createMcpToken(other.id, 'not-yours'); + const tokenId = String((created.token as { id: number }).id); + + expect(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(); + }); +}); + +// --------------------------------------------------------------------------- +// 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', () => { + const result = 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', () => { + const result = 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', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + svc.createMcpToken(user.id, 'a'); + svc.createMcpToken(other.id, 'b'); + + const all = svc.listAllMcpTokens() as Record[]; + expect(all).toHaveLength(2); + expect(all.every(t => typeof t.username === 'string')).toBe(true); + expect(all.some(t => t.token_hash !== undefined)).toBe(false); + }); + + it('TOKEN-004: adminDeleteMcpToken removes any user token and revokes that user', () => { + const { user } = createUser(testDb); + const created = svc.createMcpToken(user.id, 'admin-killed'); + const tokenId = String((created.token as { id: number }).id); + + expect(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', () => { + expect(svc.adminDeleteMcpToken('99999')).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', () => { + const { user } = createUser(testDb); + vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); + expect(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', () => { + const { user } = createUser(testDb); + testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id); + vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); + + 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', () => { + vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); + svc.createWsToken(99999); + expect(createEphemeralToken).toHaveBeenCalledWith(99999, 'ws', { pv: 0 }); + }); + + it('TOKEN-008: createWsToken reports 503 when the store refuses', () => { + const { user } = createUser(testDb); + vi.mocked(createEphemeralToken).mockReturnValueOnce(null as unknown as string); + expect(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', () => { + const { user } = createUser(testDb); + const created = svc.createMcpToken(user.id, 'bridge-case'); + const raw = (created.token as { raw_token: string }).raw_token; + + const resolved = svc.verifyMcpToken(raw); + expect(resolved?.id).toBe(user.id); + expect(svc.verifyMcpToken('trek_no_such_token')).toBeNull(); + }); + + it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', () => { + const { user } = createUser(testDb); + const created = 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(); + + 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(); + + 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', () => { + const { user } = createUser(testDb); + const created = svc.createMcpToken(user.id, 'lean'); + const raw = (created.token as { raw_token: string }).raw_token; + + const resolved = 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); + }); +});