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.
This commit is contained in:
Maurice
2026-08-09 21:40:46 +02:00
committed by Maurice
parent e8d4278899
commit 03fbced20b
14 changed files with 497 additions and 229 deletions
+4 -2
View File
@@ -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 };
}
+2 -1
View File
@@ -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],
})
-19
View File
@@ -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() {
+5 -2
View File
@@ -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);
}
+7 -6
View File
@@ -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);
}
+2 -1
View File
@@ -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],
+5 -85
View File
@@ -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<string, unknown> } {
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<User>(`
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
+143
View File
@@ -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<string, unknown> } {
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<User>(`
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;
}
}
+16
View File
@@ -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 {}
@@ -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<TokenService> = {}) =>
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<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(adminCtl(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).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<AdminService>)).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<AdminService>)).deleteMcpToken('2')).toEqual({ success: true });
expect(thrown(() => adminCtl(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial<AdminService>)).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<AdminService>)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
});
});
@@ -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<AdminService['updateAddon']>[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', () => {
+14 -10
View File
@@ -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<TokenService> = {}) =>
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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } });
expect(ac(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial<AuthService>), 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<AuthService>), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
expect(thrown(() => ac(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial<AuthService>), 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<AuthService>), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(ac(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
expect(ac(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial<AuthService>), 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 () => {
+7 -80
View File
@@ -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);
@@ -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<string, unknown>[];
expect(mine).toHaveLength(1);
expect(mine[0].name).toBe('mine');
expect(mine[0]).not.toHaveProperty('token_hash');
});
it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', () => {
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<string, unknown>[];
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<string, unknown>;
expect(Object.keys(resolved).sort()).toEqual(['email', 'id', 'role', 'username']);
});
});
describe('TokensModule', () => {
it('TOKEN-011: registers TokenService, without which every consumer fails to resolve', () => {
expectRegisteredProvider(TokensModule, TokenService);
});
});