mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
ffb73fac20
Nothing in src/nest/** was an APP_GUARD. Every protected route said so itself, 62 times, which means a forgotten @UseGuards was a silent bypass rather than an error. GlobalAuthGuard inverts that: a route is authenticated unless it carries @Public() or @OptionalAuth(), or declares its own guard chain. Reading the declared chain is what makes this behaviour-neutral. Nest runs global guards before route guards, so without that check the global guard would 401 an anonymous request to an addon-gated controller before its AddonGuard could answer 404 - inverting an order four e2e suites pin, and leaking which addons are installed. A controller that declared a chain keeps deciding for itself. It is a new class rather than JwtAuthGuard gaining a Reflector: that guard is constructed positionally in its own test with a context stub that has no getHandler, and the two want opposite behaviour anyway. @Public() takes a required reason, and the reasons are the diff worth reading: the invite token IS the credential, help assets are loaded by <img> which cannot send one, logout has to work with an expired session or nobody can sign out. The real deliverable is the inventory. validateRouteGuards runs in buildApp next to the body-contract gate and refuses to start when a route answers anonymously without being on a reviewed list of 25 - or when a listed one no longer does. An allow list nobody prunes stops being a list. Default-deny found two controllers on its own: SharedController and OidcController both sit in a file next to a guarded controller, so a file-level search for unguarded ones missed them. 37 tests went red and named them. That is the mechanism working before the change shipped, not after.
80 lines
3.3 KiB
TypeScript
80 lines
3.3 KiB
TypeScript
/**
|
|
* The anonymous surface of the server, as a list.
|
|
*
|
|
* This is what makes default-deny durable. The APP_GUARD protects what exists
|
|
* today; this fails when a NEW route arrives that answers without a session and
|
|
* nobody wrote it down. The same shape as the body-contract gate, and for the
|
|
* same reason: a rule with no inventory behind it decays into whatever the last
|
|
* person needed to make a test pass.
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const Database = (await import('better-sqlite3')).default;
|
|
const db = new Database(':memory:');
|
|
return { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null };
|
|
});
|
|
|
|
import { Test } from '@nestjs/testing';
|
|
import { AppModule } from '../../../src/nest/app.module';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { collectRouteGuards, PUBLIC_ROUTE_ALLOW_LIST, validateRouteGuards } from '../../../src/nest/common/validate-route-guards';
|
|
|
|
async function buildApp() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
|
.overrideProvider(DatabaseService)
|
|
.useValue({ get: () => undefined, all: () => [], run: () => ({}), canAccessTrip: () => null, isOwner: () => false })
|
|
.compile();
|
|
return moduleRef.createNestApplication();
|
|
}
|
|
|
|
describe('route guard inventory', () => {
|
|
it('ROUTES-001: every @Public() route is in the reviewed list, and every listed one still exists', async () => {
|
|
const app = await buildApp();
|
|
try {
|
|
expect(() => validateRouteGuards(app)).not.toThrow();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('ROUTES-002: the list is sorted and free of duplicates, so a diff to it reads', () => {
|
|
expect([...PUBLIC_ROUTE_ALLOW_LIST].sort()).toEqual(PUBLIC_ROUTE_ALLOW_LIST);
|
|
expect(new Set(PUBLIC_ROUTE_ALLOW_LIST).size).toBe(PUBLIC_ROUTE_ALLOW_LIST.length);
|
|
});
|
|
|
|
it('ROUTES-003: an unlisted public route fails the gate, naming it', async () => {
|
|
const app = await buildApp();
|
|
try {
|
|
// Drop one entry: the gate must notice the route it no longer covers.
|
|
const short = PUBLIC_ROUTE_ALLOW_LIST.slice(1);
|
|
expect(() => validateRouteGuards(app, short)).toThrow(PUBLIC_ROUTE_ALLOW_LIST[0]);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('ROUTES-004: a stale entry fails too — an unpruned list is not a list', async () => {
|
|
const app = await buildApp();
|
|
try {
|
|
expect(() => validateRouteGuards(app, [...PUBLIC_ROUTE_ALLOW_LIST, 'GhostController.vanished'].sort()))
|
|
.toThrow('GhostController.vanished');
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('ROUTES-005: nothing is left uncovered by accident — every route is public, optional, guarded or default-denied', async () => {
|
|
const app = await buildApp();
|
|
try {
|
|
const entries = collectRouteGuards(app);
|
|
// The inventory only lists routes that opted out of the default. Anything
|
|
// absent from it falls to GlobalAuthGuard, which is the safe direction.
|
|
expect(entries.every((e) => ['public', 'optional-auth', 'declared-guards'].includes(e.cover))).toBe(true);
|
|
expect(entries.filter((e) => e.cover === 'public').length).toBe(PUBLIC_ROUTE_ALLOW_LIST.length);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
});
|