Files
TREK/server/tests/unit/nest/addon-guard.test.ts
T
2026-10-06 21:50:59 +02:00

124 lines
5.3 KiB
TypeScript

/**
* The addon route gate.
*
* Three hand-written guards (collections, airtrail, journey) did the same thing
* and differed only in the addon id they read and the word leading the 404. They
* are one guard plus a @RequireAddon decorator now.
*
* The last describe matters as much as the first: with the addon id in a
* decorator, nothing in a controller's own tests notices if the decorator is
* dropped or points at the wrong addon. These assertions are what keeps the
* three route groups gated, and gated on the right thing.
*/
import { ADDON_IDS } from '../../../src/addons';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { RequireAddon, REQUIRE_ADDON, type RequireAddonMeta } from '../../../src/nest/addons/require-addon.decorator';
import { CollectionsController } from '../../../src/nest/collections/collections.controller';
import { AirtrailController } from '../../../src/nest/integrations/airtrail.controller';
import { JourneyController } from '../../../src/nest/journey/journey.controller';
import { ReservationImportController } from '../../../src/nest/reservation-import/reservation-import.controller';
import { HttpException } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { describe, it, expect, vi } from 'vitest';
/** A context whose handler/class carry the decorator under test. */
function ctxFor(handler: object, cls: object = class {}): ExecutionContext {
return {
getHandler: () => handler,
getClass: () => cls,
} as unknown as ExecutionContext;
}
function addons(enabled: boolean): AddonsService {
return { isAddonEnabled: vi.fn(() => enabled) } as unknown as AddonsService;
}
async function thrown(fn: () => unknown) {
try {
await fn();
return undefined;
} catch (err) {
return err instanceof HttpException ? { status: err.getStatus(), body: err.getResponse() } : err;
}
}
/** Apply @RequireAddon to a throwaway class the way a controller would. */
function decorated(addonId: string, label: string) {
class Gated {}
RequireAddon(addonId, label)(Gated);
return Gated;
}
describe('AddonGuard', () => {
it('ADDON-GUARD-001: 404s with the decorator label when the addon is disabled', async () => {
const cls = decorated(ADDON_IDS.JOURNEY, 'Journey');
const guard = new AddonGuard(addons(false), new Reflector());
expect(await thrown(() => guard.canActivate(ctxFor(() => {}, cls)))).toEqual({
status: 404,
body: { error: 'Journey addon is not enabled' },
});
});
it('ADDON-GUARD-002: passes when the addon is enabled, asking for the declared id', async () => {
const cls = decorated(ADDON_IDS.COLLECTIONS, 'Collections');
const svc = addons(true);
const guard = new AddonGuard(svc, new Reflector());
expect(await guard.canActivate(ctxFor(() => {}, cls))).toBe(true);
expect(svc.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.COLLECTIONS);
});
it('ADDON-GUARD-003: is a no-op on a route that never asked for an addon', async () => {
const svc = addons(false);
const guard = new AddonGuard(svc, new Reflector());
expect(await guard.canActivate(ctxFor(() => {}, class {}))).toBe(true);
expect(svc.isAddonEnabled).not.toHaveBeenCalled();
});
it('ADDON-GUARD-004: a handler-level decorator overrides the controller-level one', async () => {
const cls = decorated(ADDON_IDS.JOURNEY, 'Journey');
const handler = () => {};
RequireAddon(ADDON_IDS.AIRTRAIL, 'AirTrail')(handler);
const svc = addons(false);
expect(await thrown(() => new AddonGuard(svc, new Reflector()).canActivate(ctxFor(handler, cls)))).toEqual({
status: 404,
body: { error: 'AirTrail addon is not enabled' },
});
expect(svc.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.AIRTRAIL);
});
});
describe('the gated route groups still declare their addon', () => {
const metaOf = (cls: object): RequireAddonMeta | undefined => Reflect.getMetadata(REQUIRE_ADDON, cls);
const guardsOf = (cls: object): unknown[] => Reflect.getMetadata('__guards__', cls) ?? [];
it.each([
['JourneyController', JourneyController, ADDON_IDS.JOURNEY, 'Journey'],
['CollectionsController', CollectionsController, ADDON_IDS.COLLECTIONS, 'Collections'],
['AirtrailController', AirtrailController, ADDON_IDS.AIRTRAIL, 'AirTrail'],
])('ADDON-GUARD-005: %s gates on the right addon, with AddonGuard first', (_name, cls, addonId, label) => {
expect(metaOf(cls)).toEqual({ addonId, label });
// Order is the contract: a disabled addon has to answer 404 to anonymous
// callers, so the addon check must run before the 401.
expect(guardsOf(cls)[0]).toBe(AddonGuard);
});
it('ADDON-GUARD-006: the merged import controller gates AirTrail per handler, not per class', () => {
// A class-level requirement here would 404 the four file-upload routes
// whenever the AirTrail addon happens to be off. AddonGuard still runs first
// in the chain, so the 404 keeps beating the 401.
expect(metaOf(ReservationImportController)).toBeUndefined();
expect(Reflect.getMetadata(REQUIRE_ADDON, ReservationImportController.prototype.importAirtrail)).toEqual({
addonId: ADDON_IDS.AIRTRAIL,
label: 'AirTrail',
});
expect(guardsOf(ReservationImportController)[0]).toBe(AddonGuard);
});
});