diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index e3d141acc..41a15b737 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -306,11 +306,11 @@ export class AdminController { // ── MCP tokens / OAuth sessions ── @Get('mcp-tokens') - listMcpTokens() { return { tokens: this.tokens.listAllMcpTokens() }; } + async listMcpTokens() { return { tokens: await this.tokens.listAllMcpTokens() }; } @Delete('mcp-tokens/:id') async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - ok(this.tokens.adminDeleteMcpToken(id)); + ok(await this.tokens.adminDeleteMcpToken(id)); await 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/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index b7c1ef24f..6a182a5aa 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -297,16 +297,16 @@ export class AuthController { } @Get('mcp-tokens') - listMcpTokens(@CurrentUser() user: User) { - return { tokens: this.tokens.listMcpTokens(user.id) }; + async listMcpTokens(@CurrentUser() user: User) { + return { tokens: await this.tokens.listMcpTokens(user.id) }; } @ManagedForbidden('a static token never expires and carries every scope; OAuth covers the same ground') @Post('mcp-tokens') @HttpCode(201) - createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) { + async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) { this.limit('login', req, 5); - const result = this.tokens.createMcpToken(user.id, body.name); + const result = await this.tokens.createMcpToken(user.id, body.name); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -314,8 +314,8 @@ export class AuthController { } @Delete('mcp-tokens/:id') - deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) { - const result = this.tokens.deleteMcpToken(user.id, id); + async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) { + const result = await this.tokens.deleteMcpToken(user.id, id); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -335,18 +335,18 @@ export class AuthController { * whole reason it exists. */ @Get('api-tokens') - listApiTokens(@CurrentUser() user: User) { - return { tokens: this.tokens.listApiTokens(user.id) }; + async listApiTokens(@CurrentUser() user: User) { + return { tokens: await this.tokens.listApiTokens(user.id) }; } @Post('api-tokens') @HttpCode(201) - createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) { + async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) { this.limit('login', req, 5); // No `scopes` means the key reads everything, which is what every key minted // before this field existed does. Narrowing stays opt-in so the change // cannot break an integration that is already running. - const result = this.tokens.createApiToken(user.id, body.name, body.scopes); + const result = await this.tokens.createApiToken(user.id, body.name, body.scopes); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -354,8 +354,8 @@ export class AuthController { } @Delete('api-tokens/:id') - deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) { - const result = this.tokens.deleteApiToken(user.id, id); + async deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) { + const result = await this.tokens.deleteApiToken(user.id, id); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -364,14 +364,14 @@ export class AuthController { @Post('ws-token') @HttpCode(200) - wsToken(@CurrentUser() user: User) { + async wsToken(@CurrentUser() user: User) { // Own bucket, not 'login': a client that reconnects its socket in a loop // must not be able to lock itself out of signing in. The ceiling is far // above any real client, which mints one token per socket connect, but it // stops a single account from filling the process-wide ephemeral store and // 503-ing every other user's ws and download tokens. this.limitUser('ws_token', user.id, 120); - const result = this.tokens.createWsToken(user.id); + const result = await this.tokens.createWsToken(user.id); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index 5fb5ab782..74327f251 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -147,7 +147,7 @@ export class McpTransportService { // Long-lived static MCP token (trek_...) — full access + deprecation notice if (token.startsWith('trek_')) { - const user = this.tokens.verifyMcpToken(token); + const user = await this.tokens.verifyMcpToken(token); if (!user) return null; return { user, scopes: null, clientId: null, isStaticToken: true }; } diff --git a/server/src/nest/public-api/api-token.guard.ts b/server/src/nest/public-api/api-token.guard.ts index e173b0ff1..28f5eda1a 100644 --- a/server/src/nest/public-api/api-token.guard.ts +++ b/server/src/nest/public-api/api-token.guard.ts @@ -37,7 +37,7 @@ import { TokenService } from '../tokens/token.service'; export class ApiTokenGuard implements CanActivate { constructor(private readonly tokens: TokenService) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const token = extractApiToken(req); if (!token) { @@ -46,7 +46,7 @@ export class ApiTokenGuard implements CanActivate { 401, ); } - const resolved = this.tokens.verifyApiTokenWithGrant(token); + const resolved = await this.tokens.verifyApiTokenWithGrant(token); if (!resolved) { throw new HttpException( { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts index 294494c2c..458fd3553 100644 --- a/server/src/nest/tokens/token.service.ts +++ b/server/src/nest/tokens/token.service.ts @@ -43,8 +43,8 @@ export class TokenService { private readonly ephemeral: EphemeralTokenService, ) {} - listMcpTokens(userId: number) { - return this.listTokens(userId, 'mcp'); + async listMcpTokens(userId: number) { + return await this.listTokens(userId, 'mcp'); } /** @@ -57,11 +57,11 @@ export class TokenService { * radius nobody asked for, so `kind` keeps the two apart and each surface * verifies the one it accepts. */ - listApiTokens(userId: number) { - return this.listTokens(userId, 'api'); + async listApiTokens(userId: number) { + return await this.listTokens(userId, 'api'); } - private listTokens(userId: number, kind: TokenKind) { + private async listTokens(userId: number, kind: TokenKind) { const rows = this.db.all( 'SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC', userId, kind @@ -86,8 +86,8 @@ export class TokenService { }); } - createMcpToken(userId: number, rawName: unknown) { - return this.createToken(userId, rawName, 'mcp'); + async createMcpToken(userId: number, rawName: unknown) { + return await this.createToken(userId, rawName, 'mcp'); } /** @@ -97,11 +97,11 @@ export class TokenService { * before this argument existed. Narrowing is opt-in on purpose: shipping the * column must not change what an already-running integration can do. */ - createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) { - return this.createToken(userId, rawName, 'api', scopes); + async createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) { + return await this.createToken(userId, rawName, 'api', scopes); } - private createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): { error?: string; status?: number; token?: Record } { + private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ 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 }; @@ -135,12 +135,12 @@ export class TokenService { return { token: { ...(token as object), ...grant, raw_token: rawToken } }; } - deleteMcpToken(userId: number, tokenId: string) { - return this.deleteToken(userId, tokenId, 'mcp'); + async deleteMcpToken(userId: number, tokenId: string) { + return await this.deleteToken(userId, tokenId, 'mcp'); } - deleteApiToken(userId: number, tokenId: string) { - return this.deleteToken(userId, tokenId, 'api'); + async deleteApiToken(userId: number, tokenId: string) { + return await this.deleteToken(userId, tokenId, 'api'); } /** @@ -148,7 +148,7 @@ export class TokenService { * happily delete a token the MCP panel manages, and the user would find a key * missing from a screen they never opened. */ - private deleteToken(userId: number, tokenId: string, kind: TokenKind): { error?: string; status?: number; success?: boolean } { + private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ error?: string; status?: number; success?: boolean }> { const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ? AND kind = ?', tokenId, userId, kind); if (!token) return { error: 'Token not found', status: 404 }; this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId); @@ -162,7 +162,7 @@ export class TokenService { // Ephemeral tokens // ------------------------------------------------------------------------- - createWsToken(userId: number): { error?: string; status?: number; token?: string } { + async createWsToken(userId: number): Promise<{ 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; @@ -192,7 +192,7 @@ export class TokenService { // user-facing one treats that as best-effort. // ------------------------------------------------------------------------- - listAllMcpTokens() { + async 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 @@ -201,7 +201,7 @@ export class TokenService { `); } - adminDeleteMcpToken(id: string) { + async 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); @@ -213,13 +213,13 @@ export class TokenService { // Verification // ------------------------------------------------------------------------- - verifyMcpToken(rawToken: string): User | null { - return this.verifyToken(rawToken, 'mcp'); + async verifyMcpToken(rawToken: string): Promise { + return await this.verifyToken(rawToken, 'mcp'); } /** Verifies an integration key. An MCP token presented here does not resolve. */ - verifyApiToken(rawToken: string): User | null { - return this.verifyToken(rawToken, 'api'); + async verifyApiToken(rawToken: string): Promise { + return await this.verifyToken(rawToken, 'api'); } /** @@ -229,7 +229,7 @@ export class TokenService { * both halves, nothing else needs either, and the existing signature is * pinned by tests that assert exactly a `User`. */ - verifyApiTokenWithGrant(rawToken: string): { user: User; grant: PublicApiGrant } | null { + async verifyApiTokenWithGrant(rawToken: string): Promise<{ user: User; grant: PublicApiGrant } | null> { const hash = createHash('sha256').update(rawToken).digest('hex'); const row = this.db.get(` SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes @@ -251,7 +251,7 @@ export class TokenService { * neither the caller nor a timing measurement learns that the string was a * real credential for somewhere else. */ - private verifyToken(rawToken: string, kind: TokenKind): User | null { + private async verifyToken(rawToken: string, kind: TokenKind): Promise { const hash = createHash('sha256').update(rawToken).digest('hex'); const row = this.db.get(` SELECT u.id, u.username, u.email, u.role diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index eb31c2252..0a7e78006 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -190,7 +190,7 @@ describe('AdminController read-only getters', () => { await expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); expect(await adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] }); expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); - expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); + expect(await adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); expect(await adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); }); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index e33c5e9f9..14e5658ad 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -245,7 +245,7 @@ describe('AuthController (authenticated)', () => { expect(await 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({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) }); - expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' }); + expect(await tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' }); }); it('resource-token 503 when unavailable, else returns the token payload', () => { @@ -253,11 +253,11 @@ describe('AuthController (authenticated)', () => { expect(ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); }); - it('ws/resource tokens throttle on their own buckets, so a mint loop cannot drain the store or block login', () => { + it('ws/resource tokens throttle on their own buckets, so a mint loop cannot drain the store or block login', async () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 120; i++) s.check('ws_token', String(user.id), 120, 15 * 60 * 1000, now); - expect(thrown(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user))) + expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user))) .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); // The login bucket is untouched: an exhausted socket loop must not lock the account out. expect(s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now)).toBe(true); @@ -268,19 +268,19 @@ describe('AuthController (authenticated)', () => { .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); - it('the ws/resource ceilings are per account, not per address: one heavy user cannot 429 the office', () => { + it('the ws/resource ceilings are per account, not per address: one heavy user cannot 429 the office', async () => { const s = rl(); const now = Date.now(); const other = { ...user, id: 2 } as User; // Both users sit behind the same NAT address, and the first one burns its // whole ceiling. for (let i = 0; i < 120; i++) { - ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user); + await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user); } - expect(thrown(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user))) + expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user))) .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); - expect(ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws2' }) }).wsToken(other)).toEqual({ token: 'ws2' }); + expect(await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws2' }) }).wsToken(other)).toEqual({ token: 'ws2' }); expect(ac(asvc({}), s, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt2' }) }).resourceToken(other, {})).toEqual({ token: 'rt2' }); }); @@ -408,39 +408,39 @@ describe('AuthController (authenticated)', () => { expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' })); }); - it('mcp-tokens list + create error + delete error/success', () => { - 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('mcp-tokens list + create error + delete error/success', async () => { + expect(await ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] }); + expect(await thrownAsync(() => 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(await thrownAsync(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); + expect(await ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true }); }); // Same four paths as the MCP block above, against the other token kind. They are // separate routes on purpose — an API key and an MCP token open different doors — // so nothing here is implied by the MCP tests passing. - it('api-tokens list + create success/error + delete error/success', () => { - expect(ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] }); - expect(ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' }); - expect(thrown(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); - expect(thrown(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); - expect(ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true }); + it('api-tokens list + create success/error + delete error/success', async () => { + expect(await ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] }); + expect(await ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' }); + expect(await thrownAsync(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } }); + expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } }); + expect(await ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true }); }); // The create route shares the 'login' limiter bucket with the MCP one, at 5/window. // Unlike MCP tokens it is NOT refused on a managed instance, so the limiter is the // only thing standing between a scripted caller and an unbounded key list. - it('api-tokens create is rate limited after 5 in the window', () => { + it('api-tokens create is rate limited after 5 in the window', async () => { const limiter = rl(); const createApiToken = vi.fn().mockReturnValue({ token: 'trek_x' }); const ctl = ac(asvc({}), limiter, { createApiToken }); - for (let i = 0; i < 5; i++) expect(ctl.createApiToken(user, { name: `k${i}` }, req)).toEqual({ token: 'trek_x' }); - expect(thrown(() => ctl.createApiToken(user, { name: 'k5' }, req)).status).toBe(429); + for (let i = 0; i < 5; i++) expect(await ctl.createApiToken(user, { name: `k${i}` }, req)).toEqual({ token: 'trek_x' }); + expect((await thrownAsync(() => ctl.createApiToken(user, { name: 'k5' }, req))).status).toBe(429); expect(createApiToken).toHaveBeenCalledTimes(5); }); - it('ws-token maps error, else returns the token', () => { - 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('ws-token maps error, else returns the token', async () => { + expect(await thrownAsync(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); + expect(await 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 8f962a6b7..3182bdf9c 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -465,7 +465,7 @@ describe('changePassword — session invalidation', () => { it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', async () => { const { user, password } = createUser(testDb); - tokens.createMcpToken(user.id, 'cli'); + await tokens.createMcpToken(user.id, 'cli'); expect(pvOf(user.id)).toBe(0); expect(mcpCount(user.id)).toBe(1); @@ -1028,13 +1028,13 @@ describe('auth quirk fixes', () => { // --------------------------------------------------------------------------- describe('MCP token verification round-trips', () => { - it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => { + it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => { const { user } = createUser(testDb); - const created = tokens.createMcpToken(user.id, 'bridge-case'); + const created = await tokens.createMcpToken(user.id, 'bridge-case'); const raw = (created.token as { raw_token: string }).raw_token; - const resolved = tokens.verifyMcpToken(raw); + const resolved = await tokens.verifyMcpToken(raw); expect(resolved?.id).toBe(user.id); - expect(tokens.verifyMcpToken('trek_no_such_token')).toBeNull(); + expect(await tokens.verifyMcpToken('trek_no_such_token')).toBeNull(); }); it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', async () => { diff --git a/server/tests/unit/nest/public-api-scopes.test.ts b/server/tests/unit/nest/public-api-scopes.test.ts index 51a9b8c25..1b2d55587 100644 --- a/server/tests/unit/nest/public-api-scopes.test.ts +++ b/server/tests/unit/nest/public-api-scopes.test.ts @@ -229,11 +229,11 @@ describe('ApiTokenGuard — the grant it leaves behind', () => { }; } - it('PUBAPI-SCOPE-U010: puts the user AND the grant on the request, as two separate things', () => { + it('PUBAPI-SCOPE-U010: puts the user AND the grant on the request, as two separate things', async () => { const grant = limited('trips'); const { guard, verifyApiTokenWithGrant } = guardWith({ user: USER, grant }); const ctx = contextWith({ authorization: 'Bearer trek_abc123' }); - expect(guard.canActivate(ctx)).toBe(true); + expect(await guard.canActivate(ctx)).toBe(true); expect(verifyApiTokenWithGrant).toHaveBeenCalledWith('trek_abc123'); expect(ctx.req.user).toBe(USER); expect(ctx.req.apiToken).toBe(grant); @@ -247,25 +247,25 @@ describe('ApiTokenGuard — the grant it leaves behind', () => { * The two refusals predate scopes and are load-bearing for every integration * that already handles them. Adding a third answer must not reword them. */ - it('PUBAPI-SCOPE-U011: the 401 bodies are unchanged — no scope wording leaked into them', () => { + it('PUBAPI-SCOPE-U011: the 401 bodies are unchanged — no scope wording leaked into them', async () => { const { guard: noCredential } = guardWith(null); - expect(thrown(() => noCredential.canActivate(contextWith({})))).toEqual({ + expect(await thrownAsync(() => noCredential.canActivate(contextWith({})))).toEqual({ status: 401, body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, }); const { guard: unknownToken } = guardWith(null); expect( - thrown(() => unknownToken.canActivate(contextWith({ authorization: 'Bearer trek_nope' }))), + await thrownAsync(() => unknownToken.canActivate(contextWith({ authorization: 'Bearer trek_nope' }))), ).toEqual({ status: 401, body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, }); }); - it('PUBAPI-SCOPE-U012: a rejected token leaves no grant behind for a later handler to trust', () => { + it('PUBAPI-SCOPE-U012: a rejected token leaves no grant behind for a later handler to trust', async () => { const { guard } = guardWith(null); const ctx = contextWith({ authorization: 'Bearer trek_nope' }); - thrown(() => guard.canActivate(ctx)); + await thrownAsync(() => guard.canActivate(ctx)); expect(ctx.req.apiToken).toBeUndefined(); expect(ctx.req.user).toBeUndefined(); }); @@ -417,17 +417,17 @@ describe('TokenService — storing and resolving a grant', () => { api_scopes: string | null; }; - const mint = (userId: number, name: string, scopes?: readonly string[]) => - tokens.createApiToken(userId, name, scopes).token as { + const mint = async (userId: number, name: string, scopes?: readonly string[]) => + (await tokens.createApiToken(userId, name, scopes)).token as { id: number; raw_token: string; scope_mode: string; scopes: string[]; }; - it('PUBAPI-SCOPE-U040: a key minted without scopes is stored as a full grant, not an empty one', () => { + it('PUBAPI-SCOPE-U040: a key minted without scopes is stored as a full grant, not an empty one', async () => { const { user } = createUser(testDb); - const created = mint(user.id, 'Dawarich'); + const created = await mint(user.id, 'Dawarich'); // 'all' is written explicitly: a NULL sentinel here would mean a bug that // drops the column silently grants everything. expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null }); @@ -435,10 +435,10 @@ describe('TokenService — storing and resolving a grant', () => { expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]); }); - it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', () => { + it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', async () => { const { user } = createUser(testDb); - const raw = mint(user.id, 'Dawarich').raw_token; - const resolved = tokens.verifyApiTokenWithGrant(raw); + const raw = (await mint(user.id, 'Dawarich')).raw_token; + const resolved = await tokens.verifyApiTokenWithGrant(raw); expect(resolved?.user.id).toBe(user.id); expect(resolved?.grant).toEqual({ mode: 'all', scopes: [...PUBLIC_API_SCOPES] }); }); @@ -449,7 +449,7 @@ describe('TokenService — storing and resolving a grant', () => { * 'all', and a DatabaseService that hands back NULL anyway must land in the * same place. Both are "nobody ever narrowed this key". */ - it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', () => { + it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', async () => { const noColumns = new TokenService( { get: () => ({ id: 3, username: 'ada', email: 'a@b.c', role: 'user', scope_mode: null, api_scopes: null }), @@ -457,15 +457,15 @@ describe('TokenService — storing and resolving a grant', () => { } as unknown as DatabaseService, new EphemeralTokenService(), ); - expect(noColumns.verifyApiTokenWithGrant('trek_legacy')?.grant).toEqual({ + expect((await noColumns.verifyApiTokenWithGrant('trek_legacy'))?.grant).toEqual({ mode: 'all', scopes: [...PUBLIC_API_SCOPES], }); }); - it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', () => { + it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', async () => { const { user } = createUser(testDb); - const created = mint(user.id, 'Dawarich', ['stats', 'trips']); + const created = await mint(user.id, 'Dawarich', ['stats', 'trips']); expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'limited', @@ -476,81 +476,81 @@ describe('TokenService — storing and resolving a grant', () => { expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'stats'] }); }); - it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', () => { + it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', async () => { const { user } = createUser(testDb); - const raw = mint(user.id, 'Dawarich', ['trips', 'days']).raw_token; - expect(tokens.verifyApiTokenWithGrant(raw)?.grant).toEqual({ + const raw = (await mint(user.id, 'Dawarich', ['trips', 'days'])).raw_token; + expect((await tokens.verifyApiTokenWithGrant(raw))?.grant).toEqual({ mode: 'limited', scopes: ['trips', 'days'], }); }); - it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', () => { + it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', async () => { const { user } = createUser(testDb); - const created = mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']); + const created = await mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']); expect(rowFor(created.id).api_scopes).toBe(JSON.stringify(['trips'])); }); - it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', () => { + it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', async () => { const { user } = createUser(testDb); // Not an empty limited grant: that would mint a key that can read nothing, // which nobody asked for and which fails hours later at the integration. - const created = mint(user.id, 'Dawarich', ['everything']); + const created = await mint(user.id, 'Dawarich', ['everything']); expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); }); - it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', () => { + it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', async () => { const { user } = createUser(testDb); - expect(rowFor(mint(user.id, 'Dawarich', []).id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); + expect(rowFor((await mint(user.id, 'Dawarich', [])).id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); }); - it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', () => { + it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', async () => { // The direction matters. A row that never said 'limited' is a legacy // full-access key and stays one; a row that HAS said it is restricted must // never widen because its list became unreadable — that is the failure this // whole column exists to prevent. A key that stops working is a support // ticket; a key that quietly reads every trip is the bug. const { user } = createUser(testDb); - const created = mint(user.id, 'Dawarich', ['trips']); + const created = await mint(user.id, 'Dawarich', ['trips']); for (const broken of ['{"not":"a list"', 'null', '"trips"', '{}', '[]', '[42, null]']) { testDb.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?').run(broken, created.id); - expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({ + expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({ mode: 'limited', scopes: [], }); } }); - it('PUBAPI-SCOPE-U051: a narrowed key whose scopes were all renamed away denies rather than widens', () => { + it('PUBAPI-SCOPE-U051: a narrowed key whose scopes were all renamed away denies rather than widens', async () => { const { user } = createUser(testDb); - const created = mint(user.id, 'Dawarich', ['trips']); + const created = await mint(user.id, 'Dawarich', ['trips']); // What a future version dropping or renaming a scope constant leaves behind. testDb .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?') .run(JSON.stringify(['itineraries']), created.id); - expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({ + expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({ mode: 'limited', scopes: [], }); }); - it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', () => { + it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', async () => { const { user } = createUser(testDb); - const created = mint(user.id, 'Dawarich', ['trips']); + const created = await mint(user.id, 'Dawarich', ['trips']); testDb .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?') .run(JSON.stringify(['trips', 42, 'nope', 'stats']), created.id); - expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({ + expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({ mode: 'limited', scopes: ['trips', 'stats'], }); }); - it('PUBAPI-SCOPE-U050: the list route reports the grant of every key, narrowed or not', () => { + it('PUBAPI-SCOPE-U050: the list route reports the grant of every key, narrowed or not', async () => { const { user } = createUser(testDb); - mint(user.id, 'Wide'); - mint(user.id, 'Narrow', ['trips']); - const listed = tokens.listApiTokens(user.id) as Array>; + await mint(user.id, 'Wide'); + await mint(user.id, 'Narrow', ['trips']); + const listed = await tokens.listApiTokens(user.id) as Array>; expect(listed).toHaveLength(2); expect(listed.map((t) => [t.name, t.scope_mode, t.scopes])).toEqual( expect.arrayContaining([ @@ -562,13 +562,13 @@ describe('TokenService — storing and resolving a grant', () => { expect(JSON.stringify(listed)).not.toContain('token_hash'); }); - it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', () => { + it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', async () => { const { user } = createUser(testDb); - const mcp = tokens.createMcpToken(user.id, 'Assistant').token as { id: number }; + const mcp = (await tokens.createMcpToken(user.id, 'Assistant')).token as { id: number }; // Stored as the column default, because an MCP token carries no read scopes // and must not look as if it did. expect(rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); - const listed = tokens.listMcpTokens(user.id) as Array>; + const listed = await tokens.listMcpTokens(user.id) as Array>; expect(listed).toHaveLength(1); // The MCP panel cannot act on scopes, so it is not shown two columns that // would always say the same thing. @@ -579,22 +579,22 @@ describe('TokenService — storing and resolving a grant', () => { ); }); - it('PUBAPI-SCOPE-U052: an API key still does not verify as an MCP token, scopes or not', () => { + it('PUBAPI-SCOPE-U052: an API key still does not verify as an MCP token, scopes or not', async () => { const { user } = createUser(testDb); - const raw = mint(user.id, 'Dawarich', ['trips']).raw_token; - expect(tokens.verifyMcpToken(raw)).toBeNull(); - expect(tokens.verifyApiTokenWithGrant(raw)).not.toBeNull(); + const raw = (await mint(user.id, 'Dawarich', ['trips'])).raw_token; + expect(await tokens.verifyMcpToken(raw)).toBeNull(); + expect(await tokens.verifyApiTokenWithGrant(raw)).not.toBeNull(); }); - it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', () => { + it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', async () => { const { user } = createUser(testDb); - const created = mint(user.id, 'Dawarich', ['trips']); + const created = await mint(user.id, 'Dawarich', ['trips']); expect( (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as { last_used_at: string | null; }).last_used_at, ).toBeNull(); - tokens.verifyApiTokenWithGrant(created.raw_token); + await tokens.verifyApiTokenWithGrant(created.raw_token); expect( (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as { last_used_at: string | null; @@ -619,13 +619,13 @@ describe('TokenService — storing and resolving a grant', () => { ); const httpReq = { ip: '9.9.9.9', headers: {} } as Request; - it('PUBAPI-SCOPE-U060: POST /api/auth/api-tokens stores the scopes it was given', () => { + it('PUBAPI-SCOPE-U060: POST /api/auth/api-tokens stores the scopes it was given', async () => { const { user } = createUser(testDb); - const created = authController().createApiToken( + const created = (await authController().createApiToken( { id: user.id } as User, { name: 'Dawarich', scopes: ['trips', 'days'] }, httpReq, - ).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string }; + )).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string }; expect(rowFor(created.id)).toMatchObject({ scope_mode: 'limited', @@ -636,42 +636,42 @@ describe('TokenService — storing and resolving a grant', () => { expect(created.raw_token).toMatch(/^trek_/); }); - it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', () => { + it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', async () => { const { user } = createUser(testDb); const ctl = authController(); - ctl.createApiToken({ id: user.id } as User, { name: 'Dawarich', scopes: ['bucket-list'] }, httpReq); - expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({ + await ctl.createApiToken({ id: user.id } as User, { name: 'Dawarich', scopes: ['bucket-list'] }, httpReq); + expect(await ctl.listApiTokens({ id: user.id } as User)).toEqual({ tokens: [expect.objectContaining({ name: 'Dawarich', scope_mode: 'limited', scopes: ['bucket-list'] })], }); }); - it('PUBAPI-SCOPE-U062: a key minted with no scopes field is a full grant, as it always was', () => { + it('PUBAPI-SCOPE-U062: a key minted with no scopes field is a full grant, as it always was', async () => { const { user } = createUser(testDb); const ctl = authController(); - const created = ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq).token as { + const created = (await ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq)).token as { id: number; }; expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); - expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({ + expect(await ctl.listApiTokens({ id: user.id } as User)).toEqual({ tokens: [expect.objectContaining({ scope_mode: 'all', scopes: [...PUBLIC_API_SCOPES] })], }); }); - it('PUBAPI-SCOPE-U063: POST /api/auth/mcp-tokens ignores scopes in the body entirely', () => { + it('PUBAPI-SCOPE-U063: POST /api/auth/mcp-tokens ignores scopes in the body entirely', async () => { const { user } = createUser(testDb); const ctl = authController(); // The DTO is a different schema on purpose, so the field never survives the // pipe — and even handed straight to the controller it changes nothing. - const created = ctl.createMcpToken( + const created = (await ctl.createMcpToken( { id: user.id } as User, { name: 'Assistant', scopes: ['trips'] } as { name: string }, httpReq, - ).token as Record; + )).token as Record; expect(rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); expect(created).not.toHaveProperty('scope_mode'); expect(created).not.toHaveProperty('scopes'); - expect(ctl.listMcpTokens({ id: user.id } as User).tokens[0]).not.toHaveProperty('scope_mode'); + expect((await ctl.listMcpTokens({ id: user.id } as User)).tokens[0]).not.toHaveProperty('scope_mode'); }); it('PUBAPI-SCOPE-U064: the two create schemas differ, which is what keeps them apart', () => { diff --git a/server/tests/unit/nest/public-api.guard.test.ts b/server/tests/unit/nest/public-api.guard.test.ts index a5094534e..c2441520c 100644 --- a/server/tests/unit/nest/public-api.guard.test.ts +++ b/server/tests/unit/nest/public-api.guard.test.ts @@ -37,9 +37,9 @@ function makeGuard(verify: (raw: string) => User | null) { } /** Run the guard, expecting a refusal; return its { status, body }. */ -function refused(fn: () => boolean): { status: number; body: unknown } { +async function refused(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { - fn(); + await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -49,51 +49,51 @@ function refused(fn: () => boolean): { status: number; body: unknown } { } describe('ApiTokenGuard', () => { - it('accepts a trek_ token via Authorization: Bearer and resolves the user', () => { + it('accepts a trek_ token via Authorization: Bearer and resolves the user', async () => { const verify = vi.fn().mockReturnValue(USER); const ctx = contextWith({ authorization: 'Bearer trek_abc123' }); - expect(makeGuard(verify).canActivate(ctx)).toBe(true); + expect(await makeGuard(verify).canActivate(ctx)).toBe(true); expect(verify).toHaveBeenCalledWith('trek_abc123'); expect(ctx.req.user).toBe(USER); }); - it('accepts the same token via X-API-Key', () => { + it('accepts the same token via X-API-Key', async () => { const verify = vi.fn().mockReturnValue(USER); const ctx = contextWith({ 'x-api-key': 'trek_abc123' }); - expect(makeGuard(verify).canActivate(ctx)).toBe(true); + expect(await makeGuard(verify).canActivate(ctx)).toBe(true); expect(verify).toHaveBeenCalledWith('trek_abc123'); }); - it('trims surrounding whitespace before looking a token up', () => { + it('trims surrounding whitespace before looking a token up', async () => { const verify = vi.fn().mockReturnValue(USER); - makeGuard(verify).canActivate(contextWith({ 'x-api-key': ' trek_abc123 ' })); + await makeGuard(verify).canActivate(contextWith({ 'x-api-key': ' trek_abc123 ' })); expect(verify).toHaveBeenCalledWith('trek_abc123'); }); - it('401s without any credential', () => { + it('401s without any credential', async () => { const verify = vi.fn(); - expect(refused(() => makeGuard(verify).canActivate(contextWith({})))).toEqual({ + expect(await refused(() => makeGuard(verify).canActivate(contextWith({})))).toEqual({ status: 401, body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, }); expect(verify).not.toHaveBeenCalled(); }); - it('never reaches for the MCP verifier, so the two credentials cannot swap', () => { + it('never reaches for the MCP verifier, so the two credentials cannot swap', async () => { const verifyApiTokenWithGrant = vi .fn() .mockReturnValue({ user: USER, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } }); const verifyMcpToken = vi.fn(); const guard = new ApiTokenGuard({ verifyApiTokenWithGrant, verifyMcpToken } as unknown as TokenService); - guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' })); + await guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' })); expect(verifyApiTokenWithGrant).toHaveBeenCalledTimes(1); expect(verifyMcpToken).not.toHaveBeenCalled(); }); - it('401s on a token the store does not know, without saying which part was wrong', () => { + it('401s on a token the store does not know, without saying which part was wrong', async () => { const verify = vi.fn().mockReturnValue(null); expect( - refused(() => makeGuard(verify).canActivate(contextWith({ authorization: 'Bearer trek_nope' }))), + await refused(() => makeGuard(verify).canActivate(contextWith({ authorization: 'Bearer trek_nope' }))), ).toEqual({ status: 401, body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, @@ -112,19 +112,19 @@ describe('ApiTokenGuard', () => { ['a non-Bearer scheme', { authorization: 'Basic dHJlazpwdw==' }], ['a trek_ token in the wrong scheme', { authorization: 'Token trek_abc123' }], ['an X-API-Key that is not a trek_ token', { 'x-api-key': 'eyJhbGciOiJIUzI1NiJ9.p.s' }], - ])('refuses %s without ever hitting the token store', (_label, headers) => { + ])('refuses %s without ever hitting the token store', async (_label, headers) => { const verify = vi.fn(); - expect(refused(() => makeGuard(verify).canActivate(contextWith(headers)))).toEqual({ + expect(await refused(() => makeGuard(verify).canActivate(contextWith(headers)))).toEqual({ status: 401, body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, }); expect(verify).not.toHaveBeenCalled(); }); - it('ignores an array-valued Authorization header rather than coercing it', () => { + it('ignores an array-valued Authorization header rather than coercing it', async () => { const verify = vi.fn(); const ctx = contextWith({ authorization: ['Bearer trek_abc'] as unknown as string }); - expect(refused(() => makeGuard(verify).canActivate(ctx)).status).toBe(401); + expect((await refused(() => makeGuard(verify).canActivate(ctx))).status).toBe(401); expect(verify).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/token.service.test.ts b/server/tests/unit/nest/token.service.test.ts index f0383b815..632417b9b 100644 --- a/server/tests/unit/nest/token.service.test.ts +++ b/server/tests/unit/nest/token.service.test.ts @@ -69,83 +69,83 @@ afterAll(() => { // --------------------------------------------------------------------------- describe('MCP token service', () => { - it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => { + it('AUTH-DB-041: createMcpToken returns 400 when name is missing', async () => { const { user } = createUser(testDb); - const result = svc.createMcpToken(user.id, undefined); + const result = await svc.createMcpToken(user.id, undefined); expect(result.status).toBe(400); }); - it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => { + it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', async () => { const { user } = createUser(testDb); - const result = svc.createMcpToken(user.id, 'a'.repeat(101)); + const result = await svc.createMcpToken(user.id, 'a'.repeat(101)); expect(result.status).toBe(400); }); - it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => { + it('AUTH-DB-043: createMcpToken creates token and returns raw_token', async () => { const { user } = createUser(testDb); - const result = svc.createMcpToken(user.id, 'My Token'); + const result = await svc.createMcpToken(user.id, 'My Token'); expect(result.token).toBeDefined(); expect((result.token as any).raw_token).toMatch(/^trek_/); }); - it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', () => { + it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', async () => { const { user } = createUser(testDb); for (let i = 0; i < 10; i++) { testDb.prepare( 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)' ).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`); } - const result = svc.createMcpToken(user.id, 'One More'); + const result = await svc.createMcpToken(user.id, 'One More'); expect(result.status).toBe(400); }); - it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => { + it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', async () => { const { user } = createUser(testDb); - const result = svc.deleteMcpToken(user.id, '99999'); + const result = await svc.deleteMcpToken(user.id, '99999'); expect(result.status).toBe(404); }); - it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => { + it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', async () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'Deletable Token'); + const created = await svc.createMcpToken(user.id, 'Deletable Token'); const tokenId = String((created.token as any).id); - const result = svc.deleteMcpToken(user.id, tokenId); + const result = await svc.deleteMcpToken(user.id, tokenId); expect(result).toEqual({ success: true }); const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId); expect(row).toBeUndefined(); }); - it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', () => { + it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'sweep-down'); + const created = await svc.createMcpToken(user.id, 'sweep-down'); const tokenId = String((created.token as { id: number }).id); vi.mocked(revokeUserSessions).mockImplementationOnce(() => { throw new Error('sweep down'); }); - expect(svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true }); + expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true }); expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined(); }); - it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', () => { + it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - svc.createMcpToken(user.id, 'mine'); - svc.createMcpToken(other.id, 'theirs'); + await svc.createMcpToken(user.id, 'mine'); + await svc.createMcpToken(other.id, 'theirs'); - const mine = svc.listMcpTokens(user.id) as Record[]; + const mine = await 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', () => { + it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - const created = svc.createMcpToken(other.id, 'not-yours'); + const created = await 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(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ error: 'Token not found', status: 404 }); expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeDefined(); }); }); @@ -159,60 +159,60 @@ describe('MCP token service', () => { // --------------------------------------------------------------------------- describe('API key service', () => { - it('TOKEN-010: an API key does not verify as an MCP token', () => { + it('TOKEN-010: an API key does not verify as an MCP token', async () => { const { user } = createUser(testDb); - const created = svc.createApiToken(user.id, 'dawarich'); + const created = await svc.createApiToken(user.id, 'dawarich'); const raw = (created.token as { raw_token: string }).raw_token; - expect(svc.verifyApiToken(raw)?.id).toBe(user.id); - expect(svc.verifyMcpToken(raw)).toBeNull(); + expect((await svc.verifyApiToken(raw))?.id).toBe(user.id); + expect(await svc.verifyMcpToken(raw)).toBeNull(); }); - it('TOKEN-011: an MCP token does not verify as an API key', () => { + it('TOKEN-011: an MCP token does not verify as an API key', async () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'claude'); + const created = await svc.createMcpToken(user.id, 'claude'); const raw = (created.token as { raw_token: string }).raw_token; - expect(svc.verifyMcpToken(raw)?.id).toBe(user.id); - expect(svc.verifyApiToken(raw)).toBeNull(); + expect((await svc.verifyMcpToken(raw))?.id).toBe(user.id); + expect(await svc.verifyApiToken(raw)).toBeNull(); }); - it('TOKEN-012: each list shows only its own kind', () => { + it('TOKEN-012: each list shows only its own kind', async () => { const { user } = createUser(testDb); - svc.createMcpToken(user.id, 'claude'); - svc.createApiToken(user.id, 'dawarich'); + await svc.createMcpToken(user.id, 'claude'); + await svc.createApiToken(user.id, 'dawarich'); - const mcp = svc.listMcpTokens(user.id) as Record[]; - const api = svc.listApiTokens(user.id) as Record[]; + const mcp = await svc.listMcpTokens(user.id) as Record[]; + const api = await svc.listApiTokens(user.id) as Record[]; expect(mcp.map((t) => t.name)).toEqual(['claude']); expect(api.map((t) => t.name)).toEqual(['dawarich']); }); - it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', () => { + it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', async () => { const { user } = createUser(testDb); - const mcpId = String((svc.createMcpToken(user.id, 'claude').token as { id: number }).id); - const apiId = String((svc.createApiToken(user.id, 'dawarich').token as { id: number }).id); + const mcpId = String((await svc.createMcpToken(user.id, 'claude')).token!.id as number); + const apiId = String((await svc.createApiToken(user.id, 'dawarich')).token!.id as number); - expect(svc.deleteApiToken(user.id, mcpId)).toEqual({ error: 'Token not found', status: 404 }); - expect(svc.deleteMcpToken(user.id, apiId)).toEqual({ error: 'Token not found', status: 404 }); + expect(await svc.deleteApiToken(user.id, mcpId)).toEqual({ error: 'Token not found', status: 404 }); + expect(await svc.deleteMcpToken(user.id, apiId)).toEqual({ error: 'Token not found', status: 404 }); // Neither row was touched: a wrong-kind delete must not be a way to revoke // someone's assistant access from the API-key screen. expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(mcpId)).toBeDefined(); expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(apiId)).toBeDefined(); }); - it('TOKEN-014: the ten-token ceiling counts each kind on its own', () => { + it('TOKEN-014: the ten-token ceiling counts each kind on its own', async () => { const { user } = createUser(testDb); - for (let i = 0; i < 10; i += 1) svc.createMcpToken(user.id, `mcp-${i}`); + for (let i = 0; i < 10; i += 1) await svc.createMcpToken(user.id, `mcp-${i}`); - expect(svc.createMcpToken(user.id, 'one-too-many').status).toBe(400); + expect((await svc.createMcpToken(user.id, 'one-too-many')).status).toBe(400); // A full MCP shelf must not lock the user out of minting an API key. - expect(svc.createApiToken(user.id, 'dawarich').status).toBeUndefined(); + expect((await svc.createApiToken(user.id, 'dawarich')).status).toBeUndefined(); }); - it('TOKEN-015: an API key is stored hashed, with only a prefix in the clear', () => { + it('TOKEN-015: an API key is stored hashed, with only a prefix in the clear', async () => { const { user } = createUser(testDb); - const created = svc.createApiToken(user.id, 'dawarich'); + const created = await svc.createApiToken(user.id, 'dawarich'); const raw = (created.token as { raw_token: string }).raw_token; const row = testDb @@ -223,15 +223,15 @@ describe('API key service', () => { expect(raw.startsWith(row.token_prefix)).toBe(true); }); - it('TOKEN-016: verifying an API key records last_used_at, so a stale key is visible', () => { + it('TOKEN-016: verifying an API key records last_used_at, so a stale key is visible', async () => { const { user } = createUser(testDb); - const raw = (svc.createApiToken(user.id, 'dawarich').token as { raw_token: string }).raw_token; + const raw = (await svc.createApiToken(user.id, 'dawarich')).token!.raw_token as string; - const before = svc.listApiTokens(user.id) as Record[]; + const before = await svc.listApiTokens(user.id) as Record[]; expect(before[0].last_used_at).toBeNull(); - svc.verifyApiToken(raw); - const after = svc.listApiTokens(user.id) as Record[]; + await svc.verifyApiToken(raw); + const after = await svc.listApiTokens(user.id) as Record[]; expect(after[0].last_used_at).not.toBeNull(); }); }); @@ -244,42 +244,42 @@ describe('API key service', () => { // --------------------------------------------------------------------------- describe('MCP token service (admin view)', () => { - it('ADMIN-SVC-068 — listAllMcpTokens returns empty array initially', () => { - const result = svc.listAllMcpTokens() as any[]; + it('ADMIN-SVC-068 — listAllMcpTokens returns empty array initially', async () => { + const result = await svc.listAllMcpTokens() as any[]; expect(Array.isArray(result)).toBe(true); expect(result).toHaveLength(0); }); - it('ADMIN-SVC-069 — adminDeleteMcpToken returns 404 for non-existent token', () => { - const result = svc.adminDeleteMcpToken('99999') as any; + it('ADMIN-SVC-069 — adminDeleteMcpToken returns 404 for non-existent token', async () => { + const result = await svc.adminDeleteMcpToken('99999') as any; expect(result.status).toBe(404); expect(result.error).toBeDefined(); }); - it('TOKEN-003: listAllMcpTokens spans users and carries the owner username', () => { + it('TOKEN-003: listAllMcpTokens spans users and carries the owner username', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - svc.createMcpToken(user.id, 'a'); - svc.createMcpToken(other.id, 'b'); + await svc.createMcpToken(user.id, 'a'); + await svc.createMcpToken(other.id, 'b'); - const all = svc.listAllMcpTokens() as Record[]; + const all = await 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', () => { + it('TOKEN-004: adminDeleteMcpToken removes any user token and revokes that user', async () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'admin-killed'); + const created = await svc.createMcpToken(user.id, 'admin-killed'); const tokenId = String((created.token as { id: number }).id); - expect(svc.adminDeleteMcpToken(tokenId)).toEqual({}); + expect(await svc.adminDeleteMcpToken(tokenId)).toEqual({}); expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined(); expect(revokeUserSessions).toHaveBeenCalledWith(user.id); }); - it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', () => { - expect(svc.adminDeleteMcpToken('99999')).toEqual({ error: 'Token not found', status: 404 }); + it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', async () => { + expect(await svc.adminDeleteMcpToken('99999')).toEqual({ error: 'Token not found', status: 404 }); expect(revokeUserSessions).not.toHaveBeenCalled(); }); }); @@ -297,31 +297,31 @@ describe('ephemeral tokens', () => { expect(svc.createResourceToken(user.id, 'download')).toEqual({ token: 'tok-1' }); }); - it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', () => { + it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', async () => { const { user } = createUser(testDb); vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); - expect(svc.createWsToken(user.id)).toEqual({ token: 'ws-tok' }); + expect(await svc.createWsToken(user.id)).toEqual({ token: 'ws-tok' }); }); - it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', () => { + it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', async () => { const { user } = createUser(testDb); testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id); vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); - svc.createWsToken(user.id); + await svc.createWsToken(user.id); expect(createEphemeralToken).toHaveBeenCalledWith(user.id, 'ws', { pv: 7 }); }); - it('TOKEN-007: createWsToken falls back to pv 0 for a user row without one', () => { + it('TOKEN-007: createWsToken falls back to pv 0 for a user row without one', async () => { vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); - svc.createWsToken(99999); + await svc.createWsToken(99999); expect(createEphemeralToken).toHaveBeenCalledWith(99999, 'ws', { pv: 0 }); }); - it('TOKEN-008: createWsToken reports 503 when the store refuses', () => { + it('TOKEN-008: createWsToken reports 503 when the store refuses', async () => { const { user } = createUser(testDb); vi.mocked(createEphemeralToken).mockReturnValueOnce(null as unknown as string); - expect(svc.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 }); + expect(await svc.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 }); }); }); @@ -330,36 +330,36 @@ describe('ephemeral tokens', () => { // --------------------------------------------------------------------------- describe('verifyMcpToken', () => { - it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => { + it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'bridge-case'); + const created = await svc.createMcpToken(user.id, 'bridge-case'); const raw = (created.token as { raw_token: string }).raw_token; - const resolved = svc.verifyMcpToken(raw); + const resolved = await svc.verifyMcpToken(raw); expect(resolved?.id).toBe(user.id); - expect(svc.verifyMcpToken('trek_no_such_token')).toBeNull(); + expect(await svc.verifyMcpToken('trek_no_such_token')).toBeNull(); }); - it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', () => { + it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', async () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'stamped'); + const created = await svc.createMcpToken(user.id, 'stamped'); const raw = (created.token as { raw_token: string }).raw_token; const id = (created.token as { id: number }).id; expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).toBeNull(); - svc.verifyMcpToken(raw); + await svc.verifyMcpToken(raw); expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).not.toBeNull(); - svc.verifyMcpToken('trek_wrong'); + await svc.verifyMcpToken('trek_wrong'); expect(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens').get()).toEqual({ c: 1 }); }); - it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', () => { + it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => { const { user } = createUser(testDb); - const created = svc.createMcpToken(user.id, 'lean'); + const created = await svc.createMcpToken(user.id, 'lean'); const raw = (created.token as { raw_token: string }).raw_token; - const resolved = svc.verifyMcpToken(raw) as unknown as Record; + const resolved = await svc.verifyMcpToken(raw) as unknown as Record; expect(Object.keys(resolved).sort()).toEqual(['email', 'id', 'role', 'username']); }); }); diff --git a/server/tests/websocket/connection.test.ts b/server/tests/websocket/connection.test.ts index a9f815e45..930409258 100644 --- a/server/tests/websocket/connection.test.ts +++ b/server/tests/websocket/connection.test.ts @@ -42,7 +42,8 @@ import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.servi // instance mints tokens the app under test accepts (TokenService is a leaf — // its own unit suite constructs it the same way). const tokenService = new TokenService(new DatabaseService(testDb), new EphemeralTokenService()); -const createWsToken = tokenService.createWsToken.bind(tokenService); +const createWsToken = (...args: Parameters) => + tokenService.createWsToken(...args); let server: http.Server; let wsUrl: string; @@ -423,7 +424,7 @@ describe('WS auth edge cases', () => { it('WS-027 — ws-token minted before a password change is rejected (session gate)', async () => { // createWsToken stamps the user's current password_version (0) into the token. const { user } = createUser(testDb); - const result = createWsToken(user.id); + const result = await createWsToken(user.id); const token = result.token!; // Simulate a password reset bumping the version AFTER the token was issued. @@ -441,7 +442,7 @@ describe('WS auth edge cases', () => { const { user } = createUser(testDb); // Bump the version first, THEN mint — the token captures the current pv. testDb.prepare('UPDATE users SET password_version = 3 WHERE id = ?').run(user.id); - const result = createWsToken(user.id); + const result = await createWsToken(user.id); const client = await connectWs(result.token!); try { const msg = await client.next();