mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
3a7c8e1d26
An adversarial audit over the three merged migration PRs. Sixteen findings
survived triple refutation; these are the ones with a defect behind them.
The serious one is mine, from the gateway move. ws registers
`error: this.emit.bind(this, 'error')` on the http server it attaches to, and
the adapter now runs inside buildApp, before listen(). So from that moment the
http server HAS an error listener and Node stops throwing on one. The adapter's
handler was `() => {}`, which meant EADDRINUSE was forwarded there and dropped:
the process survived, the listen callback never ran, so the banner never
printed and the scheduler never started. A container that looks healthy and
serves nothing. Reproduced, then fixed at both ends: index.ts owns the fatal
decision on the http server, and the adapter logs instead of swallowing.
Four DTOs were tightening contracts rather than describing them, which is the
thing the pipe-before-handler ordering makes easy to do by accident:
- OauthClientCreateDto replaced 'Name is required' with a Zod field report, and
pre-empted the handler's 403 on an instance with the MCP addon off.
- BookingImportConfirmDto demanded per-item fields the service never reads, so
a caller assembling items itself got a 400 about `source`.
- TagCreateDto/TagUpdateDto typed name and color as strings; the legacy route
bound whatever arrived, so a numeric name used to create a tag.
- PluginUserSettingsUpdateDto typed config as a record, where the handler
answers 200 for anything it cannot use.
Two of my own tests were false-green, which the audit demonstrated by mutation
rather than by reading. WSGW-008 asserted on frames that never carry the user
object at all, so it passed with the password_version strip deleted; it reads
the retained identity now. GEO-009 made the same assertion before and after the
write; it distinguishes a cached negative from a miss now, through a hasCached
seam that says what getCached cannot. Both were re-run against the mutation
they exist to catch, and both fail on it.
booking-import geocodes every venue and endpoint in one request loop, up to
thirty sequential calls. Folding maps onto the shared Nominatim cursor put that
loop on the interactive lane, so an import starved every concurrent keystroke
for half a minute. It takes the background lane now, which is what the two-lane
split was built for.
Also: the README's in-container bridge list had outlived one entry and was
missing another, and the journey e2e still mocked a module this branch deleted.
154 lines
6.7 KiB
TypeScript
154 lines
6.7 KiB
TypeScript
/**
|
|
* Journey e2e — exercises the migrated /api/journeys and /api/public/journey
|
|
* endpoints through the real JwtAuthGuard against a temp SQLite db. The journey
|
|
* services + addon gate are mocked; this focuses on the addon-gate-before-auth
|
|
* ordering (404 wins over 401), auth, the service-owned 403/404 mapping, status
|
|
* codes and the unguarded public route.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { Test } from '@nestjs/testing';
|
|
import { seedUser, sessionCookie } from './harness';
|
|
|
|
const { db } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const tmp = new Database(':memory:');
|
|
tmp.exec('PRAGMA journal_mode = WAL');
|
|
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
|
return { db: tmp };
|
|
});
|
|
|
|
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
|
|
|
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
|
// The controller's pure helpers (isVideoExtension/isVideoMime/MAX_VIDEO_SIZE)
|
|
// now come from the real files.constants; only the request-time app_settings
|
|
// read is mocked, preserving the old '*'-allowlist semantics for the fixtures.
|
|
// The memories providers are injected since the fold — stubbed on the prototype
|
|
// so JourneyModule still resolves them through DI.
|
|
vi.mock('../../src/nest/memories/immich.service', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/nest/memories/immich.service')>();
|
|
actual.ImmichService.prototype.uploadToImmich = vi.fn();
|
|
actual.ImmichService.prototype.streamImmichAsset = vi.fn();
|
|
return actual;
|
|
});
|
|
vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/nest/memories/photo-resolver.service')>();
|
|
actual.PhotoResolverService.prototype.streamPhoto = vi.fn();
|
|
return actual;
|
|
});
|
|
|
|
const { jsvc } = vi.hoisted(() => ({
|
|
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
|
|
}));
|
|
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
|
|
|
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
|
|
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
|
|
|
|
import { JourneyModule } from '../../src/nest/journey/journey.module';
|
|
import { AddonsService } from '../../src/nest/addons/addons.service';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
|
|
describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] })
|
|
.overrideProvider(JourneyDomainService)
|
|
.useValue(jsvc)
|
|
.overrideProvider(JourneyShareService)
|
|
.useValue(sharesvc)
|
|
.overrideProvider(AddonsService)
|
|
.useValue({ isAddonEnabled })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
seedUser(db as never, { id: 1 });
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
|
|
jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
|
|
sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
|
|
});
|
|
|
|
beforeEach(() => {
|
|
isAddonEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('404 (addon gate wins over auth) when the Journey addon is disabled', async () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
const res = await request(server).get('/api/journeys');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
|
|
});
|
|
|
|
it('401 with the addon enabled but no session cookie', async () => {
|
|
expect((await request(server).get('/api/journeys')).status).toBe(401);
|
|
});
|
|
|
|
it('200 list with a session', async () => {
|
|
const res = await request(server).get('/api/journeys').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ journeys: [{ id: 1, title: 'J' }] });
|
|
});
|
|
|
|
it('201 create, 400 without a title', async () => {
|
|
const ok = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({ title: 'J' });
|
|
expect(ok.status).toBe(201);
|
|
expect(ok.body).toEqual({ id: 9, title: 'J' });
|
|
const bad = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({});
|
|
expect(bad.status).toBe(400);
|
|
expect(bad.body).toEqual({ error: 'Title is required' });
|
|
});
|
|
|
|
it('404 for an inaccessible journey', async () => {
|
|
jsvc.getJourneyFull.mockReturnValue(null);
|
|
const res = await request(server).get('/api/journeys/9').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey not found' });
|
|
});
|
|
|
|
it('public journey read is unguarded (200 with a valid token, no cookie)', async () => {
|
|
const res = await request(server).get('/api/public/journey/tok');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ id: 9 });
|
|
});
|
|
|
|
it('the image fileFilter is wired: a disallowed extension is rejected, not stored', async () => {
|
|
// The multer options are built by MulterModule.registerAsync now, and Nest
|
|
// injects MULTER_MODULE_OPTIONS with @Optional(): a token that fails to
|
|
// resolve is not a boot error, it silently falls back to defaults, which
|
|
// means no fileFilter and no size cap. This case is what turns that into a
|
|
// visible failure, since an unfiltered upload would answer 201.
|
|
const res = await request(server)
|
|
.post('/api/journeys/9/cover')
|
|
.set('Cookie', sessionCookie(1))
|
|
.attach('cover', Buffer.from('MZ'), { filename: 'payload.exe', contentType: 'application/octet-stream' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('public journey 404 for an unknown token', async () => {
|
|
sharesvc.getPublicJourney.mockReturnValueOnce(null);
|
|
const res = await request(server).get('/api/public/journey/bad');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Not found' });
|
|
});
|
|
});
|