From 03fbced20b076708e962714fdcc6e43711907d40 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 9 Aug 2026 21:40:46 +0200 Subject: [PATCH] Move tokens out of AuthService into a domain of their own AuthService carried identity, profile, settings and tokens at once. Tokens are the cleanest of those to cut loose: they touch one table plus the ephemeral-token store, and nothing in them needs to know how a password is hashed or how a session is established. TokenService takes the six user-facing methods plus verifyMcpToken, and the two admin-side ones that only lived on AdminService because the admin route did. The pairs are genuinely different queries rather than duplicates, so both survive under names that say which is which: the user-facing ones scope every statement by user_id and treat the session sweep as best-effort, the admin ones do neither. Left behind on purpose: verifyJwtToken, which is login identity and belongs next to the cookie logic, and isDemoUser, which is a demo gate that happens to read the users table. TokensModule is a leaf with nothing but DatabaseService, because auth, admin and the MCP transport all import it and anything added here lands in three graphs at once. Both bridges and both controller test harnesses construct these classes positionally and had to be pulled along; the new argument goes in an optional slot so untouched call sites stay untouched. --- server/src/nest/admin/admin.controller.ts | 6 +- server/src/nest/admin/admin.module.ts | 3 +- server/src/nest/admin/admin.service.ts | 19 -- server/src/nest/auth/auth.bridge.ts | 7 +- server/src/nest/auth/auth.controller.ts | 13 +- server/src/nest/auth/auth.module.ts | 3 +- server/src/nest/auth/auth.service.ts | 90 +----- server/src/nest/tokens/token.service.ts | 143 +++++++++ server/src/nest/tokens/tokens.module.ts | 16 + .../tests/unit/nest/admin.controller.test.ts | 13 +- server/tests/unit/nest/admin.service.test.ts | 18 -- .../tests/unit/nest/auth.controller.test.ts | 24 +- server/tests/unit/nest/auth.service.test.ts | 87 +----- server/tests/unit/nest/token.service.test.ts | 284 ++++++++++++++++++ 14 files changed, 497 insertions(+), 229 deletions(-) create mode 100644 server/src/nest/tokens/token.service.ts create mode 100644 server/src/nest/tokens/tokens.module.ts create mode 100644 server/tests/unit/nest/token.service.test.ts 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); + }); +});