Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`scope-coverage-matrix snapshot of route → method → scope mapping (catches accidental drift) 1`] = `
[
"AnalyticsIntegrationController DELETE /:id → delete [session_token]",
"AnalyticsIntegrationController GET → read [session_token]",
"AnalyticsIntegrationController PATCH /:id → update [session_token]",
"AnalyticsIntegrationController POST → write [session_token]",
"AnalyticsIntegrationController POST /:id/test → read (override) [session_token]",
"ApiKeyController DELETE /:id → delete [session_token]",
"ApiKeyController GET → read [session_token]",
"ApiKeyController PATCH /:id → update [session_token]",
"ApiKeyController POST → write [session_token]",
"BillingController GET /subscription → read [session_token]",
"BillingController POST /checkout-session → write [session_token]",
"BillingController POST /portal-session → write [session_token]",
"ConfigTemplateController DELETE /:id → delete",
"ConfigTemplateController GET → read",
"ConfigTemplateController GET /:id → read",
"ConfigTemplateController GET /predefined → read",
"ConfigTemplateController PATCH /:id → update",
"ConfigTemplateController POST → write",
"ConfigTemplateController POST /:id/duplicate → write",
"CustomDomainController DELETE /:id → delete [session_token]",
"CustomDomainController GET → read [session_token]",
"CustomDomainController GET /:id → read [session_token]",
"CustomDomainController GET /:id/setup-instructions → read [session_token]",
"CustomDomainController GET /default → read [session_token]",
"CustomDomainController GET /resolve → read",
"CustomDomainController POST → write [session_token]",
"CustomDomainController POST /:id/set-default → update (override) [session_token]",
"CustomDomainController POST /:id/verify → update (override) [session_token]",
"CustomDomainController POST /clear-default → update (override) [session_token]",
"QrCodeController DELETE /:id → delete",
"QrCodeController GET → read",
"QrCodeController GET /:id → read",
"QrCodeController GET /:id/download → read",
"QrCodeController GET /screenshot → read [session_token]",
"QrCodeController PATCH /:id → update",
"QrCodeController POST → write",
"QrCodeController POST /:id/duplicate → write",
"QrCodeController POST /bulk-import → write",
"QrCodeController POST /render → read (override) [session_token]",
"QrCodeShareController DELETE /:id/share → delete",
"QrCodeShareController GET /:id/share → read",
"QrCodeShareController PATCH /:id/share → update",
"QrCodeShareController POST /:id/share → write",
"ShortUrlController DELETE /:shortCode → delete",
"ShortUrlController GET → read",
"ShortUrlController GET /:shortCode → read [session_token]",
"ShortUrlController GET /:shortCode/analytics → read",
"ShortUrlController GET /:shortCode/detail → read",
"ShortUrlController GET /:shortCode/get-views → read",
"ShortUrlController GET /reserved → read",
"ShortUrlController PATCH /:shortCode → update",
"ShortUrlController PATCH /:shortCode/toggle-active-state → update",
"ShortUrlController POST → write",
"ShortUrlController POST /:shortCode/duplicate → write",
"ShortUrlController POST /:shortCode/record-scan → write [session_token]",
"TagController DELETE /:id → delete",
"TagController GET → read",
"TagController PATCH /:id → update",
"TagController POST → write",
"TagController PUT /qr-code/:id → update",
"TagController PUT /short-url/:id → update",
"UserSurveyController GET /status → read [session_token]",
"UserSurveyController POST → write [session_token]",
]
`;
131 changes: 131 additions & 0 deletions apps/backend/src/__tests__/scope-coverage-matrix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import 'reflect-metadata';
import { ROUTE_METADATA_KEY, type RouteMetadata } from '@/core/decorators/route';
import { resolveScopeForMethod } from '@/libs/fastify/helpers';

