mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Boot-sweep request-context regression guard (task-6-review-parity.md
|
||||
* Critical C1; task-6-fix-brief.md item 7).
|
||||
*
|
||||
* `journey-thumbs.job.ts`'s `onApplicationBootstrap` used to call
|
||||
* `void this.sweep()` directly, bypassing `CronRegistrarService`'s
|
||||
* request-context wrapper entirely — the one place every SCHEDULED tick
|
||||
* gets it. Once `sweepOrphanThumbs` went repository-backed
|
||||
* (`AddonsService.isAddonEnabled`), that boot-time call threw
|
||||
* `cannotUseGlobalContext` on EVERY production boot, silently, because the
|
||||
* job's own try/catch swallowed it into a log line about the sweep itself.
|
||||
* The smoke boot reproduced it twice. Every `onApplicationBootstrap` boot
|
||||
* sweep across the seven job providers listed in the parity review now
|
||||
* routes through `CronRegistrarService.runOnBoot` instead.
|
||||
*
|
||||
* Under `NODE_ENV=test`, `CronRegistrarService.isEnabled()` is false
|
||||
* (SCHED-GATE), so `onApplicationBootstrap` never fires these sweeps
|
||||
* automatically in this suite — deliberately, so tests stay timer- and
|
||||
* network-free. This test instead triggers each job's real boot-sweep
|
||||
* entrypoint directly through the SAME, production-wired
|
||||
* `CronRegistrarService.runOnBoot` (its `orm` is `app.get(MikroORM)` —
|
||||
* pinned by orm-request-context-seams.test.ts), which is exactly what
|
||||
* `onApplicationBootstrap` itself would call in production. It then asserts
|
||||
* the captured log contains zero `cannotUseGlobalContext` / "global
|
||||
* EntityManager" lines — the regression guard the smoke boot would
|
||||
* otherwise be the only thing catching.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
|
||||
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
|
||||
import { JourneyThumbsJob } from '../../src/nest/memories/journey-thumbs.job';
|
||||
import { PlacePhotoCacheJob } from '../../src/nest/place-photos/place-photo-cache.job';
|
||||
import { TrekPhotoCacheJob } from '../../src/nest/memories/trek-photo-cache.job';
|
||||
import { ReminderJobsService } from '../../src/nest/notifications/reminder-jobs.service';
|
||||
import { DocSyncJob } from '../../src/nest/doc-sync/doc-sync.job';
|
||||
import { AirtrailSyncJob } from '../../src/nest/integrations/airtrail-sync.job';
|
||||
import { DawarichSyncJob } from '../../src/nest/integrations/dawarich-sync.job';
|
||||
|
||||
describe('Every onApplicationBootstrap boot sweep runs inside a request context', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
it('BOOT-SWEEP-001: the seven boot-sweep entrypoints, invoked through the real production-wired CronRegistrarService.runOnBoot, never log cannotUseGlobalContext / "global EntityManager"', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const registrar = app.get(CronRegistrarService);
|
||||
|
||||
await registrar.runOnBoot('journey-thumbs-boot', () => app.get(JourneyThumbsJob).sweep());
|
||||
await registrar.runOnBoot('place-photo-cache-boot', () => app.get(PlacePhotoCacheJob).sweep());
|
||||
await registrar.runOnBoot('trek-photo-cache-boot', () => app.get(TrekPhotoCacheJob).tick());
|
||||
await registrar.runOnBoot('reminder-jobs-boot', async () => {
|
||||
await app.get(ReminderJobsService).tripTick();
|
||||
await app.get(ReminderJobsService).todoTick();
|
||||
});
|
||||
await registrar.runOnBoot('docsync-boot', () => app.get(DocSyncJob).tick());
|
||||
await registrar.runOnBoot('airtrail-sync-boot', () => app.get(AirtrailSyncJob).tick());
|
||||
await registrar.runOnBoot('dawarich-sync-boot', () => app.get(DawarichSyncJob).tick());
|
||||
|
||||
const suspicious = errSpy.mock.calls
|
||||
.map((args) => args.map(String).join(' '))
|
||||
.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
|
||||
expect(suspicious).toEqual([]);
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* ORM request-context seam ratchet (task-6-fix-brief.md item 2;
|
||||
* task-6-review-template.md's Important 4 / M-E). task-6-fix-brief.md item 1
|
||||
* made the three D6 choke points — CronRegistrarService.register/runOnBoot,
|
||||
* PluginSupervisor.onMessage's dispatch, TrekWsAdapter.bindMessageHandlers —
|
||||
* THROW when their ORM/thunk is absent, and every unit suite (WSAD-040,
|
||||
* CRONREG-010, CTX-PLUGIN-001) asserts that throw. None of those tests can
|
||||
* tell a genuinely wired production seam apart from a hand-built double that
|
||||
* happens to pass no ORM — they'd pass either way. This is the one test that
|
||||
* pins production wiring itself: that `buildApp()` actually populates
|
||||
* `CronRegistrarService`'s and `PluginRuntimeService`'s `@Optional()` ORM
|
||||
* params and `PluginSupervisor`'s `resolveOrm` thunk with the SAME MikroORM
|
||||
* instance `app.get(MikroORM)` resolves — so a future refactor that quietly
|
||||
* drops one of those three constructor arguments fails here, not silently in
|
||||
* production.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
|
||||
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
|
||||
import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service';
|
||||
|
||||
describe('ORM request-context seams populated in production', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
it('SEAM-001: CronRegistrarService, PluginRuntimeService and PluginSupervisor.resolveOrm() all resolve the SAME MikroORM app.get(MikroORM) does', () => {
|
||||
const orm = app.get(MikroORM);
|
||||
|
||||
const cronRegistrar = app.get(CronRegistrarService) as unknown as { orm?: MikroORM };
|
||||
expect(cronRegistrar.orm).toBe(orm);
|
||||
|
||||
const runtime = app.get(PluginRuntimeService) as unknown as {
|
||||
orm?: MikroORM;
|
||||
supervisor: { resolveOrm?: () => unknown };
|
||||
};
|
||||
expect(runtime.orm).toBe(orm);
|
||||
expect(runtime.supervisor.resolveOrm?.()).toBe(orm);
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,12 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
// (if it had one) first. The boot reconcile must therefore not live there.
|
||||
{
|
||||
provide: PluginRuntimeService,
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), addonsService, userSettings, undefined, hostFactory),
|
||||
// 8th arg (orm): task-6-fix-brief.md item 1 — PluginSupervisor.onMessage
|
||||
// now THROWS on a 'req' dispatch (e.g. this fixture's ctx.db.migrate in
|
||||
// onLoad) when resolveOrm returns undefined, instead of running it
|
||||
// unwrapped, so this hand-built double needs the same real ORM the
|
||||
// repositories above (auditLogRepo/usersRepo) already share.
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), addonsService, userSettings, undefined, hostFactory, undefined, t?.orm),
|
||||
},
|
||||
{
|
||||
provide: 'REGISTRY_SCAN',
|
||||
|
||||
@@ -11,12 +11,14 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { PluginSupervisor, type SupervisorHooks, type SupervisorTuning } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
||||
import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
|
||||
import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service';
|
||||
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
||||
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
|
||||
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
/**
|
||||
* DbRpc also carries `settings.get`, which needs the per-user settings store. No
|
||||
@@ -53,7 +55,12 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning:
|
||||
onEvent: (_id, topic, data) => events.push({ topic, data }),
|
||||
onLog: (_id, level, msg, meta) => events.push({ topic: '__log', data: { level, msg, meta } }),
|
||||
};
|
||||
return new PluginSupervisor(createRpcHost, hooks, tuning);
|
||||
// task-6-fix-brief.md item 1: every real `ctx.*` round-trip below dispatches
|
||||
// through PluginSupervisor.onMessage's 'req' branch over real child-process
|
||||
// IPC, which now THROWS without a resolveOrm thunk rather than dispatching
|
||||
// unwrapped — so this double needs a real (if otherwise unused) ORM, even
|
||||
// though PluginDataDb/DbRpc here are raw better-sqlite3, not MikroORM.
|
||||
return new PluginSupervisor(createRpcHost, hooks, tuning, () => ormHandle.orm);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,17 +73,23 @@ function logMeta<T = Record<string, unknown>>(events: Array<{ topic: string; dat
|
||||
return (hit && (hit.data as { meta?: unknown }).meta) as T;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
const ormDb = new Database(':memory:');
|
||||
let ormHandle: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-code-'));
|
||||
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-pdata-'));
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
ormHandle = await createTestOrm(ormDb);
|
||||
});
|
||||
afterAll(async () => {
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
await ormHandle.close();
|
||||
ormDb.close();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await sup?.shutdownAll();
|
||||
|
||||
Reference in New Issue
Block a user