mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(tokens): async DB-touching methods; the auth token handlers await them
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
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' },
|
||||
|
||||
@@ -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<TokenRow>(
|
||||
'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<string, unknown> } {
|
||||
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ 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 };
|
||||
@@ -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<User | null> {
|
||||
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<User | null> {
|
||||
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<User & { scope_mode: string | null; api_scopes: string | null }>(`
|
||||
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<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
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('AdminController read-only getters', () => {
|
||||
await expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).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<AdminService>)).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 }] });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
await mint(user.id, 'Wide');
|
||||
await mint(user.id, 'Narrow', ['trips']);
|
||||
const listed = await tokens.listApiTokens(user.id) as Array<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
const listed = await tokens.listMcpTokens(user.id) as Array<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
)).token as Record<string, unknown>;
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<boolean>): 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
const mine = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
|
||||
expect(mine).toHaveLength(1);
|
||||
expect(mine[0].name).toBe('mine');
|
||||
expect(mine[0]).not.toHaveProperty('token_hash');
|
||||
});
|
||||
|
||||
it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', () => {
|
||||
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<string, unknown>[];
|
||||
const api = svc.listApiTokens(user.id) as Record<string, unknown>[];
|
||||
const mcp = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
|
||||
const api = await svc.listApiTokens(user.id) as Record<string, unknown>[];
|
||||
expect(mcp.map((t) => t.name)).toEqual(['claude']);
|
||||
expect(api.map((t) => t.name)).toEqual(['dawarich']);
|
||||
});
|
||||
|
||||
it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', () => {
|
||||
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<string, unknown>[];
|
||||
const before = await svc.listApiTokens(user.id) as Record<string, unknown>[];
|
||||
expect(before[0].last_used_at).toBeNull();
|
||||
|
||||
svc.verifyApiToken(raw);
|
||||
const after = svc.listApiTokens(user.id) as Record<string, unknown>[];
|
||||
await svc.verifyApiToken(raw);
|
||||
const after = await svc.listApiTokens(user.id) as Record<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
const all = await 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', () => {
|
||||
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<string, unknown>;
|
||||
const resolved = await svc.verifyMcpToken(raw) as unknown as Record<string, unknown>;
|
||||
expect(Object.keys(resolved).sort()).toEqual(['email', 'id', 'role', 'username']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof tokenService.createWsToken>) =>
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user