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:
jubnl
2026-09-22 16:54:45 +02:00
parent 6fafcd72f2
commit 761cd14b2b
37 changed files with 1020 additions and 204 deletions
+28 -27
View File
@@ -1,9 +1,13 @@
/**
* GET /api/addons e2e — exercises the AddonsController through the real
* JwtAuthGuard against a temp SQLite db. getPhotoProviderConfig is
* mocked; the addons/photo_providers/photo_provider_fields/app_settings reads
* JwtAuthGuard against a real migrated-and-seeded temp SQLite db
* (createSnapshotTestDb(), task-6-fix-brief.md item 5 — this used to hand-roll
* four CREATE TABLEs, a second hand-maintained schema copy whose NOT
* NULL/defaults/FK/UNIQUE drifted from the real migrations). getPhotoProviderConfig
* is mocked; the addons/photo_providers/photo_provider_fields/app_settings reads
* run against the temp db (the collab/bag-tracking flags are real AddonsService
* reads since the admin-1 extraction). Asserts the byte-identical body the legacy inline handler produced.
* reads since the admin-1 extraction). Asserts the byte-identical body the legacy
* inline handler produced.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
@@ -11,32 +15,23 @@ 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';
import { 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);`);
// description/config (addons) and description (photo_providers) are absent
// from the legacy SELECTs this suite exercises, but the Addons/PhotoProviders
// entities declare them as columns (Plan 3a Task 4) — MikroORM's generated
// SELECT names every scalar column, so they must exist on this hand-rolled
// schema even though no test here reads them off addons/photo_providers rows.
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, enabled INTEGER, config TEXT DEFAULT '{}', sort_order INTEGER);`);
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, description TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
tmp.exec(`CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);`);
return { db: tmp };
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/db/database', () => ({
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
import { db } from '../../src/db/database';
// The snapshot ships the real seeded catalogue (production default addons +
// photo providers) — this suite wants a known, empty set of each so every
// case controls its own fixture rows exactly like the legacy hand-rolled
// tables did. Child table first for the FK on photo_provider_fields.
db.exec('DELETE FROM photo_provider_fields');
db.exec('DELETE FROM photo_providers');
db.exec('DELETE FROM addons');
db.exec('DELETE FROM app_settings');
const { getPhotoProviderConfig } = vi.hoisted(() => ({
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
@@ -62,7 +57,13 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
// harness.ts's seedUser() omits password_hash, which the real migrated
// schema requires NOT NULL (share.e2e.test.ts hit the same thing) — a
// raw insert here instead, matching the SeededUser shape id/role/
// password_version=0 that sessionCookie(1) needs.
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
).run();
// bag tracking is opt-in (=== 'true'); collab flags default ON with no rows
db.prepare("INSERT INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run();
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run();
+16 -9
View File
@@ -20,15 +20,22 @@ const tagsPerHandle = new WeakMap<Database.Database, Promise<TagsRepository>>();
const settingsPerHandle = new WeakMap<Database.Database, Promise<SettingsRepository>>();
const usersPerHandle = new WeakMap<Database.Database, Promise<UsersRepository>>();
// ONE MikroORM per handle, shared by createTestUnitOfWork and
// createTestAppSettingsRepo (task-2-review.md I2): each used to call
// createTestOrm(db) independently, which opened a SECOND MikroORM.init over the
// same better-sqlite3 handle — two identity maps, two Kysely clients, and
// (the functional bug) `UnitOfWork.transactional` opening its transaction on
// ORM A's EntityManager while a repository built from ORM B resolved a
// DIFFERENT EntityManager, so `TransactionContext` never saw it: a repository
// write inside `uow.transactional(...)` silently ran outside the transaction.
// Both functions below now derive from this single `t.em`, so a repository
// resolved from `t.repo(X)` and a `UnitOfWork` built from `t.em` share the same
// createTestAppSettingsRepo (task-2-review.md I2; reworded per
// task-1-2-rereview.md's I-A — the original wording here claimed a
// functional bug that measurement disproved): each used to call
// createTestOrm(db) independently, which opened a SECOND MikroORM.init over
// the same better-sqlite3 handle — two identity maps and two Kysely clients,
// neither ever closed. That is NOT the same as a repository write silently
// running outside a transaction: `TransactionContext.getEntityManager(name)`
// keys on the EntityManager's CONTEXT NAME ('default'), not on which ORM
// instance created it, so a repository built from ORM B still resolved ORM
// A's transactional fork correctly even under the old split wiring —
// transaction resolution worked either way. The reason to share one ORM per
// handle is simpler: it is the same EntityManager Nest's real DI graph would
// inject (one MikroORM per app, not one per consumer), and it stops leaking
// a second identity map and Kysely client per test file. Both functions below
// now derive from this single `t.em`, so a repository resolved from
// `t.repo(X)` and a `UnitOfWork` built from `t.em` share the same
// context-resolving EntityManager, exactly like Nest's real DI graph.
const ormPerHandle = new WeakMap<Database.Database, Promise<TestOrm>>();
/**
@@ -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();
@@ -64,13 +64,6 @@ describe('AddonsRepository', () => {
// repeat call from the identity map unless refresh: true is set —
// invisible to a write on the same id in the same request.
describe('sees a write on the same id in the same request (I1, identity-map regression)', () => {
it('ADDONSREPO-004: setEnabled then isEnabled reads the new value, not the stale one', async () => {
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
expect(await addons.isEnabled('budget')).toBe(false); // populate the identity map
await addons.setEnabled('budget', true);
expect(await addons.isEnabled('budget')).toBe(true);
});
it('ADDONSREPO-005: a raw UPDATE on the same id then isEnabled reads the new value', async () => {
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
expect(await addons.isEnabled('budget')).toBe(false); // populate the identity map
@@ -116,30 +109,4 @@ describe('AddonsRepository', () => {
});
});
});
describe('setEnabled', () => {
it('ADDONSREPO-010: stores the boolean as the legacy 0/1', async () => {
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
await addons.setEnabled('budget', true);
expect(rawRow('budget')).toMatchObject({ enabled: 1 });
await addons.setEnabled('budget', false);
expect(rawRow('budget')).toMatchObject({ enabled: 0 });
});
it('ADDONSREPO-011: does not touch any other column', async () => {
insertAddon({ id: 'budget', name: 'Costs', description: 'Track spend', type: 'trip', icon: 'wallet', enabled: 0, sort_order: 5 });
await addons.setEnabled('budget', true);
expect(rawRow('budget')).toEqual({
id: 'budget',
name: 'Costs',
description: 'Track spend',
type: 'trip',
icon: 'wallet',
enabled: 1,
config: '{}',
sort_order: 5,
});
});
});
});
@@ -0,0 +1,136 @@
/**
* AirtrailSyncJob (AIRTRAIL-JOB-001..010): the scheduling shell around the
* AirTrail poller, built with `new`, collaborators as plain mocks, no Nest
* container and no timer. Mirrors dawarich-sync.job.test.ts's shape — the
* two jobs are structurally identical siblings with different numbers (floor
* 1 vs 5, default 5 vs 15) and a different setting key.
*
* task-6-review-parity.md C1 / task-6-fix-brief.md item 7: the boot-time
* interval read now goes through CronRegistrarService.runOnBoot instead of
* running inline in onApplicationBootstrap — AIRTRAIL-JOB-009/010 pin that.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
import { AirtrailSyncJob } from '../../../src/nest/integrations/airtrail-sync.job';
import type { AirtrailSyncService } from '../../../src/nest/integrations/airtrail-sync.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
const SETTING_KEY = 'airtrail_poll_interval_minutes';
function makeJob(intervalSetting?: string, enabled = true) {
let onTick: (() => void | Promise<void>) | undefined;
const registrar = {
isEnabled: vi.fn(() => enabled),
register: vi.fn((_name: string, _expression: string, cb: () => void | Promise<void>) => {
onTick = cb;
return enabled;
}),
unregister: vi.fn(),
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
const db = {
get: vi.fn((_sql: string, ..._params: unknown[]) => (intervalSetting === undefined ? undefined : { value: intervalSetting })),
};
const airtrail = {
runAirtrailSync: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
};
const job = new AirtrailSyncJob(
db as unknown as DatabaseService,
airtrail as unknown as AirtrailSyncService,
registrar as unknown as CronRegistrarService,
);
return { job, registrar, db, airtrail, takeTick: () => onTick };
}
beforeEach(() => vi.clearAllMocks());
describe('AirtrailSyncJob bootstrap', () => {
it('AIRTRAIL-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', async () => {
const { job, registrar } = makeJob('20');
await job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', '*/20 * * * *', expect.any(Function));
expect(logMock.logInfo).toHaveBeenCalledWith('AirTrail sync: scheduled every 20m');
});
it('AIRTRAIL-JOB-002: reads its own setting key, not the Dawarich one', async () => {
const { job, db } = makeJob('10');
await job.onApplicationBootstrap();
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY);
});
it('AIRTRAIL-JOB-003: clamps the interval to 1-59 minutes and falls back to 5 on anything else', async () => {
for (const [setting, minutes] of [
[undefined, 5],
['', 5],
['not-a-number', 5],
['0', 5],
['60', 5],
['1', 1], // the floor itself is allowed
['59', 59], // and so is the ceiling
] as const) {
vi.clearAllMocks();
const { job, registrar } = makeJob(setting);
await job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', `*/${minutes} * * * *`, expect.any(Function));
}
});
it('AIRTRAIL-JOB-004: the test gate stops the job before it registers, reads or logs anything', async () => {
const { job, registrar, db } = makeJob('5', false);
await job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
expect(logMock.logInfo).not.toHaveBeenCalled();
expect(registrar.runOnBoot).not.toHaveBeenCalled();
});
it('AIRTRAIL-JOB-005: the callback handed to the registrar is the tick, so a fired cron reaches runAirtrailSync', async () => {
const { job, airtrail, takeTick } = makeJob('5');
await job.onApplicationBootstrap();
const onTick = takeTick();
expect(onTick).toBeTypeOf('function');
await onTick?.();
expect(airtrail.runAirtrailSync).toHaveBeenCalledTimes(1);
});
it('AIRTRAIL-JOB-009: the boot-time interval read goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point)', async () => {
const { job, registrar } = makeJob('5');
await job.onApplicationBootstrap();
expect(registrar.runOnBoot).toHaveBeenCalledWith('airtrail-sync-boot', expect.any(Function));
});
it('AIRTRAIL-JOB-010: registers at the default 5m cadence if runOnBoot declines to run fn (no ORM available)', async () => {
const { job, registrar, db } = makeJob('30');
registrar.runOnBoot.mockImplementationOnce(async () => { /* simulates no ORM available — fn never runs */ });
await job.onApplicationBootstrap();
expect(db.get).not.toHaveBeenCalled();
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', '*/5 * * * *', expect.any(Function));
});
});
describe('AirtrailSyncJob tick', () => {
it('AIRTRAIL-JOB-006: delegates to runAirtrailSync and stays quiet on success', async () => {
const { job, airtrail } = makeJob('5');
await expect(job.tick()).resolves.toBeUndefined();
expect(airtrail.runAirtrailSync).toHaveBeenCalledTimes(1);
expect(logMock.logError).not.toHaveBeenCalled();
});
it('AIRTRAIL-JOB-007: an unreachable instance becomes one log line and a resolved promise, never an unhandled rejection in the scheduler', async () => {
const { job, airtrail } = makeJob('5');
airtrail.runAirtrailSync.mockRejectedValue(new Error('fetch failed'));
await expect(job.tick()).resolves.toBeUndefined();
expect(logMock.logError).toHaveBeenCalledWith('AirTrail sync tick failed: fetch failed');
});
it('AIRTRAIL-JOB-008: a non-Error rejection is logged by value instead of as an empty message', async () => {
const { job, airtrail } = makeJob('5');
airtrail.runAirtrailSync.mockRejectedValue('ECONNREFUSED');
await expect(job.tick()).resolves.toBeUndefined();
expect(logMock.logError).toHaveBeenCalledWith('AirTrail sync tick failed: ECONNREFUSED');
});
});
+21 -1
View File
@@ -19,6 +19,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
}));
import type { Request } from 'express';
import { ValidationError } from '@mikro-orm/core';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -38,6 +39,7 @@ testDb.exec('PRAGMA busy_timeout = 5000');
let t: TestOrm;
let auditLogRepo: AuditLogRepository;
let usersRepo: UsersRepository;
let svc: AuditService;
beforeAll(async () => {
@@ -45,7 +47,8 @@ beforeAll(async () => {
runMigrations(testDb);
t = await createTestOrm(testDb);
auditLogRepo = t.repo(AuditLog) as AuditLogRepository;
svc = new AuditService(auditLogRepo, t.repo(Users) as UsersRepository);
usersRepo = t.repo(Users) as UsersRepository;
svc = new AuditService(auditLogRepo, usersRepo);
});
beforeEach(() => {
@@ -186,6 +189,23 @@ describe('writeAudit', () => {
spy.mockRestore();
});
it('AUDIT-SVC-020: a MikroORM ValidationError from insertEntry still never throws, but is logged with a message distinct from a normal write failure (task-6-fix-brief.md item 4)', async () => {
const spy = vi.spyOn(auditLogRepo, 'insertEntry').mockRejectedValueOnce(ValidationError.cannotUseGlobalContext());
await expect(svc.writeAudit({ userId: 1, action: 'user.login' })).resolves.not.toThrow();
expect(logError).toHaveBeenCalledWith(expect.stringContaining('Audit write ran with no request context'));
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('Audit write failed:'));
spy.mockRestore();
});
it('AUDIT-SVC-021: a MikroORM ValidationError from the email lookup still resolves to uid:<id>, but is logged with a distinct message', async () => {
seedUser(1, 'a@b.c');
const spy = vi.spyOn(usersRepo, 'getEmail').mockRejectedValueOnce(ValidationError.cannotUseGlobalContext());
await svc.writeAudit({ userId: 1, action: 'user.login' });
expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('uid:1 logged in'));
expect(logError).toHaveBeenCalledWith(expect.stringContaining('Audit email lookup ran with no request context'));
spy.mockRestore();
});
it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', async () => {
seedUser(1, 'admin@b.c');
await svc.writeAudit({
@@ -26,6 +26,12 @@ function registrarStub(enabled = true) {
isEnabled: vi.fn(() => enabled),
register: vi.fn(() => enabled),
unregister: vi.fn(),
// task-6-fix-brief.md item 7: every boot-time one-off sweep now goes
// through CronRegistrarService.runOnBoot instead of running inline —
// this double just runs fn immediately (synchronously reaching fn's own
// first call, exactly the pre-fix `void this.sweep()` shape), so every
// existing assertion below is unaffected.
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
}
@@ -33,6 +33,14 @@ vi.mock('cron', () => ({
},
}));
const logErrorMock = vi.hoisted(() => vi.fn());
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: logErrorMock,
logWarn: vi.fn(),
}));
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
@@ -170,20 +178,17 @@ describe('CronRegistrarService', () => {
await t.close();
});
it('CRONREG-010 — without MikroORM injected, a repository read inside onTick throws cannotUseGlobalContext', async () => {
it('CRONREG-010 — without MikroORM injected, a fired tick throws rather than running the onTick body unwrapped (task-6-fix-brief.md item 1: fail closed at the choke point)', async () => {
const registry = new SchedulerRegistry();
const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService); // no orm arg
let caught: unknown;
registrar.register('job', '0 2 * * *', async () => {
try {
await t.orm.em.find(Users, {});
} catch (e) {
caught = e;
}
let bodyRan = false;
registrar.register('job', '0 2 * * *', () => {
bodyRan = true;
});
await h.jobs[0].onTick();
expect(caught).toBeInstanceOf(Error);
expect(String((caught as Error).message)).toMatch(/global (EntityManager|context)/i);
await expect(h.jobs[0].onTick()).rejects.toThrow(/no MikroORM available/i);
// The wrapper throws BEFORE calling onTick at all — the whole point is
// that a repository read inside the body never runs unwrapped.
expect(bodyRan).toBe(false);
});
it('CRONREG-011 — with MikroORM injected, the SAME repository read inside onTick succeeds — the wrapper is load-bearing', async () => {
@@ -203,4 +208,40 @@ describe('CronRegistrarService', () => {
expect(Array.isArray(result)).toBe(true);
});
});
describe('runOnBoot (task-6-fix-brief.md item 7 — the boot-sweep choke point)', () => {
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeEach(async () => {
t = await createTestOrm(testDb, { allowGlobalContext: false });
});
afterEach(async () => {
await t.close();
});
it('CRONREG-012 — with MikroORM injected, fn runs inside a request context and a repository read succeeds', async () => {
const registry = new SchedulerRegistry();
const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm);
let result: unknown;
await registrar.runOnBoot('boot-sweep', async () => {
result = await t.orm.em.find(Users, {});
});
expect(Array.isArray(result)).toBe(true);
});
it('CRONREG-013 — without MikroORM injected, fn never runs and the failure is logged with a distinct message, not swallowed', async () => {
const registry = new SchedulerRegistry();
const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService); // no orm
let fnRan = false;
await registrar.runOnBoot('boot-sweep', () => {
fnRan = true;
});
expect(fnRan).toBe(false);
expect(logErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/runOnBoot: no MikroORM available.*boot-sweep/),
);
});
});
});
@@ -0,0 +1,54 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { TransactionContext } from '@mikro-orm/core';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { Users } from '../../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
/**
* D5 ratchet (task-6-fix-brief.md item 2; task-1-2-rereview.md's "half that
* IS testable"): the property `test-uow.ts`'s one-ORM-per-handle wiring
* exists to protect is not "the split (two-ORM) wiring was broken" — measured
* and disproven in that re-review, since `TransactionContext.getEntityManager`
* matches on the EntityManager's CONTEXT NAME, not the ORM instance, so even
* a repository built from a second `MikroORM.init` over the same handle
* resolves the open transaction correctly. The property that IS real, and
* WAS previously unpinned by any test, is global-context-resolving EM vs a
* manually forked one: a fork defaults to `useContext: false`, so its
* `getContext()` returns itself and ignores `TransactionContext` — a
* repository built on a fork used inside `uow.transactional(...)` would
* write outside the open transaction, on a second connection the
* transaction is holding.
*/
const testDb = createSnapshotTestDb();
let t: TestOrm;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
uow = new UnitOfWork(t.em);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('UnitOfWork transaction resolution (D5)', () => {
it('UOW-D5-001: a repository built on the global, context-resolving EM resolves the SAME EntityManager transactional() opens — a manual fork() does not', async () => {
createUser(testDb);
let globalMatches: boolean | undefined;
let forkMatches: boolean | undefined;
await uow.transactional(async () => {
const repo = t.repo(Users) as UsersRepository;
globalMatches = repo.getEntityManager().getContext() === TransactionContext.getEntityManager();
const forkedRepo = t.orm.em.fork().getRepository(Users) as UsersRepository;
forkMatches = forkedRepo.getEntityManager().getContext() === TransactionContext.getEntityManager();
});
expect(globalMatches).toBe(true);
expect(forkMatches).toBe(false);
});
});
@@ -58,6 +58,11 @@ function makeJob(intervalSetting?: string, enabled = true) {
return enabled;
}),
unregister: vi.fn(),
// task-6-fix-brief.md item 7: the boot-time interval read now runs
// through CronRegistrarService.runOnBoot instead of directly inline —
// this double just runs fn immediately, reproducing the pre-fix
// behaviour exactly, so every existing assertion below is unaffected.
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
// Shaped like the real `get<T>(sql, ...params)` rather than a bare `vi.fn()`
// so the stub cannot quietly drift from the signature the job calls; the
@@ -153,6 +158,17 @@ describe('DawarichSyncJob bootstrap', () => {
expect(registrar.register).toHaveBeenCalledTimes(1);
expect(registrar.unregister).not.toHaveBeenCalled();
});
it('DAWARICH-JOB-013: the boot-time interval read goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point), and registers at the default cadence if it declines to run fn', async () => {
const { job, registrar, db } = makeJob('30');
registrar.runOnBoot.mockImplementationOnce(async () => { /* simulates no ORM available — fn never runs */ });
await job.onApplicationBootstrap();
expect(registrar.runOnBoot).toHaveBeenCalledWith('dawarich-sync-boot', expect.any(Function));
expect(db.get).not.toHaveBeenCalled();
// minutes keeps its 15-default (the interval read never ran) and the job
// still registers, rather than never scheduling at all.
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/15 * * * *', expect.any(Function));
});
});
describe('DawarichSyncJob tick', () => {
@@ -82,6 +82,11 @@ function makeJob(over: Partial<Setup> = {}) {
return setup.registrarEnabled;
}),
unregister: vi.fn(),
// task-6-fix-brief.md item 7: the boot-time banner read now runs through
// CronRegistrarService.runOnBoot instead of directly inline — this
// double just runs fn immediately, reproducing the pre-fix behaviour
// exactly, so every existing assertion below is unaffected.
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
// Both stubs carry the real signatures, so a case can read back which link a
@@ -150,6 +155,12 @@ describe('DocSyncJob bootstrap', () => {
await job.onApplicationBootstrap();
expect(addons.isAddonEnabled).not.toHaveBeenCalled();
});
it('the boot-time interval banner goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point)', async () => {
const { job, registrar } = makeJob({ interval: '90' });
await job.onApplicationBootstrap();
expect(registrar.runOnBoot).toHaveBeenCalledWith('docsync-boot', expect.any(Function));
});
});
describe('DocSyncJob tick', () => {
@@ -0,0 +1,83 @@
/**
* JourneyThumbsJob boot sweep (task-6-review-parity.md C1 — this is the
* exact regression the smoke boot reproduced: `onApplicationBootstrap` used
* to call `void this.sweep()` directly, bypassing CronRegistrarService's
* request-context wrapper entirely, so once `sweepOrphanThumbs` went
* repository-backed (`AddonsService.isAddonEnabled`), the boot-time call
* threw `cannotUseGlobalContext` — silently, swallowed by the job's own
* try/catch — on every production boot).
*/
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { SchedulerRegistry } from '@nestjs/schedule';
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { JourneyThumbsJob } from '../../../../src/nest/memories/journey-thumbs.job';
import type { ThumbnailService } from '../../../../src/nest/memories/thumbnail.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { Addons } from '../../../../src/db/entities/Addons.entity';
import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => {
// Global context disallowed — the production setting — so a repository
// read with no wrapper around it genuinely throws.
t = await createTestOrm(testDb, { allowGlobalContext: false });
});
afterAll(async () => { await t.close(); testDb.close(); });
beforeEach(() => vi.clearAllMocks());
/**
* A `sweepOrphanThumbs` double that reaches a real `AddonsRepository` read —
* the same repository-backed dependency `ThumbnailService.sweepOrphanThumbs`
* has in production — rather than mocking the property away, so this test
* actually exercises the request-context property the real chain depends on.
*/
function makeThumbnails(): Pick<ThumbnailService, 'sweepOrphanThumbs'> {
const addonsRepo = t.repo(Addons) as AddonsRepository;
return {
sweepOrphanThumbs: async () => {
await addonsRepo.isEnabled('journey');
return 0;
},
};
}
describe('JourneyThumbsJob boot sweep', () => {
it('JTHUMB-001: onApplicationBootstrap delegates the boot-time sweep through CronRegistrarService.runOnBoot, not a bare void this.sweep()', async () => {
const registered: Array<[string, () => void | Promise<void>]> = [];
const registrar = {
isEnabled: vi.fn(() => true),
register: vi.fn(() => true),
unregister: vi.fn(),
runOnBoot: vi.fn(async (name: string, fn: () => void | Promise<void>) => { registered.push([name, fn]); await fn(); }),
};
const job = new JourneyThumbsJob(makeThumbnails() as ThumbnailService, registrar as unknown as CronRegistrarService);
job.onApplicationBootstrap();
await Promise.resolve(); // flush the fire-and-forget void runOnBoot(...) call
expect(registrar.runOnBoot).toHaveBeenCalledWith('journey-thumbs-boot', expect.any(Function));
});
it('JTHUMB-002: with MikroORM wired into the registrar, the boot-time sweep\'s repository read succeeds — no cannotUseGlobalContext', async () => {
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
const job = new JourneyThumbsJob(makeThumbnails() as ThumbnailService, registrar);
await registrar.runOnBoot('journey-thumbs-boot', () => job.sweep());
expect(logMock.logError).not.toHaveBeenCalled();
});
it('JTHUMB-003: without MikroORM, the boot-time sweep never runs at all — the failure is logged with runOnBoot\'s own distinct message, never swallowed into "Journey thumbnail cleanup: ..."', async () => {
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService); // no orm
let ran = false;
const job = new JourneyThumbsJob({ sweepOrphanThumbs: async () => { ran = true; return 0; } } as ThumbnailService, registrar);
await registrar.runOnBoot('journey-thumbs-boot', () => job.sweep());
expect(ran).toBe(false);
expect(logMock.logError).toHaveBeenCalledWith(expect.stringMatching(/runOnBoot: no MikroORM available.*journey-thumbs-boot/));
expect(logMock.logError).not.toHaveBeenCalledWith(expect.stringContaining('Journey thumbnail cleanup'));
});
});
@@ -0,0 +1,67 @@
/**
* TrekPhotoCacheJob boot sweep (task-6-review-parity.md C1's "scanned for
* siblings" list: sweepExpired is raw SQL today, safe, but the boot-time
* one-off call now goes through CronRegistrarService.runOnBoot at the
* entrypoint anyway, so it stays safe once this dependency graph goes
* repository-backed too).
*/
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { SchedulerRegistry } from '@nestjs/schedule';
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { TrekPhotoCacheJob } from '../../../../src/nest/memories/trek-photo-cache.job';
import type { TrekPhotoCacheService } from '../../../../src/nest/memories/trek-photo-cache.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb, { allowGlobalContext: false }); });
afterAll(async () => { await t.close(); testDb.close(); });
beforeEach(() => vi.clearAllMocks());
describe('TrekPhotoCacheJob boot sweep', () => {
it('TPCACHE-001: onApplicationBootstrap delegates the boot-time sweep through CronRegistrarService.runOnBoot, not a bare inline IIFE', async () => {
const cache = { sweepExpired: vi.fn(async () => {}) } as unknown as TrekPhotoCacheService;
const registrar = {
isEnabled: vi.fn(() => true),
register: vi.fn(() => true),
unregister: vi.fn(),
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
const job = new TrekPhotoCacheJob(cache, registrar as unknown as CronRegistrarService);
job.onApplicationBootstrap();
await Promise.resolve();
expect(registrar.runOnBoot).toHaveBeenCalledWith('trek-photo-cache-boot', expect.any(Function));
expect(cache.sweepExpired).toHaveBeenCalledTimes(1);
});
it('TPCACHE-002: a missing cache dir at boot is still swallowed by the job\'s OWN catch, inside runOnBoot\'s wrapper', async () => {
const cache = { sweepExpired: vi.fn(async () => { throw new Error('ENOENT'); }) } as unknown as TrekPhotoCacheService;
const registrar = {
isEnabled: vi.fn(() => true),
register: vi.fn(() => true),
unregister: vi.fn(),
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
const job = new TrekPhotoCacheJob(cache, registrar as unknown as CronRegistrarService);
expect(() => job.onApplicationBootstrap()).not.toThrow();
await Promise.resolve();
expect(logMock.logError).not.toHaveBeenCalled(); // the inline try/catch around sweepExpired() still swallows it
});
it('TPCACHE-003: without MikroORM, the boot-time sweep never runs — logged with runOnBoot\'s own distinct message', async () => {
const cache = { sweepExpired: vi.fn(async () => {}) } as unknown as TrekPhotoCacheService;
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService); // no orm
await registrar.runOnBoot('trek-photo-cache-boot', async () => {
try { await cache.sweepExpired(); } catch { /* cache dir may not exist yet — harmless */ }
});
expect(cache.sweepExpired).not.toHaveBeenCalled();
expect(logMock.logError).toHaveBeenCalledWith(expect.stringMatching(/runOnBoot: no MikroORM available.*trek-photo-cache-boot/));
});
});
@@ -208,7 +208,9 @@ describe('load failures', () => {
// before any request/MCP/WS/cron entrypoint can reach this service, so
// that race no longer exists — the message-specific swallow is dropped
// and this failure now takes the same "log and serve defaults" path as
// every other repository error.
// every other repository error. (task-6-review-template.md Minor 4: this
// case does not itself assert getPermissionsCache() stays null for the
// same branch — PERM-SVC-024 below does.)
const spy = vi
.spyOn(appSettings, 'findByKeyPrefix')
.mockRejectedValueOnce(new Error('no such table: app_settings'));
@@ -0,0 +1,61 @@
/**
* PlacePhotoCacheJob boot sweep (task-6-review-parity.md C1's "scanned for
* siblings" list: sweepOrphans is raw SQL today, safe, but the boot-time
* one-off call now goes through CronRegistrarService.runOnBoot at the
* entrypoint anyway, so it stays safe once this dependency graph goes
* repository-backed too, without a second review having to rediscover it).
*/
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { SchedulerRegistry } from '@nestjs/schedule';
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { PlacePhotoCacheJob } from '../../../../src/nest/place-photos/place-photo-cache.job';
import type { PlacePhotoCacheService } from '../../../../src/nest/place-photos/place-photo-cache.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb, { allowGlobalContext: false }); });
afterAll(async () => { await t.close(); testDb.close(); });
beforeEach(() => vi.clearAllMocks());
describe('PlacePhotoCacheJob boot sweep', () => {
it('PPCACHE-001: onApplicationBootstrap delegates the boot-time sweep through CronRegistrarService.runOnBoot, not a bare void this.sweep()', async () => {
const cache = { sweepOrphans: vi.fn(async () => 0) } as unknown as PlacePhotoCacheService;
const registrar = {
isEnabled: vi.fn(() => true),
register: vi.fn(() => true),
unregister: vi.fn(),
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
const job = new PlacePhotoCacheJob(cache, registrar as unknown as CronRegistrarService);
job.onApplicationBootstrap();
await Promise.resolve();
expect(registrar.runOnBoot).toHaveBeenCalledWith('place-photo-cache-boot', expect.any(Function));
});
it('PPCACHE-002: with MikroORM wired into the registrar, the boot-time sweep runs and completes', async () => {
const cache = { sweepOrphans: vi.fn(async () => 3) } as unknown as PlacePhotoCacheService;
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
const job = new PlacePhotoCacheJob(cache, registrar);
await registrar.runOnBoot('place-photo-cache-boot', () => job.sweep());
expect(cache.sweepOrphans).toHaveBeenCalledTimes(1);
expect(logMock.logError).not.toHaveBeenCalled();
});
it('PPCACHE-003: without MikroORM, the boot-time sweep never runs — logged with runOnBoot\'s own distinct message, not swallowed into "Place-photo cache cleanup: ..."', async () => {
const cache = { sweepOrphans: vi.fn(async () => 0) } as unknown as PlacePhotoCacheService;
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService); // no orm
const job = new PlacePhotoCacheJob(cache, registrar);
await registrar.runOnBoot('place-photo-cache-boot', () => job.sweep());
expect(cache.sweepOrphans).not.toHaveBeenCalled();
expect(logMock.logError).toHaveBeenCalledWith(expect.stringMatching(/runOnBoot: no MikroORM available.*place-photo-cache-boot/));
expect(logMock.logError).not.toHaveBeenCalledWith(expect.stringContaining('Place-photo cache cleanup'));
});
});
@@ -54,6 +54,11 @@ function makeJobs(overrides: { notifications?: NotificationsService } = {}) {
return true;
}),
unregister: vi.fn(),
// task-6-fix-brief.md item 7: the boot-time banner block now runs through
// CronRegistrarService.runOnBoot instead of directly inline — this
// double just runs fn immediately, reproducing the pre-fix behaviour
// exactly, so every existing assertion below is unaffected.
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
const send = vi.fn().mockResolvedValue(undefined);
const svc = new ReminderJobsService(
@@ -105,6 +110,12 @@ describe('ReminderJobsService bootstrap', () => {
expect(logMock.logInfo).not.toHaveBeenCalled();
});
it('RJOB-002b — the boot banner block goes through CronRegistrarService.runOnBoot (task-6-review-parity.md C1 — the boot-sweep choke point)', async () => {
const { svc, registrar } = makeJobs();
await svc.onApplicationBootstrap();
expect(registrar.runOnBoot).toHaveBeenCalledWith('reminder-jobs-boot', expect.any(Function));
});
it('RJOB-003 — logs the enabled banners by default and the disabled ones when toggled off', async () => {
const { svc } = makeJobs();
await svc.onApplicationBootstrap();
+33 -29
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
/**
* TrekWsAdapter: the wire protocol and the per-socket flood guard.
@@ -35,11 +35,28 @@ import { TrekWsAdapter } from '../../../src/nest/realtime/trek-ws.adapter';
import { getServer } from '../../../src/nest/realtime/ws-state';
import type { Server as HttpServer } from 'node:http';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm } from '../../helpers/test-orm';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { Users } from '../../../src/db/entities/Users.entity';
const testDb = createSnapshotTestDb();
afterAll(() => testDb.close());
// task-6-fix-brief.md item 1: bindMessageHandlers now THROWS without an `orm`
// rather than dispatching a matched handler unwrapped (no more silent
// degrade), so every generic-behaviour test below — none of which cares
// about the D6 request-context property itself, only that a message reaches
// its handler — needs the shared adapter to carry a real (if otherwise
// unexercised) ORM. WSAD-040/041 build their OWN local adapter instances
// specifically to test the with/without-orm property and are unaffected.
let adapterOrm: TestOrm;
let adapter: TrekWsAdapter;
beforeAll(async () => {
adapterOrm = await createTestOrm(testDb);
adapter = new TrekWsAdapter({} as HttpServer, adapterOrm.orm);
});
afterAll(async () => {
await adapterOrm.close();
testDb.close();
});
type MessageListener = (buffer: Buffer) => void;
@@ -61,7 +78,6 @@ function fakeSocket() {
};
}
const adapter = new TrekWsAdapter({} as HttpServer);
const frame = (o: unknown) => Buffer.from(JSON.stringify(o));
/** The adapter hands results to Nest's transform; here it is identity. */
const transform = (v: unknown) => ({ subscribe: (o: { next: (x: unknown) => void }) => o.next(v) }) as never;
@@ -126,31 +142,19 @@ describe('TrekWsAdapter D6 request context (task-2-review.md C3 ruling)', () =>
// shape: dispatched from bindMessageHandlers below, not from an Express
// request, so nothing has forked an EntityManager for it unless the ONE
// wrapper there (not per-handler) does it.
it('WSAD-040: without MikroORM passed to the adapter, a repository read inside a handler throws cannotUseGlobalContext', async () => {
const t = await createTestOrm(testDb, { allowGlobalContext: false });
try {
let caught: unknown;
const ad = new TrekWsAdapter({} as HttpServer); // no orm
const socket = fakeSocket();
let captured: Promise<unknown> | undefined;
const capture = (v: unknown) => {
captured = v instanceof Promise ? v : Promise.resolve(v);
return { subscribe: () => {} } as never;
};
ad.bindMessageHandlers(
socket as never,
[{ message: 'join', callback: async () => {
try { await t.orm.em.find(Users, {}); } catch (e) { caught = e; }
} }] as never,
capture,
);
socket.emit('message', frame({ type: 'join', tripId: 1 }));
await captured;
expect(caught).toBeInstanceOf(Error);
expect(String((caught as Error).message)).toMatch(/global (EntityManager|context)/i);
} finally {
await t.close();
}
it('WSAD-040: without MikroORM passed to the adapter, dispatch THROWS rather than running the handler unwrapped (task-6-fix-brief.md item 1: fail closed at the choke point, not just downstream)', () => {
const ad = new TrekWsAdapter({} as HttpServer); // no orm
const socket = fakeSocket();
let handlerRan = false;
ad.bindMessageHandlers(
socket as never,
[{ message: 'join', callback: async () => { handlerRan = true; } }] as never,
() => ({ subscribe: () => {} }) as never,
);
expect(() => socket.emit('message', frame({ type: 'join', tripId: 1 }))).toThrow(/no MikroORM available/i);
// The wrapper throws BEFORE calling the handler at all — the handler's
// own repository read never even ran unwrapped.
expect(handlerRan).toBe(false);
});
it('WSAD-041: with MikroORM passed to the adapter, the SAME repository read inside a handler succeeds — the wrapper is load-bearing', async () => {
@@ -12,10 +12,16 @@
* from its 'everybody' default to 'admin', a role-'user' actor's check must
* come back `false`. Before the fix (dispatch outside any context, and
* loadPermissions serving defaults on any error) it came back `true` — a
* fail-open security regression. After BOTH this round's fixes — the wrapper
* here, and `loadPermissions` rethrowing a MikroORM `ValidationError` instead
* of degrading (permissions.service.ts) — an unwrapped dispatch fails CLOSED
* (a HOST_ERROR envelope), never silently `true`.
* fail-open security regression. Task 2's round fixed it two switches away
* from the choke point — the wrapper here, and `loadPermissions` rethrowing a
* MikroORM `ValidationError` instead of degrading (permissions.service.ts) —
* so an unwrapped dispatch failed CLOSED (a HOST_ERROR envelope) but only
* because BOTH switches held. task-6-fix-brief.md item 1 (task-6-review-
* template.md's Important 1 / M-B) moves the fail-closed decision TO the
* choke point itself: an absent `resolveOrm` now makes the dispatch THROW
* before it ever reaches `rpcHost.dispatch`, so the child gets no response at
* all — not even the old HOST_ERROR envelope — and the security property no
* longer depends on `loadPermissions`' rethrow holding too.
*
* Global context is disallowed on purpose (`{ allowGlobalContext: false }`),
* like `tests/unit/nest/database/request-context.test.ts` — the production
@@ -106,16 +112,16 @@ function makeSupervisor(resolveOrm?: () => { em: EntityManager } | undefined) {
}
describe('PluginSupervisor request context (D6, C3)', () => {
it('CTX-PLUGIN-001: unwrapped, the RPC path fails CLOSED on a cold cache — never the old fail-open `true`', async () => {
it('CTX-PLUGIN-001: without a resolveOrm thunk, the dispatch THROWS rather than running unwrapped (task-6-fix-brief.md item 1: fail closed at the choke point, not just downstream)', async () => {
permissions.invalidatePermissionsCache();
const { supervisor, sup, sent } = makeSupervisor(); // no resolveOrm — the bug's exact reproduction
await supervisor.onMessage(sup, { k: 'req', id: 'r1', method: 'trips.create', params: { _inv: 'inv-1' } });
expect(sent).toHaveLength(1);
const res = sent[0];
expect(res.ok).toBe(false);
if (res.ok === false) expect(res.error.message).toMatch(/global (EntityManager|context)/i);
await expect(
supervisor.onMessage(sup, { k: 'req', id: 'r1', method: 'trips.create', params: { _inv: 'inv-1' } }),
).rejects.toThrow(/no ORM available/i);
// The dispatch never ran, so the child never got a response at all —
// not even the old fail-closed HOST_ERROR envelope.
expect(sent).toHaveLength(0);
});
it('CTX-PLUGIN-002: wrapped in withRequestContext, the same dispatch enforces the stored override — a "user" role is refused', async () => {
@@ -4,14 +4,25 @@
* an admin depends on: re-activating a plugin that died stays possible, and a
* crash-restart cycle doesn't leak the dead child's cron tasks.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
import { RpcRateLimiter, TokenBucket } from '../../../src/nest/plugins/host/rate-limit';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
// task-6-fix-brief.md item 1: onMessage's 'req' branch now THROWS without a
// resolveOrm thunk (no more silent unwrapped dispatch), so the one case below
// that dispatches a real 'req' message ("refuses a throttled call...") needs a
// real ORM even though nothing it exercises reaches a repository.
const ormDb = new Database(':memory:');
let ormHandle: TestOrm;
beforeAll(async () => { ormHandle = await createTestOrm(ormDb); });
afterAll(async () => { await ormHandle.close(); ormDb.close(); });
function makeSupervisor() {
const dispose = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new PluginSupervisor((() => ({ dispose })) as any, {}, {});
const s = new PluginSupervisor((() => ({ dispose })) as any, {}, {}, () => ormHandle.orm);
const spawn = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any).spawn = spawn;