Files
TREK/server/tests/integration/plugins/boot-registry-order.test.ts
T
Maurice e015e5e5e8 fix(server): run the remaining multi-statement service writes in one transaction
A sweep of the services for write sequences that committed step by step,
each one leaving half a change behind when the next statement failed:

- accommodations: the linked bookings' times moved after the stay's own
  transaction had committed
- airports: the boot backfill wrote a flight's two endpoints one at a
  time, and a flight left with only the first was never picked up again;
  each flight is its own transaction now
- auth: the admin settings form and a password reset (retire the old
  link, issue the new one) each land whole
- oauth: a refresh revoked the old pair before issuing the new one, so a
  failure in between logged the client out; rotating a client secret and
  revoking its tokens go together too
- oidc: the identity link, the role from the claims and the avatar of a
  returning user are one write
- addons, notification preferences: a settings save lands whole
- places: create with its tags, update, and the GPX/KML imports with
  their track colours
- trips: the trip row, the shifted leave entries and the rebuilt day grid
- vacay: a first visit provisions the plan in one transaction with a
  second look against a concurrent first visit; updatePlan fetches the
  public holidays between two transactions instead of inside its writes
- memories: a photo and its trip link, and the deletes with the orphan
  sweep behind them

AddonsService, AirportsService and OauthService take the UnitOfWork for
this. Network I/O stays outside every transaction. Comments that only
narrated the migration plan make room under the lint:size entries, which
drop with them.
2026-10-08 18:41:32 +02:00

297 lines
15 KiB
TypeScript