import { ApiKeyController } from '@/modules/api-key/http/controller/api-key.controller';
import { AnalyticsIntegrationController } from '@/modules/analytics-integration/http/controller/analytics-integration.controller';
import { QrCodeController } from '@/modules/qr-code/http/controller/qr-code.controller';
import { QrCodeShareController } from '@/modules/qr-code/http/controller/qr-code-share.controller';
import { CustomDomainController } from '@/modules/custom-domain/http/controller/custom-domain.controller';
import { ConfigTemplateController } from '@/modules/config-template/http/controller/config-template.controller';
import { TagController } from '@/modules/tag/http/controller/tag.controller';
import { ShortUrlController } from '@/modules/url-shortener/http/controller/short-url.controller';
import { BillingController } from '@/modules/billing/http/controller/billing.controller';
import { UserSurveyController } from '@/modules/user-survey/http/controller/user-survey.controller';

const CONTROLLERS = [
ApiKeyController,
AnalyticsIntegrationController,
QrCodeController,
QrCodeShareController,
CustomDomainController,
ConfigTemplateController,
TagController,
ShortUrlController,
BillingController,
UserSurveyController,
];

type RouteRow = {
controller: string;
method: string;
path: string;
scope: string | null;
authHandlerSetting: 'default' | 'custom' | 'disabled';
allowedTokenTypes: string[] | undefined;
effectiveTokenTypes: string[] | null;
overrideExplicit: boolean;
hidden: boolean;
};

function gatherRoutes(): RouteRow[] {
const rows: RouteRow[] = [];
for (const Controller of CONTROLLERS) {
const meta = Reflect.getMetadata(ROUTE_METADATA_KEY, Controller) as RouteMetadata[] | undefined;
if (!meta) continue;
for (const r of meta) {
const explicit = r.options.config?.scope;
const resolved = explicit ?? resolveScopeForMethod(r.method);
let authSetting: RouteRow['authHandlerSetting'] = 'default';
if (r.options.authHandler === false) authSetting = 'disabled';
else if (typeof r.options.authHandler !== 'undefined') authSetting = 'custom';
const explicitAllowed = r.options.config?.allowedTokenTypes;
const hidden = (r.options.schema as { hide?: boolean } | undefined)?.hide === true;
const effectiveAllowed =
authSetting === 'disabled'
? null
: (explicitAllowed ?? (hidden ? ['session_token'] : null));
rows.push({
controller: Controller.name,
method: r.method.toUpperCase(),
path: r.path,
scope: resolved ?? null,
authHandlerSetting: authSetting,
allowedTokenTypes: explicitAllowed,
effectiveTokenTypes: effectiveAllowed,
overrideExplicit: explicit !== undefined,
hidden,
});
}
}
return rows;
}

describe('scope-coverage-matrix', () => {
const rows = gatherRoutes();

it('discovers at least one route per controller (regression guard)', () => {
const byController = new Map<string, number>();
rows.forEach((r) => byController.set(r.controller, (byController.get(r.controller) ?? 0) + 1));
for (const Controller of CONTROLLERS) {
expect(byController.get(Controller.name) ?? 0).toBeGreaterThan(0);
}
});

it('every authenticated route resolves to a non-null scope', () => {
const violations = rows.filter((r) => r.authHandlerSetting !== 'disabled' && r.scope === null);
expect(violations).toEqual([]);
});

it('every route uses a known HTTP method', () => {
const allowed = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
const violations = rows.filter((r) => !allowed.has(r.method));
expect(violations).toEqual([]);
});

it('every resolved scope is one of read/write/update/delete (or null)', () => {
const allowed = new Set(['read', 'write', 'update', 'delete', null]);
const violations = rows.filter((r) => !allowed.has(r.scope));
expect(violations).toEqual([]);
});

it('api-key management routes are session-only', () => {
const apiKeyRoutes = rows.filter((r) => r.controller === 'ApiKeyController');
const violations = apiKeyRoutes.filter(
(r) => !r.allowedTokenTypes?.includes('session_token') || r.allowedTokenTypes.length !== 1,
);
expect(violations).toEqual([]);
});

it('every hidden route is effectively session-only (default rule)', () => {
const violations = rows.filter(
(r) =>
r.hidden &&
r.authHandlerSetting !== 'disabled' &&
(!r.effectiveTokenTypes ||
!r.effectiveTokenTypes.includes('session_token') ||
r.effectiveTokenTypes.includes('api_key')),
);
expect(violations).toEqual([]);
});

it('snapshot of route → method → scope mapping (catches accidental drift)', () => {
const snapshot = rows
.map((r) => {
const tokenTag = r.effectiveTokenTypes ? ` [${r.effectiveTokenTypes.join('|')}]` : '';
return `${r.controller} ${r.method} ${r.path} → ${r.scope}${r.overrideExplicit ? ' (override)' : ''}${tokenTag}`;
})
.sort();
expect(snapshot).toMatchSnapshot();
});
});
6 changes: 6 additions & 0 deletions apps/backend/src/core/decorators/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { type HTTPMethods, type RouteShorthandOptions } from 'fastify';
import { type ZodSchema } from 'zod';
import { type ApiKeyScope } from '@shared/schemas';
import { type RateLimitPolicy } from '../rate-limit/rate-limit.policy';
import { type TTokenType } from '../domain/schema/UserSchema';

