mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
22d178d2f9
Two pieces of copy-paste in the guard layer. Three addon 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 AddonGuard plus a @RequireAddon(addonId, label) decorator now, read through the Reflector. A handler-level decorator overrides the controller's, and a route without the decorator is not gated at all. The demo-mode upload block was copy-pasted into six handlers, each carrying its own copy of the message. All six call isDemoWriteBlocked now, against the same DEMO_WRITE_ERROR body. That one is deliberately NOT a guard, though all six sites are handlers and a guard is the obvious shape — and this PR did build one first. Guards run before the multipart parser, so throwing from one leaves the request body unread and Node resets the connection: the client gets ECONNRESET instead of the 403. PROFILE-015 caught it. The check has to sit inside the handler, after the interceptor has drained the upload, for the response to arrive at all. The function carries that reasoning so the next person does not rediscover it. Two smaller findings on the way there. The DemoWriteGuard attempt broke eight e2e suites that never touch demo mode: AuthModule listed it as a provider, and RuntimeEnvService was not resolvable inside a TestingModule built around one domain module. AppConfigModule is @Global, but @Global only applies to modules that are in the graph. The five modules whose controllers now inject RuntimeEnvService import it explicitly, the same way PermissionsModule is handled. And both new guards get a test asserting the decorators are actually applied. Once a check moves out of a handler, that handler's own tests stop noticing if the decorator is dropped — the assertions on __guards__ and the @RequireAddon metadata are what keeps the four upload endpoints and the four addon route groups gated, and gated on the right addon.
109 lines
4.7 KiB
TypeScript
109 lines
4.7 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 { describe, it, expect, vi } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { ExecutionContext } from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
|
|
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
|
import { RequireAddon, REQUIRE_ADDON, type RequireAddonMeta } from '../../../src/nest/addons/require-addon.decorator';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import { ADDON_IDS } from '../../../src/addons';
|
|
import { JourneyController } from '../../../src/nest/journey/journey.controller';
|
|
import { CollectionsController } from '../../../src/nest/collections/collections.controller';
|
|
import { AirtrailController } from '../../../src/nest/integrations/airtrail.controller';
|
|
import { AirtrailImportController } from '../../../src/nest/integrations/airtrail-import.controller';
|
|
|
|
/** 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;
|
|
}
|
|
|
|
function thrown(fn: () => unknown) {
|
|
try {
|
|
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', () => {
|
|
const cls = decorated(ADDON_IDS.JOURNEY, 'Journey');
|
|
const guard = new AddonGuard(addons(false), new Reflector());
|
|
|
|
expect(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', () => {
|
|
const cls = decorated(ADDON_IDS.COLLECTIONS, 'Collections');
|
|
const svc = addons(true);
|
|
const guard = new AddonGuard(svc, new Reflector());
|
|
|
|
expect(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', () => {
|
|
const svc = addons(false);
|
|
const guard = new AddonGuard(svc, new Reflector());
|
|
|
|
expect(guard.canActivate(ctxFor(() => {}, class {}))).toBe(true);
|
|
expect(svc.isAddonEnabled).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ADDON-GUARD-004: a handler-level decorator overrides the controller-level one', () => {
|
|
const cls = decorated(ADDON_IDS.JOURNEY, 'Journey');
|
|
const handler = () => {};
|
|
RequireAddon(ADDON_IDS.AIRTRAIL, 'AirTrail')(handler);
|
|
const svc = addons(false);
|
|
|
|
expect(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'],
|
|
['AirtrailImportController', AirtrailImportController, 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);
|
|
});
|
|
});
|