/**
* Boot-order regression (#plugins): the RPC registry must be scanned before the
* runtime auto-activates the plugins an admin had enabled.
*
* The bug this pins: PluginRuntimeService booted enabled plugins from
* onModuleInit, and Nest fires same-module onModuleInit hooks in providers-array
* declaration order — where the runtime service is listed BEFORE
* PluginRpcRegistryService. Each boot activation builds its PluginRpcHost
* synchronously (bindInto snapshots the registry at construction), so every host
* was bound from a STILL-EMPTY registry: the plugin's first RPC on activation
* (typically ctx.db.migrate in onLoad) came back PERMISSION_DENIED and the plugin
* landed in error state on every cold boot, until an admin deactivated and
* reactivated it by hand. The fix moves the boot reconcile to
* onApplicationBootstrap, which Nest guarantees runs after EVERY module's
* onModuleInit — so this suite assembles a real Nest module mirroring the
* production declaration order (runtime provider first, registry scan later) and
* asserts the plugin still comes up clean.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
return { db, canAccessTrip: () => undefined };
});
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { db as testDb } from '../../../src/db/database';
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { createTestAddonsService } from '../../helpers/test-addons';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { Addons } from '../../../src/db/entities/Addons.entity';
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
import { PhotoProviderFields } from '../../../src/db/entities/PhotoProviderFields.entity';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { PluginRpcHostFactory } from '../../../src/nest/plugins/host/plugin-rpc-host.factory';
import { PluginRpcRegistry } from '../../../src/nest/plugins/host/rpc-kit/registry';
import type { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../../src/db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
let codeRoot: string;
let dataRoot: string;
let mod: TestingModule;
let t: TestOrm | undefined;
beforeAll(() => {
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-code-'));
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-data-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
process.env.TREK_PLUGINS_ENABLED = 'true';
// The exact shape the bug bit: a plugin that runs its own-db migration as the
// first thing its onLoad does — what every db-backed plugin's activation looks like.
const dir = path.join(codeRoot, 'migrator', 'server');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'index.js'),
`module.exports = { async onLoad(ctx) { await ctx.db.migrate('001', 'CREATE TABLE t (n INTEGER)'); } };`,
);
// The row a container recreate leaves behind: enabled, already consented to
// db:own. Seeded 'inactive' (not the 'active' a real shutdown leaves) so the
// poll below only terminates on a status the THIS-boot supervisor wrote.
testDb
.prepare("INSERT INTO plugins (id, name, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('migrator','migrator','inactive',1,'[\"db:own\"]','[\"db:own\"]','{}','>=3.0.0')")
.run();
});
afterAll(async () => {
await mod?.close();
await t?.close();
delete process.env.TREK_PLUGINS_DIR;
delete process.env.TREK_PLUGINS_DATA_DIR;
delete process.env.TREK_PLUGINS_ENABLED;
fs.rmSync(codeRoot, { recursive: true, force: true });
fs.rmSync(dataRoot, { recursive: true, force: true });
});
describe('plugin boot vs registry scan ordering', () => {
it('BOOT-REG-001 a plugin enabled before a restart activates cleanly even though the registry scan runs in a LATER provider\'s onModuleInit', async () => {
// Empty at construction — exactly what PluginRpcRegistryService is before its
// own onModuleInit scan has run.
const registry = new PluginRpcRegistry();
t = await createTestOrm(testDb);
// Plan 3j Task 3: `PluginRpcHostFactory`'s `audit` callback now takes
// `PluginCapabilityAuditRepository`, not `DatabaseService`.
const hostFactory = new PluginRpcHostFactory(t.repo(PluginCapabilityAudit), registry as unknown as PluginRpcRegistryService);
const userSettings = new PluginUserSettingsService((t as TestOrm).repo(PluginSettingsFields), (t as TestOrm).repo(PluginUserConfig));
const auditLogRepo = t.repo(AuditLog);
const usersRepo = t.repo(Users);
const addonsService = await createTestAddonsService(testDb);
mod = await Test.createTestingModule({
providers: [
// Mirrors plugins-runtime.module.ts: the runtime service is DECLARED BEFORE
// the provider that populates the registry, so Nest fires its onModuleInit
// (if it had one) first. The boot reconcile must therefore not live there.
{
provide: PluginRuntimeService,
// 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(
new AuditService(auditLogRepo, usersRepo),
addonsService,
userSettings,
(t as TestOrm).repo(Plugins),
(t as TestOrm).repo(PluginErrorLog),
(t as TestOrm).repo(PluginScheduledTasks),
(t as TestOrm).repo(PluginUserErasureQueue),
(t as TestOrm).repo(PluginEgressHosts),
(t as TestOrm).repo(PluginSettingsFields),
(t as TestOrm).repo(PluginActions),
(t as TestOrm).repo(PluginUserConfig),
(t as TestOrm).repo(PluginEntityMetadata),
(t as TestOrm).repo(PluginOauthTokens),
(t as TestOrm).repo(PluginOauthState),
(t as TestOrm).repo(PluginMetaMigrations),
(t as TestOrm).repo(PluginCapabilityAudit),
(t as TestOrm).repo(Settings),
(t as TestOrm).repo(NotificationChannelPreferences),
// Plan 4 Task 4: `uow` is no longer `@Optional()` — ordered ahead
// of `registry?`/`hostFactory?`, matching the real constructor.
new UnitOfWork((t as TestOrm).em),
undefined,
hostFactory,
t?.orm,
),
},
{
provide: 'REGISTRY_SCAN',
useFactory: () => ({
onModuleInit() {
registry.register(new DbRpc(userSettings));
},
}),
},
],
}).compile();
await mod.init();
// Boot activation is fire-and-forget; wait for the supervisor to settle the row.
const runtime = mod.get(PluginRuntimeService);
let row = { status: 'starting', last_error: null as string | null };
for (let i = 0; i < 100; i++) {
row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='migrator'").get() as typeof row;
if (row.status === 'active' || row.status === 'error') break;
await new Promise((r) => setTimeout(r, 50));
}
expect(row.last_error).toBeNull();
expect(row.status).toBe('active');
expect(runtime.isActive('migrator')).toBe(true);
});
/**
* task-6-rereview.md I1: `onApplicationBootstrap`'s boot-activation loop
* (`this.activate(id)` for every enabled plugin) reaches `assertActivatable`
* → `disabledRequiredAddons` → `AddonsService.isAddonEnabled` —
* repository-backed — with no request context wrapped around it. An
* installed+enabled plugin declaring a non-empty `requiredAddons` for an
* addon that IS enabled used to throw `cannotUseGlobalContext` here,
* silently (double-swallowed: `activate(id).catch(...)` only reconciles
* `PluginDependencyError`/`DependencyCycleError`, and the outer try/catch
* around the whole discovery/boot block eats everything else) — the plugin
* never left `'inactive'` and no log line named the failure at all.
*/
it('BOOT-REG-002 an installed+enabled plugin declaring requiredAddons for an ENABLED addon activates cleanly at boot — the addon check runs inside a request context', async () => {
const dir = path.join(codeRoot, 'addongated', 'server');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.js'), `module.exports = { async onLoad(ctx) {} };`);
testDb.prepare("INSERT INTO addons (id, name, enabled) VALUES ('needsaddon_addon', 'needsaddon_addon', 1)").run();
testDb
.prepare(
"INSERT INTO plugins (id, name, status, enabled, permissions, granted_permissions, config, trek_range, dependencies) VALUES ('addongated','addongated','inactive',1,'[]','[]','{}','>=3.0.0', ?)",
)
.run(JSON.stringify({ requiredAddons: ['needsaddon_addon'] }));
// Self-contained ORM/collaborators — independent of test 1's `t`, which is
// assigned inside ITS `it` body rather than a shared beforeAll. Global
// context is disallowed on purpose here (unlike test 1's `t`, and unlike
// createTestAddonsService's sharedTestOrm, both of which default to
// allowGlobalContext: true — the test-only convenience that would let
// AddonsService.isAddonEnabled succeed with NO request context and hide
// exactly the bug this test exists to catch): this is what makes the
// addon check below genuinely depend on the withRequestContext wrap
// rather than passing either way.
const t2 = await createTestOrm(testDb, { allowGlobalContext: false });
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
let mod2: TestingModule | undefined;
try {
const userSettings2 = new PluginUserSettingsService(t2.repo(PluginSettingsFields), t2.repo(PluginUserConfig));
const registry2 = new PluginRpcRegistry();
// Registry already scanned — this test is about the addon check, not the
// registry-ordering bug BOOT-REG-001 pins.
registry2.register(new DbRpc(userSettings2));
const hostFactory2 = new PluginRpcHostFactory(t2.repo(PluginCapabilityAudit), registry2 as unknown as PluginRpcRegistryService);
// Built directly on t2 (not createTestAddonsService's sharedTestOrm) so
// its repositories share t2's allowGlobalContext: false ORM.
const addonsService2 = new AddonsService(
t2.repo(Addons),
t2.repo(PhotoProviders),
t2.repo(PhotoProviderFields),
t2.repo(AppSettings),
t2.repo(Users),
new UnitOfWork(t2.em),
);
const auditLogRepo2 = t2.repo(AuditLog);
const usersRepo2 = t2.repo(Users);
mod2 = await Test.createTestingModule({
providers: [
{
provide: PluginRuntimeService,
useFactory: () =>
new PluginRuntimeService(
new AuditService(auditLogRepo2, usersRepo2),
addonsService2,
userSettings2,
t2.repo(Plugins),
t2.repo(PluginErrorLog),
t2.repo(PluginScheduledTasks),
t2.repo(PluginUserErasureQueue),
t2.repo(PluginEgressHosts),
t2.repo(PluginSettingsFields),
t2.repo(PluginActions),
t2.repo(PluginUserConfig),
t2.repo(PluginEntityMetadata),
t2.repo(PluginOauthTokens),
t2.repo(PluginOauthState),
t2.repo(PluginMetaMigrations),
t2.repo(PluginCapabilityAudit),
t2.repo(Settings),
t2.repo(NotificationChannelPreferences),
new UnitOfWork(t2.em),
undefined,
hostFactory2,
t2.orm,
),
},
],
}).compile();
await mod2.init();
const runtime2 = mod2.get(PluginRuntimeService);
let row = { status: 'starting', last_error: null as string | null };
for (let i = 0; i < 100; i++) {
row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='addongated'").get() as typeof row;
if (row.status === 'active' || row.status === 'error') break;
await new Promise((r) => setTimeout(r, 50));
}
expect(row.last_error).toBeNull();
expect(row.status).toBe('active');
expect(runtime2.isActive('addongated')).toBe(true);
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();
await mod2?.close();
await t2.close();
}
});
});