mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
49c3c57c31
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup timer only because it happened to be the domain that mints tokens. Issuing them and sweeping them are different jobs, and FilesService and TokenService mint or consume them too. EphemeralTokenService owns the lifecycle and hands the three operations out. The store and the interval stay module state next door, and that is load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on the upgrade handshake, outside the container, before any injector is in reach. Per-instance state would hand it an empty Map and close every connection with 4001, which no test would catch because the ws suites drive the transport directly. WS-027 through WS-029 pin the session gate that runs through exactly that path. The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken returns null at 10,000 live tokens and TokenService turns that into a 503. A second store would raise the real ceiling with nobody noticing. Its own leaf module rather than a provider in AuthModule: TokensModule mints download and ws tokens as well, AuthModule already imports TokensModule, and TokensModule is documented as a deliberate leaf that three graphs reach for. Same shape as MailerModule, which broke AuthModule <-> NotificationsModule. Twelve hand-wired construction sites in tests needed the new collaborator. The typecheck named all twelve; before this branch they would have been undefined at runtime. Sixteen more test files are cleared here too, each at its root cause. Four of them were passing for the wrong reason: files.controller.test.ts drove two handlers with shifted arguments, a string tripId where the trip row belongs and undefined where tripId and id belong. It stayed green because FilesService is fully stubbed and the stubs ignore what they are given, so DELETE /:id/permanent and DELETE /trash/empty never covered that the trip row reaches the permission check. conflictUpdate.test.ts was missing real collaborators on PlacesService and PackingService. None of the exercised paths reach them, so it passed by luck. assignments.service.test.ts builds its service by hand, and the one consumer of the collaborator it was missing swallows every throw in a bare catch. A broken journey reconciliation would still have shown 28 green tests. scheduler.test.ts mocks two modules with paths that resolve one directory above the server root, so they mock nothing at all. Harmless only because src/scheduler.ts imports neither.
181 lines
9.5 KiB
TypeScript
181 lines
9.5 KiB
TypeScript
/**
|
||
* Operator-supplied egress hosts.
|
||
*
|
||
* A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin that
|
||
* talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's hostname —
|
||
* so a community plugin would serve nobody. An ADMIN adds the hosts post-install and the
|
||
* runtime unions them into the child's allow-list at spawn.
|
||
*
|
||
* The invariants that keep this from becoming an egress bypass:
|
||
* - only a plugin that DECLARED operatorEgress may have hosts (install-time consent);
|
||
* - hosts are validated like manifest egress (no bare `*`, no scheme, no whole-TLD);
|
||
* - it is always the ADMIN, never an end user, who widens it;
|
||
* - changing the set RE-SPAWNS the plugin, because the child's guard is install-once.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||
});
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
import { db as dbConn } from '../../../src/db/database';
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||
|
||
import { createTables } from '../../../src/db/schema';
|
||
import { runMigrations } from '../../../src/db/migrations';
|
||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
|
||
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
|
||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||
|
||
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
|
||
testDb.prepare(
|
||
`INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config, operator_egress)
|
||
VALUES (?, ?, 'inactive', 0, '1.0.0', ?, ?, '{}', '{}', ?)`,
|
||
).run(id, id, JSON.stringify(perms), JSON.stringify(perms), operatorEgress ? 1 : 0);
|
||
}
|
||
|
||
let rt: PluginRuntimeService;
|
||
|
||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||
beforeEach(() => {
|
||
testDb.prepare('DELETE FROM plugins').run();
|
||
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
|
||
testDb.prepare('DELETE FROM plugin_actions').run();
|
||
rt = createPluginRuntime(new DatabaseService(dbConn));
|
||
});
|
||
|
||
describe('operator-supplied egress hosts', () => {
|
||
it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => {
|
||
install('gotify', true);
|
||
expect(rt.wantsOperatorEgress('gotify')).toBe(true);
|
||
expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']);
|
||
expect(rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
|
||
});
|
||
|
||
it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => {
|
||
install('sneaky', false);
|
||
expect(rt.wantsOperatorEgress('sneaky')).toBe(false);
|
||
// This is the load-bearing check: without it an admin could silently widen egress for
|
||
// ANY plugin, and the install-time consent would stop bounding what's possible.
|
||
await expect(rt.setOperatorEgressHosts('sneaky', ['evil.example.com'])).rejects.toThrow(/did not declare operatorEgress/);
|
||
expect(rt.operatorEgressHosts('sneaky')).toEqual([]);
|
||
});
|
||
|
||
it('OEG-003 — hosts are validated exactly like manifest egress', async () => {
|
||
install('gotify', true);
|
||
for (const bad of ['*', '*.com', 'https://gotify.example.com', 'has space', 'a/b']) {
|
||
await expect(rt.setOperatorEgressHosts('gotify', [bad])).rejects.toThrow(/invalid host/);
|
||
}
|
||
// A legitimate wildcard with a real multi-label suffix is fine.
|
||
expect(await rt.setOperatorEgressHosts('gotify', ['*.mydomain.com'])).toEqual(['*.mydomain.com']);
|
||
});
|
||
|
||
it('OEG-004 — hosts are normalized and de-duplicated', async () => {
|
||
install('gotify', true);
|
||
expect(await rt.setOperatorEgressHosts('gotify', ['Gotify.MyDomain.com', 'gotify.mydomain.com.', ' ', ''])).toEqual([
|
||
'gotify.mydomain.com',
|
||
]);
|
||
});
|
||
|
||
it('OEG-005 — setting the list replaces it (a removed host is really gone)', async () => {
|
||
install('gotify', true);
|
||
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
|
||
await rt.setOperatorEgressHosts('gotify', ['b.example.com']);
|
||
expect(rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
|
||
});
|
||
|
||
it('OEG-006 — the manifest rejects operatorEgress without an outbound permission', () => {
|
||
const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false };
|
||
expect(() => parseManifest({ ...base, permissions: ['db:own'], operatorEgress: true })).toThrow(/requires an http:outbound/);
|
||
expect(() => parseManifest({ ...base, permissions: [], operatorEgress: 'yes' })).toThrow(ManifestError);
|
||
// …and accepts the real thing.
|
||
const m = parseManifest({ ...base, permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true });
|
||
expect(m.operatorEgress).toBe(true);
|
||
});
|
||
|
||
it('OEG-009 — an operatorEgress plugin may ship an EMPTY egress[]; anyone else may not', () => {
|
||
const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false };
|
||
// A self-hosted target (Gotify, ntfy) has no host the author can name at publish time.
|
||
const m = parseManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true });
|
||
expect(m.egress).toEqual([]);
|
||
expect(m.operatorEgress).toBe(true);
|
||
// Without the flag an empty egress[] is still refused — this is what stops a plugin
|
||
// from asking for outbound while declaring no reach at all.
|
||
expect(() => parseManifest({ ...base, permissions: ['http:outbound'] })).toThrow(/egress\[\] is empty/);
|
||
});
|
||
|
||
it('OEG-010 — an operatorEgress plugin with no configured hosts still reaches nothing', async () => {
|
||
// It ACTIVATES (it may have useful offline features), but the child's allow-list is the
|
||
// union of its http:outbound:<host> grants and the admin's hosts — both empty here.
|
||
install('gotify', true, ['http:outbound']);
|
||
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
|
||
expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false);
|
||
});
|
||
|
||
it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => {
|
||
install('gotify', true);
|
||
await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']);
|
||
await rt.uninstall('gotify', false);
|
||
// A LATER plugin reusing this id must not silently inherit hosts approved for another.
|
||
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('settings-page actions (runtime)', () => {
|
||
function declareAction(id: string, key: string) {
|
||
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, NULL, 0, 0)')
|
||
.run(id, key, key);
|
||
}
|
||
|
||
it('ACT-001 — actionsOf returns the declared descriptors', () => {
|
||
install('p', false);
|
||
declareAction('p', 'testConnection');
|
||
expect(rt.actionsOf('p')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false }]);
|
||
});
|
||
|
||
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
|
||
install('p', false);
|
||
declareAction('p', 'testConnection');
|
||
// The key is caller-supplied (it comes off the URL), so the host must check it
|
||
// against the manifest rather than forwarding whatever it is handed to the child.
|
||
await expect(rt.invokeAction('p', 'somethingElse', 1)).rejects.toThrow(/did not declare action/);
|
||
await expect(rt.invokeAction('p', '__proto__', 1)).rejects.toThrow(/did not declare action/);
|
||
});
|
||
|
||
it('ACT-003 — a plugin with no actions can never be invoked', async () => {
|
||
install('p', false);
|
||
await expect(rt.invokeAction('p', 'testConnection', 1)).rejects.toThrow(/did not declare action/);
|
||
});
|
||
});
|
||
|
||
describe('the admin list surfaces operator egress (so the chip can be shown)', () => {
|
||
it('OEG-008 — reports operatorEgress + the host count', async () => {
|
||
const { PluginsService } = await import('../../../src/nest/plugins/plugins.service');
|
||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||
install('gotify', true);
|
||
install('plain', false);
|
||
|
||
// list() resolves required-addon dependencies through AddonsService, so it gets a real
|
||
// one over the same DB. These fixtures declare no dependencies, so it is never consulted.
|
||
const listPlugins = () => {
|
||
const dbs = new DatabaseService(dbConn);
|
||
return new PluginsService(dbs, new AddonsService(dbs)).list().plugins;
|
||
};
|
||
|
||
const before = listPlugins();
|
||
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
|
||
// A plugin that never asked for it must never invite the admin to add hosts.
|
||
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
|
||
|
||
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
|
||
const after = listPlugins();
|
||
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
|
||
delete process.env.TREK_PLUGINS_ENABLED;
|
||
});
|
||
});
|