mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
591 lines
28 KiB
TypeScript
591 lines
28 KiB
TypeScript
/**
|
|
* Plan 3j Task 2 — mutation/parity proofs the brief names explicitly, on top of the
|
|
* pre-existing (and still-green) `plugin-runtime.test.ts`/`plugins-service.test.ts`
|
|
* suites: R-install-gates' three named accept+refuse pairs (PR17/PR26/PR28), the
|
|
* 9-table uninstall cascade's preserved NON-transactional partial-failure shape
|
|
* (R-uninstall), the egress-host DELETE+loop-INSERT's new transaction rollback proof
|
|
* (R-uninstall's ONE named exception), PS7/PS9/PS11's triple-duplicate SELECT
|
|
* collapsing onto one `PluginUserConfigRepository.findConfig` method, and a two-tick
|
|
* race for the scheduler sweep + the GDPR erasure drain.
|
|
*
|
|
* Full migrated schema (`createSnapshotTestDb`) — the same real-schema harness
|
|
* `plugin-scheduler-sweep.test.ts`/`plugin-runtime.boot-no-orm.test.ts` already use —
|
|
* so every one of the 14 repositories this task injects reads/writes real columns,
|
|
* not a hand-slimmed fixture table.
|
|
*/
|
|
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
|
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
|
|
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
|
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
|
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
|
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
|
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
|
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
|
|
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
|
|
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
|
|
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
|
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
|
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
|
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
|
|
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
|
import { Settings } from '../../../src/db/entities/Settings.entity';
|
|
import { Users } from '../../../src/db/entities/Users.entity';
|
|
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
|
import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service';
|
|
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
|
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
|
import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { createTestAddonsService } from '../../helpers/test-addons';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
|
|
|
const testDb = createSnapshotTestDb();
|
|
let t: TestOrm;
|
|
let codeRoot: string;
|
|
let dataRoot: string;
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestOrm(testDb);
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-t2-code-'));
|
|
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-t2-data-'));
|
|
process.env.TREK_PLUGINS_DIR = codeRoot;
|
|
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
delete process.env.TREK_PLUGINS_ENABLED;
|
|
delete process.env.TREK_PLUGINS_DIR;
|
|
delete process.env.TREK_PLUGINS_DATA_DIR;
|
|
await t.close();
|
|
testDb.close();
|
|
fs.rmSync(codeRoot, { recursive: true, force: true });
|
|
fs.rmSync(dataRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function buildRuntime(registrar?: CronRegistrarService): Promise<PluginRuntimeService> {
|
|
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
|
const addons = await createTestAddonsService(testDb);
|
|
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
|
|
const uow = new UnitOfWork(t.em);
|
|
return new PluginRuntimeService(
|
|
audit,
|
|
addons,
|
|
userSettings,
|
|
t.repo(Plugins),
|
|
t.repo(PluginErrorLog),
|
|
t.repo(PluginScheduledTasks),
|
|
t.repo(PluginUserErasureQueue),
|
|
t.repo(PluginEgressHosts),
|
|
t.repo(PluginSettingsFields),
|
|
t.repo(PluginActions),
|
|
t.repo(PluginUserConfig),
|
|
t.repo(PluginEntityMetadata),
|
|
t.repo(PluginOauthTokens),
|
|
t.repo(PluginOauthState),
|
|
t.repo(PluginMetaMigrations),
|
|
t.repo(PluginCapabilityAudit),
|
|
t.repo(Settings),
|
|
t.repo(NotificationChannelPreferences),
|
|
// Plan 4 Task 4: `uow` is no longer `@Optional()` — ordered ahead of
|
|
// `registry?`/`hostFactory?`, matching the real constructor's own order.
|
|
uow,
|
|
undefined,
|
|
undefined,
|
|
t.orm,
|
|
registrar,
|
|
);
|
|
}
|
|
|
|
function buildPluginsService(): PluginsService {
|
|
return new PluginsService(
|
|
// A real AddonsService is not needed by any method this file calls.
|
|
{ isAddonEnabled: async () => true } as never,
|
|
t.repo(Plugins),
|
|
t.repo(PluginEgressHosts),
|
|
t.repo(PluginSettingsFields),
|
|
t.repo(PluginActions),
|
|
t.repo(PluginUserConfig),
|
|
t.repo(PluginErrorLog),
|
|
t.repo(PluginCapabilityAudit),
|
|
);
|
|
}
|
|
|
|
function seedPlugin(
|
|
id: string,
|
|
overrides: Partial<{
|
|
enabled: number;
|
|
granted_permissions: string;
|
|
permissions: string;
|
|
trek_range: string;
|
|
api_version: number;
|
|
version: string;
|
|
author_pubkey: string;
|
|
}> = {},
|
|
) {
|
|
testDb
|
|
.prepare(
|
|
`INSERT INTO plugins (id, name, status, enabled, version, trek_range, api_version, permissions, granted_permissions, capabilities, config, author_pubkey)
|
|
VALUES (?, ?, 'inactive', ?, ?, ?, ?, ?, ?, '{}', '{}', ?)`,
|
|
)
|
|
.run(
|
|
id,
|
|
id,
|
|
overrides.enabled ?? 0,
|
|
overrides.version ?? '1.0.0',
|
|
overrides.trek_range ?? '>=3.0.0',
|
|
overrides.api_version ?? 1,
|
|
overrides.permissions ?? '["db:own"]',
|
|
overrides.granted_permissions ?? '["db:own"]',
|
|
overrides.author_pubkey ?? null,
|
|
);
|
|
}
|
|
|
|
describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR26/PR28)', () => {
|
|
let rt: PluginRuntimeService;
|
|
beforeAll(async () => {
|
|
rt = await buildRuntime();
|
|
});
|
|
|
|
describe('PR17 — version-range + permission-grant gate (assertActivatable)', () => {
|
|
it('INSTALL-GATE-PR17-ACCEPT: a plugin whose declared range admits this host, with no permission widening, passes the gate (activate resolves, never PluginDependencyError)', async () => {
|
|
seedPlugin('pr17-accept', { trek_range: '>=3.0.0 <99.0.0' });
|
|
// The gate itself (assertActivatable, reading PR17's row through
|
|
// PluginsRepository.findActivationGate) is the thing under test — the actual
|
|
// child spawn is stubbed out (already covered end to end by
|
|
// plugin-runtime.test.ts's own activation suite, real code on disk, real spawn).
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const supervisorSpy = vi.spyOn((rt as any).supervisor, 'activate').mockResolvedValue(undefined);
|
|
await expect(rt.activate('pr17-accept')).resolves.toBeUndefined();
|
|
expect(supervisorSpy).toHaveBeenCalledTimes(1); // the gate let it through to the spawn call
|
|
supervisorSpy.mockRestore();
|
|
});
|
|
|
|
it('INSTALL-GATE-PR17-REFUSE: a plugin whose declared range excludes this host is refused, never spawned', async () => {
|
|
seedPlugin('pr17-refuse', { trek_range: '>=99.0.0' });
|
|
const err = await rt.activate('pr17-refuse').catch((e) => e);
|
|
expect(err).toBeInstanceOf(PluginDependencyError);
|
|
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
|
expect(rt.isActive('pr17-refuse')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("PR26 — update()'s pre-update snapshot feeding the permission-widening diff", () => {
|
|
const fakeRegistry = (perms: string[]) =>
|
|
({
|
|
resolveVersion: vi.fn(async () => ({ version: '2.0.0' })),
|
|
install: vi.fn(async (id: string) => {
|
|
testDb.prepare('UPDATE plugins SET permissions = ? WHERE id = ?').run(JSON.stringify(perms), id);
|
|
return { id, version: '2.0.0' };
|
|
}),
|
|
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
|
|
|
it('INSTALL-GATE-PR26-ACCEPT: no new permissions on update -> activated: true, PR26 snapshot read via the repository', async () => {
|
|
seedPlugin('pr26-accept2', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
|
|
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
|
const addons = await createTestAddonsService(testDb);
|
|
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
|
|
const rtU = new PluginRuntimeService(
|
|
audit,
|
|
addons,
|
|
userSettings,
|
|
t.repo(Plugins),
|
|
t.repo(PluginErrorLog),
|
|
t.repo(PluginScheduledTasks),
|
|
t.repo(PluginUserErasureQueue),
|
|
t.repo(PluginEgressHosts),
|
|
t.repo(PluginSettingsFields),
|
|
t.repo(PluginActions),
|
|
t.repo(PluginUserConfig),
|
|
t.repo(PluginEntityMetadata),
|
|
t.repo(PluginOauthTokens),
|
|
t.repo(PluginOauthState),
|
|
t.repo(PluginMetaMigrations),
|
|
t.repo(PluginCapabilityAudit),
|
|
t.repo(Settings),
|
|
t.repo(NotificationChannelPreferences),
|
|
new UnitOfWork(t.em),
|
|
fakeRegistry(['db:own']),
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
vi.spyOn((rtU as any).supervisor, 'activate').mockResolvedValue(undefined);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
vi.spyOn((rtU as any).supervisor, 'disable').mockResolvedValue(undefined);
|
|
await rtU.activate('pr26-accept2');
|
|
const res = await rtU.update('pr26-accept2');
|
|
expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] });
|
|
});
|
|
|
|
it('INSTALL-GATE-PR26-REFUSE: a widened permission set on update leaves the plugin inactive with the delta reported', async () => {
|
|
seedPlugin('pr26-refuse', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
|
|
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
|
const addons = await createTestAddonsService(testDb);
|
|
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
|
|
const rtU = new PluginRuntimeService(
|
|
audit,
|
|
addons,
|
|
userSettings,
|
|
t.repo(Plugins),
|
|
t.repo(PluginErrorLog),
|
|
t.repo(PluginScheduledTasks),
|
|
t.repo(PluginUserErasureQueue),
|
|
t.repo(PluginEgressHosts),
|
|
t.repo(PluginSettingsFields),
|
|
t.repo(PluginActions),
|
|
t.repo(PluginUserConfig),
|
|
t.repo(PluginEntityMetadata),
|
|
t.repo(PluginOauthTokens),
|
|
t.repo(PluginOauthState),
|
|
t.repo(PluginMetaMigrations),
|
|
t.repo(PluginCapabilityAudit),
|
|
t.repo(Settings),
|
|
t.repo(NotificationChannelPreferences),
|
|
new UnitOfWork(t.em),
|
|
fakeRegistry(['db:own', 'db:read:trips']),
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
vi.spyOn((rtU as any).supervisor, 'activate').mockResolvedValue(undefined);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
vi.spyOn((rtU as any).supervisor, 'disable').mockResolvedValue(undefined);
|
|
await rtU.activate('pr26-refuse');
|
|
const res = await rtU.update('pr26-refuse');
|
|
expect(res.activated).toBe(false);
|
|
expect(res.newPermissions).toEqual(['db:read:trips']);
|
|
});
|
|
});
|
|
|
|
describe("PR28 — retrust()'s signing-key TOFU comparison", () => {
|
|
const registryFor = (id: string) =>
|
|
({
|
|
assertRetrustable: vi.fn(async (_id: string, key: string) => ({ authorPublicKey: key })),
|
|
install: vi.fn(async (_id: string, opts?: { retrustKey?: string }) => {
|
|
testDb
|
|
.prepare("UPDATE plugins SET author_pubkey = ?, version = '2.0.0' WHERE id = ?")
|
|
.run(opts?.retrustKey, id);
|
|
return { id, version: '2.0.0' };
|
|
}),
|
|
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
|
|
|
it('INSTALL-GATE-PR28-ACCEPT: a genuinely rotated key is pinned, read through PluginsRepository.findAuthorPubkey', async () => {
|
|
seedPlugin('pr28-accept', { enabled: 1, author_pubkey: 'OLDKEY' });
|
|
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
|
const addons = await createTestAddonsService(testDb);
|
|
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
|
|
const registry = registryFor('pr28-accept');
|
|
const rtR = new PluginRuntimeService(
|
|
audit,
|
|
addons,
|
|
userSettings,
|
|
t.repo(Plugins),
|
|
t.repo(PluginErrorLog),
|
|
t.repo(PluginScheduledTasks),
|
|
t.repo(PluginUserErasureQueue),
|
|
t.repo(PluginEgressHosts),
|
|
t.repo(PluginSettingsFields),
|
|
t.repo(PluginActions),
|
|
t.repo(PluginUserConfig),
|
|
t.repo(PluginEntityMetadata),
|
|
t.repo(PluginOauthTokens),
|
|
t.repo(PluginOauthState),
|
|
t.repo(PluginMetaMigrations),
|
|
t.repo(PluginCapabilityAudit),
|
|
t.repo(Settings),
|
|
t.repo(NotificationChannelPreferences),
|
|
new UnitOfWork(t.em),
|
|
registry,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
vi.spyOn((rtR as any).supervisor, 'activate').mockResolvedValue(undefined);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
vi.spyOn((rtR as any).supervisor, 'disable').mockResolvedValue(undefined);
|
|
await rtR.retrust('pr28-accept', '2.0.0', 'NEWKEY', { userId: 1 });
|
|
expect(registry.assertRetrustable).toHaveBeenCalledWith('pr28-accept', 'NEWKEY');
|
|
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-accept'").get() as {
|
|
author_pubkey: string;
|
|
};
|
|
expect(row.author_pubkey).toBe('NEWKEY');
|
|
});
|
|
|
|
it('INSTALL-GATE-PR28-REFUSE: an invalid signature is not re-trustable — the pinned key is untouched', async () => {
|
|
seedPlugin('pr28-refuse', { enabled: 1, author_pubkey: 'OLDKEY' });
|
|
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
|
const addons = await createTestAddonsService(testDb);
|
|
const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig));
|
|
const registry = registryFor('pr28-refuse');
|
|
vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust'));
|
|
const rtR = new PluginRuntimeService(
|
|
audit,
|
|
addons,
|
|
userSettings,
|
|
t.repo(Plugins),
|
|
t.repo(PluginErrorLog),
|
|
t.repo(PluginScheduledTasks),
|
|
t.repo(PluginUserErasureQueue),
|
|
t.repo(PluginEgressHosts),
|
|
t.repo(PluginSettingsFields),
|
|
t.repo(PluginActions),
|
|
t.repo(PluginUserConfig),
|
|
t.repo(PluginEntityMetadata),
|
|
t.repo(PluginOauthTokens),
|
|
t.repo(PluginOauthState),
|
|
t.repo(PluginMetaMigrations),
|
|
t.repo(PluginCapabilityAudit),
|
|
t.repo(Settings),
|
|
t.repo(NotificationChannelPreferences),
|
|
new UnitOfWork(t.em),
|
|
registry,
|
|
);
|
|
await expect(rtR.retrust('pr28-refuse', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
|
|
expect(registry.install).not.toHaveBeenCalled();
|
|
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-refuse'").get() as {
|
|
author_pubkey: string;
|
|
};
|
|
expect(row.author_pubkey).toBe('OLDKEY');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Plan 3j Task 2 — R-uninstall: the 9-table cascade stays non-transactional (concurrency-pin)', () => {
|
|
it('CASCADE-PARTIAL-001: a failure partway through the cascade leaves EARLIER deletes committed and LATER ones never attempted — the CURRENT legacy shape, not hardened to all-or-nothing', async () => {
|
|
const rt = await buildRuntime();
|
|
const id = 'cascade-partial';
|
|
seedPlugin(id);
|
|
testDb
|
|
.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope) VALUES (?, 'k', 'instance')")
|
|
.run(id);
|
|
testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, 'poll', 0)").run(id);
|
|
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, 'error', 'x')").run(id);
|
|
testDb
|
|
.prepare(
|
|
"INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value) VALUES (?, 'place', 1, 'k', 'v')",
|
|
)
|
|
.run(id);
|
|
|
|
// Simulate a crash INSIDE the cascade, between plugin_scheduled_tasks (deletes
|
|
// cleanly, unconditional, no try/catch) and plugin_error_log (the next
|
|
// unconditional delete, inside the deleteData branch).
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const errorLogRepo = (rt as any).pluginErrorLog;
|
|
const spy = vi
|
|
.spyOn(errorLogRepo, 'deleteAllForPlugin')
|
|
.mockRejectedValueOnce(new Error('simulated mid-cascade crash'));
|
|
|
|
await expect(rt.uninstall(id, true)).rejects.toThrow(/simulated mid-cascade crash/);
|
|
spy.mockRestore();
|
|
|
|
// EVERYTHING before the simulated crash point is already gone — no transaction
|
|
// rolled it back, exactly matching the legacy sequential-statement shape.
|
|
expect(testDb.prepare('SELECT COUNT(*) c FROM plugins WHERE id=?').get(id)).toMatchObject({ c: 0 });
|
|
expect(testDb.prepare('SELECT COUNT(*) c FROM plugin_settings_fields WHERE plugin_id=?').get(id)).toMatchObject({
|
|
c: 0,
|
|
});
|
|
expect(testDb.prepare('SELECT COUNT(*) c FROM plugin_scheduled_tasks WHERE plugin_id=?').get(id)).toMatchObject({
|
|
c: 0,
|
|
});
|
|
// The row the mock made deleteAllForPlugin THROW for is untouched (never committed)...
|
|
expect(testDb.prepare('SELECT COUNT(*) c FROM plugin_error_log WHERE plugin_id=?').get(id)).toMatchObject({ c: 1 });
|
|
// ...and everything AFTER it in cascade order never even ran.
|
|
expect(testDb.prepare('SELECT COUNT(*) c FROM plugin_entity_metadata WHERE plugin_id=?').get(id)).toMatchObject({
|
|
c: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Plan 3j Task 2 — R-uninstall's ONE named transaction: PR22/PR23 egress-host replace", () => {
|
|
it('EGRESS-TX-ROLLBACK-001: a simulated failure mid-write leaves the OLD egress-host set intact, not partially cleared', async () => {
|
|
const rt = await buildRuntime();
|
|
const id = 'egress-tx';
|
|
seedPlugin(id, { permissions: '["http:outbound"]', granted_permissions: '["http:outbound"]' });
|
|
testDb.prepare('UPDATE plugins SET operator_egress = 1 WHERE id = ?').run(id);
|
|
await rt.setOperatorEgressHosts(id, ['old-a.example.com', 'old-b.example.com']);
|
|
expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']);
|
|
|
|
// Plan 3j Task 7 fix (must-land 4a, task-7-review.md): mocking the WHOLE
|
|
// `replaceAllForPlugin` call (the old shape of this test) throws before the real
|
|
// DELETE ever runs, so it proves nothing about the transaction — M13 (deleting the
|
|
// `uow.transactional` wrapper entirely) still passed against it. Spy on the per-host
|
|
// `upsert` call INSIDE `replaceAllForPlugin` instead, and only throw on the SECOND
|
|
// host: the real DELETE and the real first-host INSERT both already happened by the
|
|
// time this throws, so the assertion below can only pass if the transaction actually
|
|
// rolled both of them back, not just skipped a write that was never attempted.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const egressRepo = (rt as any).pluginEgressHosts;
|
|
const originalUpsert = egressRepo.upsert.bind(egressRepo);
|
|
const spy = vi
|
|
.spyOn(egressRepo, 'upsert')
|
|
.mockImplementation(async (data: { plugin_id: string; host: string }, ...rest: unknown[]) => {
|
|
if (data.host === 'boom') throw new Error('simulated mid-write failure');
|
|
return originalUpsert(data, ...rest);
|
|
});
|
|
|
|
await expect(rt.setOperatorEgressHosts(id, ['new-a.example.com', 'boom'])).rejects.toThrow(
|
|
/simulated mid-write failure/,
|
|
);
|
|
spy.mockRestore();
|
|
|
|
// The OLD set survives untouched — the transaction rolled the DELETE AND the
|
|
// already-succeeded 'new-a.example.com' insert back too, not a partially-cleared or
|
|
// partially-replaced allow-list.
|
|
expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']);
|
|
});
|
|
});
|
|
|
|
describe('Plan 3j Task 2 — PS7/PS9/PS11: the triple-duplicate SELECT collapses onto ONE PluginUserConfigRepository.findConfig call', () => {
|
|
it("PS-USERCONFIG-PARITY-001: getUserConfig, setUserConfig's pre-write check and getUserConfigDecrypted all read the SAME stored row identically", async () => {
|
|
const svc = buildPluginsService();
|
|
const id = 'ps-parity';
|
|
seedPlugin(id);
|
|
testDb
|
|
.prepare(
|
|
"INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'apiKey', 'user', 1)",
|
|
)
|
|
.run(id);
|
|
testDb
|
|
.prepare(
|
|
"INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'units', 'user', 0)",
|
|
)
|
|
.run(id);
|
|
|
|
await svc.updateUserConfig(id, 7, { apiKey: 'sk-live', units: 'metric' });
|
|
|
|
// PS7 (getUserConfig, masked) and PS11 (getUserConfigDecrypted, plaintext) both
|
|
// resolve through the SAME repository method (`findConfig`) as PS9's pre-write
|
|
// check inside updateUserConfig above — all three must agree on what is stored.
|
|
const masked = await svc.getUserConfig(id, 7);
|
|
const decrypted = await svc.getUserConfigDecrypted(id, 7);
|
|
expect(masked.units).toBe('metric');
|
|
expect(masked.apiKey).toBe('••••••••'); // PS7: secret masked
|
|
expect(decrypted.units).toBe('metric');
|
|
expect(decrypted.apiKey).toBe('sk-live'); // PS11: secret decrypted, host-only
|
|
});
|
|
});
|
|
|
|
describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure drain)', () => {
|
|
let registry: SchedulerRegistry;
|
|
let registrar: CronRegistrarService;
|
|
let rt: PluginRuntimeService;
|
|
|
|
beforeAll(async () => {
|
|
registry = new SchedulerRegistry();
|
|
registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm);
|
|
rt = await buildRuntime(registrar);
|
|
await rt.onApplicationBootstrap();
|
|
});
|
|
afterAll(async () => {
|
|
await rt.onModuleDestroy();
|
|
});
|
|
|
|
it('RACE-SCHED-001: two concurrent fireDueScheduled passes deliver a due one-shot task exactly once', async () => {
|
|
const pluginId = 'race-sched';
|
|
seedPlugin(pluginId, { enabled: 1 });
|
|
testDb
|
|
.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, ?, ?)')
|
|
.run(pluginId, 'once', Date.now() - 1000);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const supervisor = (rt as any).supervisor;
|
|
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
|
|
let delivered = 0;
|
|
const invokeSpy = vi.spyOn(supervisor, 'invoke').mockImplementation(async () => {
|
|
delivered += 1;
|
|
// Yield, so the second concurrent pass's own claim-read genuinely overlaps
|
|
// this one's window rather than running strictly after it.
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return {};
|
|
});
|
|
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await Promise.all([(rt as any).fireDueScheduled(), (rt as any).fireDueScheduled()]);
|
|
expect(delivered).toBe(1); // claimed (re-armed/deleted) by exactly one pass
|
|
const row = testDb
|
|
.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?')
|
|
.get(pluginId, 'once');
|
|
expect(row).toBeUndefined(); // deleted, not left dangling or double-inserted
|
|
} finally {
|
|
invokeSpy.mockRestore();
|
|
supervisor.running.delete(pluginId);
|
|
}
|
|
});
|
|
|
|
it('RACE-SCHED-002: two concurrent fireDueScheduled passes deliver a due RECURRING task exactly once (the re-arm claim guard, unproven by RACE-SCHED-001)', async () => {
|
|
// Plan 3j Task 7 fix (must-land 4b, task-7-review.md): RACE-SCHED-001 above only
|
|
// exercises the ONE-SHOT branch (`deleteById`'s claim guard) — the recurring branch
|
|
// (`rearm`'s claim guard) went untested, and both mutations dropping IT survived
|
|
// (M6/M6b). Same shape as RACE-SCHED-001, on a task with `every_ms` set instead.
|
|
const pluginId = 'race-sched-recur';
|
|
seedPlugin(pluginId, { enabled: 1 });
|
|
const everyMs = 60_000;
|
|
testDb
|
|
.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, every_ms) VALUES (?, ?, ?, ?)')
|
|
.run(pluginId, 'recurring', Date.now() - 1000, everyMs);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const supervisor = (rt as any).supervisor;
|
|
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
|
|
let delivered = 0;
|
|
const invokeSpy = vi.spyOn(supervisor, 'invoke').mockImplementation(async () => {
|
|
delivered += 1;
|
|
// Yield, so the second concurrent pass's own claim-read genuinely overlaps
|
|
// this one's window rather than running strictly after it.
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return {};
|
|
});
|
|
const before = Date.now();
|
|
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await Promise.all([(rt as any).fireDueScheduled(), (rt as any).fireDueScheduled()]);
|
|
// Without the `due_at <= now` guard on `rearm`, BOTH concurrent passes would read
|
|
// `claimed = true` and both invoke — this is the assertion RACE-SCHED-001's own
|
|
// shape proves for the one-shot branch, now proven for the recurring one.
|
|
expect(delivered).toBe(1);
|
|
const row = testDb
|
|
.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?')
|
|
.get(pluginId, 'recurring') as { due_at: number } | undefined;
|
|
expect(row).toBeDefined(); // a recurring task is re-armed, never deleted
|
|
expect(row?.due_at).toBeGreaterThanOrEqual(before + everyMs); // moved forward, not left due in the past
|
|
} finally {
|
|
invokeSpy.mockRestore();
|
|
supervisor.running.delete(pluginId);
|
|
}
|
|
});
|
|
|
|
it("RACE-ERASURE-001: two concurrent drains coalesce onto the SAME in-flight pass (drainUserErasures' own promise-sharing) — one ACK, one drop", async () => {
|
|
const pluginId = 'race-erasure';
|
|
seedPlugin(pluginId, { enabled: 1 });
|
|
testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)').run(pluginId, 99);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const supervisor = (rt as any).supervisor;
|
|
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
|
|
let delivered = 0;
|
|
const deliverSpy = vi.spyOn(supervisor, 'deliverUserErasure').mockImplementation(async () => {
|
|
delivered += 1;
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return true;
|
|
});
|
|
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await Promise.all([(rt as any).drainUserErasures(), (rt as any).drainUserErasures()]);
|
|
expect(delivered).toBe(1); // drainUserErasures' own in-flight coalescing, unchanged by this task's conversion
|
|
const row = testDb
|
|
.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?')
|
|
.get(pluginId, 99);
|
|
expect(row).toBeUndefined();
|
|
} finally {
|
|
deliverSpy.mockRestore();
|
|
supervisor.running.delete(pluginId);
|
|
}
|
|
});
|
|
});
|