mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
132 lines
5.7 KiB
TypeScript
132 lines
5.7 KiB
TypeScript
/**
|
|
* ApiTokenGuard — the only door into /api/v1.
|
|
*
|
|
* The cases that matter here are the refusals. A guard that lets the right token
|
|
* through is easy; one that also lets a session JWT, an OAuth bearer or a random
|
|
* string through is a credential-confusion bug, and those are the tests below.
|
|
*/
|
|
import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard';
|
|
import type { TokenService } from '../../../src/nest/tokens/token.service';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { ExecutionContext } from '@nestjs/common';
|
|
import { PUBLIC_API_SCOPES } from '@trek/shared';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
const USER: User = { id: 7, username: 'ada', email: 'ada@example.com', role: 'user' } as User;
|
|
|
|
function contextWith(headers: Record<string, string | undefined>) {
|
|
const req: Record<string, unknown> = { headers };
|
|
return {
|
|
switchToHttp: () => ({ getRequest: () => req }),
|
|
req,
|
|
} as unknown as ExecutionContext & { req: Record<string, unknown> };
|
|
}
|
|
|
|
/**
|
|
* The guard resolves the user and the key's read grant in one lookup (#2279).
|
|
* These cases are about the credential, so every accepted token here carries the
|
|
* full grant; what a narrowed one may read is public-api-scopes.test.ts.
|
|
*/
|
|
function makeGuard(verify: (raw: string) => User | null) {
|
|
const verifyApiTokenWithGrant = (raw: string) => {
|
|
const user = verify(raw);
|
|
return user ? { user, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } } : null;
|
|
};
|
|
return new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService);
|
|
}
|
|
|
|
/** Run the guard, expecting a refusal; return its { status, body }. */
|
|
async function refused(fn: () => Promise<boolean>): Promise<{ status: number; body: unknown }> {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected the guard to refuse');
|
|
}
|
|
|
|
describe('ApiTokenGuard', () => {
|
|
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(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', async () => {
|
|
const verify = vi.fn().mockReturnValue(USER);
|
|
const ctx = contextWith({ 'x-api-key': 'trek_abc123' });
|
|
expect(await makeGuard(verify).canActivate(ctx)).toBe(true);
|
|
expect(verify).toHaveBeenCalledWith('trek_abc123');
|
|
});
|
|
|
|
it('trims surrounding whitespace before looking a token up', async () => {
|
|
const verify = vi.fn().mockReturnValue(USER);
|
|
await makeGuard(verify).canActivate(contextWith({ 'x-api-key': ' trek_abc123 ' }));
|
|
expect(verify).toHaveBeenCalledWith('trek_abc123');
|
|
});
|
|
|
|
it('401s without any credential', async () => {
|
|
const verify = vi.fn();
|
|
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', 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);
|
|
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', async () => {
|
|
const verify = vi.fn().mockReturnValue(null);
|
|
expect(
|
|
await refused(() => makeGuard(verify).canActivate(contextWith({ authorization: 'Bearer trek_nope' }))),
|
|
).toEqual({
|
|
status: 401,
|
|
body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The credential-confusion cases. Each of these is a valid credential somewhere
|
|
* else in TREK, and none of them may open this door — a session cookie that ends
|
|
* up in a third-party integration is precisely what a machine token prevents.
|
|
*/
|
|
it.each([
|
|
['a session JWT', { authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig' }],
|
|
['an OAuth bearer', { authorization: 'Bearer mcp_at_9f3a2b' }],
|
|
['a bare random string', { authorization: 'Bearer hunter2' }],
|
|
['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', async (_label, headers) => {
|
|
const verify = vi.fn();
|
|
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', async () => {
|
|
const verify = vi.fn();
|
|
const ctx = contextWith({ authorization: ['Bearer trek_abc'] as unknown as string });
|
|
expect((await refused(() => makeGuard(verify).canActivate(ctx))).status).toBe(401);
|
|
expect(verify).not.toHaveBeenCalled();
|
|
});
|
|
});
|