mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
b3a085df3f
The process-state ports were meant to let a store shared between processes replace the in-memory one, but they could not. The readers outside the container (the broadcast functions, the backup restore's permissions-cache flush, the pending-code sweep, hand-built consumers) went straight to the module-level in-memory instances, so an overridden provider was used by the gateway and the services while the broadcasts and the restore flush kept using the old store. And every port was synchronous, while a store in the database or on the network is async, so plugging one in would have changed every signature anyway. RateLimitStore, PendingCodeStore, OidcFlowStore and PermissionsCacheStore now return promises, and RateLimitService, OauthService, OidcService, PermissionsService and their callers await them. The in-memory stores still decide synchronously, so who gets a 429, a code or a cached level is unchanged. RoomRegistry stays synchronous: membership holds live sockets and broadcast() is a synchronous call every domain makes, so several processes need a fan-out in front of broadcast(), not a different place for the sets. The process-wide ports get a ProcessStoreSlot. PermissionsModule, OauthModule and RealtimeGatewayModule install the store the container resolved and release it on shutdown, and the free functions, the sweep and hand-built consumers read the slot, so overriding the provider changes every reader. Tests provide fake stores and check that the broadcasts, the restore flush and the sweep use them. Two callers needed more than an await. The transit MCP tools counted in a RateLimitService of their own that no provider could reach; they now take the one RateLimitModule provides (their mcp_transit_* buckets never overlap the REST ones). The link preview claimed its in-flight slot only after the budget check, which now yields, so simultaneous askers would each have been charged; it now claims the slot first, and an asker that joined someone out of budget asks again on its own budget. The unused putPendingCode/takePendingCode helpers are gone.
691 lines
31 KiB
TypeScript
691 lines
31 KiB
TypeScript
/**
|
||
* Per-key read scopes for /api/v1 (#2279).
|
||
*
|
||
* The feature is spread over four files that each know one third of it: the
|
||
* shared vocabulary, the column and how it is read back (TokenService), the
|
||
* grant on the request (ApiTokenGuard), and the refusal (public-api-request,
|
||
* used from two controllers in two modules). Nothing forces those halves to
|
||
* agree, so the cases below walk the whole path — a row in, a status out — and
|
||
* pin the one rule that is easy to get backwards on the way:
|
||
*
|
||
* a key that was never narrowed reads everything.
|
||
*
|
||
* That is not a nicety. Every integration key minted before this shipped has an
|
||
* empty `api_scopes`, and the day "no list" starts meaning "no access" is the
|
||
* day every one of them stops working.
|
||
*
|
||
* The end-to-end shape (real HTTP, real hash lookup) lives in
|
||
* tests/e2e/public-api.e2e.test.ts; this file owns the branches that are
|
||
* awkward to reach through a socket — a NULL column, a key the guard never
|
||
* touched, an unknown scope name.
|
||
*/
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// vi.hoisted: real in-memory DB + the module mock, before any import
|
||
// ---------------------------------------------------------------------------
|
||
|
||
vi.mock('../../../src/db/database', async () => {
|
||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||
const db = createSnapshotTestDb();
|
||
return {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
canAccessTrip: () => undefined,
|
||
isOwner: () => false,
|
||
};
|
||
});
|
||
vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
|
||
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
|
||
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
|
||
LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn(),
|
||
}));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Imports (after the mocks)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
import { HttpException } from '@nestjs/common';
|
||
import type { ExecutionContext } from '@nestjs/common';
|
||
import type { Request } from 'express';
|
||
import {
|
||
PUBLIC_API_INCLUDES,
|
||
PUBLIC_API_SCOPES,
|
||
apiTokenCreateRequestSchema,
|
||
mcpTokenCreateRequestSchema,
|
||
type PublicApiGrant,
|
||
type PublicApiScope,
|
||
} from '@trek/shared';
|
||
import { db as testDb } from '../../../src/db/database';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createUser } from '../../helpers/factories';
|
||
import { TokenService } from '../../../src/nest/tokens/token.service';
|
||
import type { McpTokensRepository } from '../../../src/db/repositories/McpTokens.repository';
|
||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||
import { createTestMcpTokensRepo, createTestUsersRepo } from '../../helpers/test-uow';
|
||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
||
import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard';
|
||
import { PublicApiController } from '../../../src/nest/public-api/public-api.controller';
|
||
import type { PublicApiService } from '../../../src/nest/public-api/public-api.service';
|
||
import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller';
|
||
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||
import {
|
||
grantedScopes,
|
||
narrowToGrant,
|
||
requireScope,
|
||
} from '../../../src/nest/public-api/public-api-request';
|
||
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
||
import { AuthController } from '../../../src/nest/auth/auth.controller';
|
||
import type { AuthService } from '../../../src/nest/auth/auth.service';
|
||
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
||
import type { UserProfileService } from '../../../src/nest/auth/user-profile.service';
|
||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||
import type { User } from '../../../src/types';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fixtures
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ALL: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
|
||
const limited = (...scopes: PublicApiScope[]): PublicApiGrant => ({ mode: 'limited', scopes });
|
||
|
||
/** A request as the guard leaves it: a user and a grant, nothing else. */
|
||
const req = (grant: PublicApiGrant | undefined = ALL, userId: number | null = 7) =>
|
||
({
|
||
headers: {},
|
||
user: userId === null ? undefined : ({ id: userId } as User),
|
||
apiToken: grant,
|
||
}) as Request;
|
||
|
||
const TRIP = {
|
||
id: 12,
|
||
title: 'Toskana',
|
||
description: null,
|
||
start_date: '2026-06-14',
|
||
end_date: '2026-06-22',
|
||
currency: 'EUR',
|
||
archived: false,
|
||
updated_at: '2026-06-01 10:00:00',
|
||
};
|
||
|
||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||
try {
|
||
fn();
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(HttpException);
|
||
const e = err as HttpException;
|
||
return { status: e.getStatus(), body: e.getResponse() };
|
||
}
|
||
throw new Error('expected the handler to refuse');
|
||
}
|
||
|
||
async function thrownAsync(fn: () => Promise<unknown>): 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 handler to refuse');
|
||
}
|
||
|
||
function apiController(svc: Partial<PublicApiService>) {
|
||
const rl = { check: vi.fn().mockResolvedValue(true) } as unknown as RateLimitService;
|
||
return new PublicApiController(svc as PublicApiService, rl);
|
||
}
|
||
|
||
function statsController() {
|
||
return new PublicStatsController(
|
||
{
|
||
getTravelStats: vi.fn(() => ({
|
||
countries: ['JP'], cities: ['tokyo'], coords: [],
|
||
totalTrips: 1, totalDays: 2, totalPlaces: 3, totalDistanceKm: 4,
|
||
})),
|
||
lastTrip: vi.fn(() => null),
|
||
nextTrip: vi.fn(() => null),
|
||
} as unknown as AtlasService,
|
||
new RateLimitService(),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The grant on the request
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('public-api scopes — the grant on the request', () => {
|
||
it('PUBAPI-SCOPE-U001: a request the guard never touched may read nothing', () => {
|
||
// Fail closed. A route mounted without the guard is a bug, and the answer to
|
||
// a bug in an authorisation path is "no", never "everything".
|
||
expect(grantedScopes({} as Request)).toEqual([]);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U002: a resolved grant is what the request reports, verbatim', () => {
|
||
expect(grantedScopes(req(limited('trips', 'days')))).toEqual(['trips', 'days']);
|
||
expect(grantedScopes(req(ALL))).toEqual([...PUBLIC_API_SCOPES]);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U003: a missing scope is a 403 with a code of its own, never a 401 or a 404', () => {
|
||
// 401 would tell an integrator to re-mint a key that is perfectly valid; 404
|
||
// would send them looking for a trip that is right there. Both are worse than
|
||
// saying what actually happened.
|
||
expect(thrown(() => requireScope(req(limited('trips')), 'stats'))).toEqual({
|
||
status: 403,
|
||
body: {
|
||
error: 'This API key is not allowed to read stats',
|
||
code: 'API_SCOPE_FORBIDDEN',
|
||
required_scope: 'stats',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U004: a granted scope passes without comment', () => {
|
||
expect(() => requireScope(req(limited('trips')), 'trips')).not.toThrow();
|
||
for (const scope of PUBLIC_API_SCOPES) {
|
||
expect(() => requireScope(req(ALL), scope)).not.toThrow();
|
||
}
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U005: narrowToGrant filters instead of refusing, and keeps the caller’s order', () => {
|
||
expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips', 'days', 'notes')))).toEqual([
|
||
'days',
|
||
'notes',
|
||
]);
|
||
expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(ALL))).toEqual([...PUBLIC_API_INCLUDES]);
|
||
expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips')))).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ApiTokenGuard — resolving the grant
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('ApiTokenGuard — the grant it leaves behind', () => {
|
||
const USER = { id: 7, username: 'ada', email: 'ada@example.com', role: 'user' } as User;
|
||
|
||
function contextWith(headers: Record<string, string | undefined>) {
|
||
const request: Record<string, unknown> = { headers };
|
||
return {
|
||
switchToHttp: () => ({ getRequest: () => request }),
|
||
req: request,
|
||
} as unknown as ExecutionContext & { req: Record<string, unknown> };
|
||
}
|
||
|
||
function guardWith(result: { user: User; grant: PublicApiGrant } | null) {
|
||
const verifyApiTokenWithGrant = vi.fn().mockReturnValue(result);
|
||
return {
|
||
guard: new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService),
|
||
verifyApiTokenWithGrant,
|
||
};
|
||
}
|
||
|
||
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(await guard.canActivate(ctx)).toBe(true);
|
||
expect(verifyApiTokenWithGrant).toHaveBeenCalledWith('trek_abc123');
|
||
expect(ctx.req.user).toBe(USER);
|
||
expect(ctx.req.apiToken).toBe(grant);
|
||
// The grant belongs to the credential, not to the person: nothing of it may
|
||
// end up on `user`, which every other auth path in TREK also reads.
|
||
expect(USER).not.toHaveProperty('scopes');
|
||
expect(USER).not.toHaveProperty('scope_mode');
|
||
});
|
||
|
||
/**
|
||
* 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', async () => {
|
||
const { guard: noCredential } = guardWith(null);
|
||
expect(await thrownAsync(() => noCredential.canActivate(contextWith({})))).toEqual({
|
||
status: 401,
|
||
body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' },
|
||
});
|
||
const { guard: unknownToken } = guardWith(null);
|
||
expect(
|
||
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', async () => {
|
||
const { guard } = guardWith(null);
|
||
const ctx = contextWith({ authorization: 'Bearer trek_nope' });
|
||
await thrownAsync(() => guard.canActivate(ctx));
|
||
expect(ctx.req.apiToken).toBeUndefined();
|
||
expect(ctx.req.user).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The two controllers behind the guard
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('PublicApiController — what a narrowed key reaches', () => {
|
||
it('PUBAPI-SCOPE-U020: an un-narrowed key reads every route, exactly as before', async () => {
|
||
const listTrips = vi.fn().mockReturnValue([TRIP]);
|
||
const listBucketList = vi.fn().mockReturnValue([]);
|
||
const getTrip = vi.fn().mockReturnValue(TRIP);
|
||
const ctl = apiController({ listTrips, listBucketList, getTrip });
|
||
expect(await ctl.listTrips(req(ALL))).toEqual({ trips: [TRIP] });
|
||
expect(await ctl.listBucketList(req(ALL))).toEqual({ items: [] });
|
||
expect(await ctl.getTrip(req(ALL), '12', undefined)).toEqual(TRIP);
|
||
expect(getTrip).toHaveBeenCalledWith(12, 7, [...PUBLIC_API_INCLUDES], [...PUBLIC_API_SCOPES]);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U021: a trips-only key reads the trip list', async () => {
|
||
const listTrips = vi.fn().mockReturnValue([TRIP]);
|
||
expect(await apiController({ listTrips }).listTrips(req(limited('trips')))).toEqual({ trips: [TRIP] });
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U022: and is refused the bucket list before the service is ever asked', async () => {
|
||
const listBucketList = vi.fn();
|
||
const res = await thrownAsync(() => apiController({ listBucketList }).listBucketList(req(limited('trips'))));
|
||
expect(res.status).toBe(403);
|
||
expect(res.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN', required_scope: 'bucket-list' });
|
||
// The refusal has to happen before the read, not after it: a 403 carrying
|
||
// rows that were already fetched is one careless `return` from leaking them.
|
||
expect(listBucketList).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U023: a key without `trips` cannot reach the list or a single trip', async () => {
|
||
const listTrips = vi.fn();
|
||
const getTrip = vi.fn();
|
||
const ctl = apiController({ listTrips, getTrip });
|
||
expect((await thrownAsync(() => ctl.listTrips(req(limited('bucket-list'))))).status).toBe(403);
|
||
expect((await thrownAsync(() => ctl.getTrip(req(limited('bucket-list')), '12', undefined))).status).toBe(403);
|
||
expect(listTrips).not.toHaveBeenCalled();
|
||
expect(getTrip).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U024: a trip asked for without include is narrowed, not refused', async () => {
|
||
const getTrip = vi.fn().mockReturnValue(TRIP);
|
||
await apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', undefined);
|
||
// `include` absent means "everything"; refusing a key for wanting sections it
|
||
// never named would make a narrow key unable to read a trip at all. The grant
|
||
// rides along so the service can tell an implied day block from a granted one.
|
||
expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes'], ['trips', 'days', 'notes']);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U025: a trips-only key gets the summary and nothing that hangs off it', async () => {
|
||
const getTrip = vi.fn().mockReturnValue(TRIP);
|
||
await apiController({ getTrip }).getTrip(req(limited('trips')), '12', undefined);
|
||
expect(getTrip).toHaveBeenCalledWith(12, 7, [], ['trips']);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U026: an empty include is treated like an absent one, and narrowed too', async () => {
|
||
const getTrip = vi.fn().mockReturnValue(TRIP);
|
||
await apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', ' ');
|
||
expect(getTrip).toHaveBeenCalledWith(12, 7, ['days'], ['trips', 'days']);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U027: a section named explicitly and not granted is a 403, not a silent drop', async () => {
|
||
const getTrip = vi.fn();
|
||
const res = await thrownAsync(() =>
|
||
apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', 'days,places'),
|
||
);
|
||
expect(res).toEqual({
|
||
status: 403,
|
||
body: {
|
||
error: 'This API key is not allowed to read places',
|
||
code: 'API_SCOPE_FORBIDDEN',
|
||
required_scope: 'places',
|
||
},
|
||
});
|
||
expect(getTrip).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U028: an include the key covers is passed through untouched', async () => {
|
||
const getTrip = vi.fn().mockReturnValue(TRIP);
|
||
await apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', 'days, notes');
|
||
expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes'], ['trips', 'days', 'notes']);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U029: a bad id is still a 400 — the scope check does not swallow it', async () => {
|
||
const getTrip = vi.fn();
|
||
expect((await thrownAsync(() => apiController({ getTrip }).getTrip(req(ALL), 'abc', undefined))).status).toBe(400);
|
||
expect(getTrip).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('PublicStatsController — the widest answer on the surface', () => {
|
||
it('PUBAPI-SCOPE-U030: a key without `stats` is refused, in the module next door too', async () => {
|
||
// The route lives in atlas/ and imports the check from public-api/. If it ever
|
||
// stops calling it, nothing else in that module would notice.
|
||
const res = await thrownAsync(() => statsController().stats(req(limited('trips'))));
|
||
expect(res).toEqual({
|
||
status: 403,
|
||
body: {
|
||
error: 'This API key is not allowed to read stats',
|
||
code: 'API_SCOPE_FORBIDDEN',
|
||
required_scope: 'stats',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U031: a key that was granted stats still gets its numbers', async () => {
|
||
expect(await statsController().stats(req(limited('stats')))).toMatchObject({
|
||
total_trips: 1,
|
||
total_countries: 1,
|
||
});
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U032: an un-narrowed key reads stats, as every key did before', async () => {
|
||
expect(await statsController().stats(req(ALL))).toMatchObject({ total_trips: 1 });
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The column: what is written, and what is read back out of it
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('TokenService — storing and resolving a grant', () => {
|
||
let tokens: TokenService;
|
||
|
||
beforeAll(async () => {
|
||
tokens = new TokenService(await createTestMcpTokensRepo(testDb), await createTestUsersRepo(testDb), new EphemeralTokenService());
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
afterAll(() => {
|
||
testDb.close();
|
||
});
|
||
|
||
const rowFor = (id: number) =>
|
||
testDb.prepare('SELECT kind, scope_mode, api_scopes FROM mcp_tokens WHERE id = ?').get(id) as {
|
||
kind: string;
|
||
scope_mode: string | null;
|
||
api_scopes: string | null;
|
||
};
|
||
|
||
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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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 });
|
||
expect(created.scope_mode).toBe('all');
|
||
expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', async () => {
|
||
const { user } = createUser(testDb);
|
||
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] });
|
||
});
|
||
|
||
/**
|
||
* The backwards-compatibility case, at the layer that decides it. A row written
|
||
* before the columns existed carries neither value; the ALTER backfills
|
||
* 'all', and a repository read that hands back NULL anyway must land in the
|
||
* same place — `McpTokensRepository.findGrantByHash`'s row type states
|
||
* `scope_mode`/`api_scopes` as non-nullable (the column is `NOT NULL
|
||
* DEFAULT 'all'`), so this fakes the repository directly rather than a raw
|
||
* row, the same way the legacy version faked `DatabaseService` directly.
|
||
* Both are "nobody ever narrowed this key".
|
||
*/
|
||
it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', async () => {
|
||
const noColumns = new TokenService(
|
||
{
|
||
findGrantByHash: async () => ({ id: 3, username: 'ada', email: 'a@b.c', role: 'user', scope_mode: null, api_scopes: null }),
|
||
touchLastUsedByHash: async () => {},
|
||
} as unknown as McpTokensRepository,
|
||
{} as UsersRepository,
|
||
new EphemeralTokenService(),
|
||
);
|
||
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', async () => {
|
||
const { user } = createUser(testDb);
|
||
const created = await mint(user.id, 'Dawarich', ['stats', 'trips']);
|
||
expect(rowFor(created.id)).toEqual({
|
||
kind: 'api',
|
||
scope_mode: 'limited',
|
||
// Canonical order, not the caller's: two keys with the same access must
|
||
// read identically in the settings list.
|
||
api_scopes: JSON.stringify(['trips', 'stats']),
|
||
});
|
||
expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'stats'] });
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', async () => {
|
||
const { user } = createUser(testDb);
|
||
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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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', 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 = 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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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', 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 = 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((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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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((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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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((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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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([
|
||
['Wide', 'all', [...PUBLIC_API_SCOPES]],
|
||
['Narrow', 'limited', ['trips']],
|
||
]),
|
||
);
|
||
// Still no hash, and still no raw key.
|
||
expect(JSON.stringify(listed)).not.toContain('token_hash');
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', async () => {
|
||
const { user } = createUser(testDb);
|
||
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 = 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.
|
||
expect(listed[0]).not.toHaveProperty('scope_mode');
|
||
expect(listed[0]).not.toHaveProperty('scopes');
|
||
expect(Object.keys(listed[0]).sort()).toEqual(
|
||
['created_at', 'id', 'last_used_at', 'name', 'token_prefix'].sort(),
|
||
);
|
||
});
|
||
|
||
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 = (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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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();
|
||
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;
|
||
}).last_used_at,
|
||
).not.toBeNull();
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// The mint routes. AuthController is constructed directly (repo convention)
|
||
// over the real TokenService above, so the body really does reach the column.
|
||
// -------------------------------------------------------------------------
|
||
|
||
const authController = () =>
|
||
new AuthController(
|
||
{ setAuthCookie: vi.fn(), clearAuthCookie: vi.fn() } as unknown as AuthService,
|
||
{} as UserProfileService,
|
||
tokens,
|
||
new RateLimitService(),
|
||
{ writeAudit: vi.fn() } as unknown as AuditService,
|
||
new RuntimeEnvService(),
|
||
{ put: vi.fn() } as unknown as StorageService,
|
||
);
|
||
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', async () => {
|
||
const { user } = createUser(testDb);
|
||
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 };
|
||
|
||
expect(rowFor(created.id)).toMatchObject({
|
||
scope_mode: 'limited',
|
||
api_scopes: JSON.stringify(['trips', 'days']),
|
||
});
|
||
// The response carries the grant, so the panel can show it without a re-read.
|
||
expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'days'] });
|
||
expect(created.raw_token).toMatch(/^trek_/);
|
||
});
|
||
|
||
it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', async () => {
|
||
const { user } = createUser(testDb);
|
||
const ctl = authController();
|
||
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', async () => {
|
||
const { user } = createUser(testDb);
|
||
const ctl = authController();
|
||
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(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', 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 = (await ctl.createMcpToken(
|
||
{ id: user.id } as User,
|
||
{ name: 'Assistant', scopes: ['trips'] } as { name: string },
|
||
httpReq,
|
||
)).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((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', () => {
|
||
const api = apiTokenCreateRequestSchema.safeParse({ name: 'Dawarich', scopes: ['trips'] });
|
||
expect(api.success && api.data.scopes).toEqual(['trips']);
|
||
// An unknown section is refused at the edge rather than quietly sanitised, so
|
||
// a typo in an integration's setup script is visible immediately.
|
||
expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: ['passwords'] }).success).toBe(false);
|
||
// An empty array would mint a key that reads nothing; omitting the field is
|
||
// how you ask for everything.
|
||
expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: [] }).success).toBe(false);
|
||
expect(apiTokenCreateRequestSchema.safeParse({ name: 'x' }).success).toBe(true);
|
||
|
||
const mcp = mcpTokenCreateRequestSchema.safeParse({ name: 'Assistant', scopes: ['trips'] });
|
||
expect(mcp.success).toBe(true);
|
||
// Parsed and dropped: the MCP route cannot be handed read scopes by accident.
|
||
expect(mcp.success && mcp.data).toEqual({ name: 'Assistant' });
|
||
});
|
||
});
|