export type HandlerName = string;
export type Constructable<T = unknown> = new (...args: unknown[]) => T;
Expand All @@ -18,6 +20,10 @@ interface CustomRouteOptions {
responseSchema?: Record<number, ZodSchema>;
config?: {
rateLimitPolicy?: RateLimitPolicy;
/** Override the method-default scope (e.g. a POST that's a read). */
scope?: ApiKeyScope;
/** Lock a route to specific token types — e.g. `['session_token']` for API-key management. */
allowedTokenTypes?: TTokenType[];
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/core/domain/schema/UserSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export const UserSchema = z.object({
id: z.string(),
tokenType: TokenTypeSchema,
plan: z.enum(PlanName),
scopes: z.array(z.string()).optional(),
});
export type TUser = z.infer<typeof UserSchema>;
4 changes: 4 additions & 0 deletions apps/backend/src/core/error/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { CustomApiError } from './custom-api.error';
import { NotFoundError } from './not-found.error';
import { UnauthorizedError } from './unauthorized.error';
import { ForbiddenError } from './forbidden.error';
import { InsufficientScopeError } from './insufficient-scope.error';
import { ServiceUnavailableError } from './service-unavailable.error';
import { TokenTypeNotAllowedError } from './token-type-not-allowed.error';

export {
CustomApiError,
Expand All @@ -13,5 +15,7 @@ export {
NotFoundError,
UnauthorizedError,
ForbiddenError,
InsufficientScopeError,
ServiceUnavailableError,
TokenTypeNotAllowedError,
};
16 changes: 16 additions & 0 deletions apps/backend/src/core/error/http/insufficient-scope.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { type ApiKeyScope } from '@shared/schemas';
import { ForbiddenError } from './forbidden.error';

export class InsufficientScopeError extends ForbiddenError {
public readonly errorCode = 'INSUFFICIENT_SCOPE' as const;
public readonly requiredScope: ApiKeyScope;
public readonly grantedScopes: string[];

constructor(requiredScope: ApiKeyScope, grantedScopes: string[]) {
super(
`Insufficient API key scope. This endpoint requires "${requiredScope}", but the key only has [${grantedScopes.join(', ') || 'none'}].`,
);
this.requiredScope = requiredScope;
this.grantedScopes = grantedScopes;
}
}
15 changes: 15 additions & 0 deletions apps/backend/src/core/error/http/token-type-not-allowed.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ForbiddenError } from './forbidden.error';

export class TokenTypeNotAllowedError extends ForbiddenError {
public readonly errorCode = 'TOKEN_TYPE_NOT_ALLOWED' as const;
public readonly providedTokenType: string;
public readonly allowedTokenTypes: string[];

constructor(provided: string, allowed: string[]) {
super(
`Authentication type "${provided}" is not allowed for this endpoint. Allowed: [${allowed.join(', ')}].`,
);
this.providedTokenType = provided;
this.allowedTokenTypes = allowed;
}
}
Loading
Loading