mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
6680bc7c74
PluginsModule was 21 controllers, 24 domain imports and 10 providers in one class, which made it the hub most of the Nest graph ran through: AdminModule imported it for a single cascade-disable call and inherited all of it. It is now PluginsRuntimeModule (supervisor, capability router, hook contracts), PluginContributionsModule (the 14 read-only hook controllers, moved to contributions/), PluginOAuthModule (the credential leaf, moved to oauth/) and a composition root that keeps the install and delivery controllers. AdminModule imports the runtime alone. The three plugin files that reached for the db singleton alongside the injected DatabaseService no longer do. plugin-host-state and signature-status take the connection from the caller, and the three per-user settings functions that were bare exports 'so the RPC host wiring can call them directly' are now PluginUserSettingsService - the host wiring is providers, so it injects them. Coverage moves from one repo-wide 80% to a per-domain ratchet. The single number let a well-covered domain subsidise a thin one: booking-import sits at 50% and integrations at 20%, and the aggregate still cleared the bar, so either could have lost another ten points unnoticed. Each domain now carries its own measured floor, regenerated by scripts/coverage-thresholds.mjs. Two test-side findings on the way: expect(undefined).toContain(SomeClass) PASSES in this vitest version - it only throws for a string needle. Every '<X> is listed in its module providers' guard was written that way, so all twelve of them silently stopped guarding the moment their class moved. They go through expectRegisteredProvider now, which asserts the array shape first. The atlas region test paid for the one-off decompression of the multi-MB admin1 bundle inside its own 5s budget, which is why it failed under load and passed alone. The bundle is warmed in beforeAll instead.
163 lines
9.9 KiB
TypeScript
163 lines
9.9 KiB
TypeScript
/**
|
|
* Host-brokered outbound OAuth (#plugins). Proves the security-critical broker logic
|
|
* without a browser: PKCE challenge derivation, single-use + user-bound + TTL state,
|
|
* https/SSRF guard on the endpoints, the code + refresh token exchanges (mocked fetch),
|
|
* tokens encrypted at rest, and a stored refresh token that the plugin never sees.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
|
encrypt_api_key: (v: unknown) => (typeof v === 'string' ? `enc:${v}` : v),
|
|
decrypt_api_key: (v: unknown) => (typeof v === 'string' && v.startsWith('enc:') ? v.slice(4) : v),
|
|
}));
|
|
vi.mock('../../../src/app-config', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/app-config')>();
|
|
return { ...actual, getAppUrl: () => 'https://trek.example' };
|
|
});
|
|
|
|
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
|
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
|
import { db as dbConn } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
|
|
// The token POST now runs through the SSRF guard (ssrfGuard.safeFetchLlm), which
|
|
// resolves the host before fetching. Stub DNS so the fake provider.example host
|
|
// resolves — to a public IP by default, or to a per-test address for the guard.
|
|
const { dnsState } = vi.hoisted(() => ({ dnsState: { address: '93.184.216.34', family: 4 } }));
|
|
vi.mock('node:dns/promises', () => {
|
|
const lookup = async () => ({ address: dnsState.address, family: dnsState.family });
|
|
return { default: { lookup }, lookup };
|
|
});
|
|
|
|
import Database from 'better-sqlite3';
|
|
import { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
|
|
|
|
const CFG = {
|
|
oauth_authorize_url: 'https://provider.example/authorize',
|
|
oauth_token_url: 'https://provider.example/token',
|
|
oauth_scopes: 'read write',
|
|
oauth_client_id: 'enc:client-123', // stored encrypted
|
|
oauth_client_secret: 'enc:secret-abc',
|
|
};
|
|
|
|
function freshDb(cfg: Record<string, unknown> = CFG) {
|
|
const d = new Database(':memory:');
|
|
d.exec(`
|
|
CREATE TABLE plugins (id TEXT PRIMARY KEY, config TEXT, status TEXT);
|
|
CREATE TABLE plugin_oauth_tokens (plugin_id TEXT, user_id INTEGER, access_token TEXT, refresh_token TEXT, expires_at INTEGER, scope TEXT, updated_at TEXT, PRIMARY KEY (plugin_id, user_id));
|
|
CREATE TABLE plugin_oauth_state (state TEXT PRIMARY KEY, plugin_id TEXT, user_id INTEGER, verifier TEXT, created_at INTEGER);
|
|
`);
|
|
d.prepare("INSERT INTO plugins (id, config, status) VALUES ('p', ?, 'active')").run(JSON.stringify(cfg));
|
|
return d;
|
|
}
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
|
|
describe('PluginOAuthService', () => {
|
|
let svc: PluginOAuthService;
|
|
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
|
|
|
|
it('providerConfig returns null unless every piece is present, decrypting the secrets', () => {
|
|
expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
|
|
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
|
|
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
|
|
});
|
|
|
|
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
|
|
const url = new URL(svc.startConnect('p', 42, NOW));
|
|
expect(url.origin + url.pathname).toBe('https://provider.example/authorize');
|
|
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
|
expect(url.searchParams.get('client_id')).toBe('client-123');
|
|
expect(url.searchParams.get('redirect_uri')).toBe('https://trek.example/api/plugin-oauth/p/callback');
|
|
expect(url.searchParams.get('code_challenge')).toBeTruthy();
|
|
const state = url.searchParams.get('state')!;
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
const stored = rows.prepare('SELECT verifier, user_id FROM plugin_oauth_state WHERE state = ?').get(state) as { verifier: string; user_id: number };
|
|
expect(stored.user_id).toBe(42);
|
|
// a second connect replaces the first (one live state per user)
|
|
svc.startConnect('p', 42, NOW);
|
|
expect((rows.prepare('SELECT COUNT(*) c FROM plugin_oauth_state WHERE user_id = 42').get() as { c: number }).c).toBe(1);
|
|
});
|
|
|
|
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
|
|
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/);
|
|
// IPv6-literal loopback must not slip past the fast-fail
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/);
|
|
// cloud-metadata by literal is refused too
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
|
|
// an internal name suffix is refused
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
|
|
});
|
|
|
|
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
|
|
const url = new URL(svc.startConnect('p', 42, NOW));
|
|
const state = url.searchParams.get('state')!;
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600, scope: 'read' }),
|
|
} as Response);
|
|
|
|
await svc.completeCallback('p', 42, 'the-code', state, NOW + 1000);
|
|
// the token exchange used PKCE (code_verifier) + client creds
|
|
const body = (fetchMock.mock.calls[0][1] as { body: string }).body;
|
|
expect(body).toContain('grant_type=authorization_code');
|
|
expect(body).toContain('code_verifier=');
|
|
expect(body).toContain('client_secret=secret-abc');
|
|
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
const tok = rows.prepare('SELECT access_token, refresh_token FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = 42').get('p') as { access_token: string; refresh_token: string };
|
|
expect(tok.access_token).toBe('enc:AT'); // encrypted at rest
|
|
expect(tok.refresh_token).toBe('enc:RT');
|
|
expect(svc.status('p', 42)).toMatchObject({ configured: true, connected: true });
|
|
// state is single-use — replaying it fails
|
|
await expect(svc.completeCallback('p', 42, 'the-code', state, NOW + 2000)).rejects.toThrow(/state/);
|
|
});
|
|
|
|
it('rejects a foreign or expired state', async () => {
|
|
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
|
|
await expect(svc.completeCallback('p', 99, 'code', state, NOW + 1000)).rejects.toThrow(/state/); // wrong user
|
|
const state2 = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
|
|
await expect(svc.completeCallback('p', 42, 'code', state2, NOW + 20 * 60 * 1000)).rejects.toThrow(/state/); // > 10 min
|
|
});
|
|
|
|
it('getAccessToken returns the token, refreshes an expiring one, and hands the plugin only the access token', async () => {
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
// a live token → returned decrypted, no network
|
|
rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token, expires_at) VALUES (?,?,?,?,?)').run('p', 42, 'enc:LIVE', 'enc:RT', NOW + 3600_000);
|
|
expect(await svc.getAccessToken('p', 42, NOW)).toBe('LIVE');
|
|
|
|
// an expired token → refreshed via the refresh_token grant
|
|
rows.prepare('UPDATE plugin_oauth_tokens SET access_token = ?, expires_at = ? WHERE user_id = 42').run('enc:OLD', NOW - 1000);
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as Response);
|
|
expect(await svc.getAccessToken('p', 42, NOW)).toBe('NEW');
|
|
expect((fetchMock.mock.calls[0][1] as { body: string }).body).toContain('grant_type=refresh_token');
|
|
// the provider omitted a new refresh_token → the old one is kept
|
|
const tok = rows.prepare('SELECT refresh_token FROM plugin_oauth_tokens WHERE user_id = 42').get() as { refresh_token: string };
|
|
expect(tok.refresh_token).toBe('enc:RT');
|
|
|
|
// a user who never connected → null
|
|
expect(await svc.getAccessToken('p', 7, NOW)).toBeNull();
|
|
});
|
|
|
|
it('routes the token exchange through the SSRF guard — a token_url resolving to cloud metadata is refused', async () => {
|
|
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'AT' }) } as Response);
|
|
// The provider's token_url now resolves to the cloud-metadata address.
|
|
dnsState.address = '169.254.169.254';
|
|
await expect(svc.completeCallback('p', 42, 'the-code', state, NOW + 1000)).rejects.toThrow();
|
|
expect(fetchMock).not.toHaveBeenCalled(); // blocked before any request left the host
|
|
});
|
|
|
|
it('disconnect drops the user\'s tokens', async () => {
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token) VALUES (?,?,?)').run('p', 42, 'enc:X');
|
|
svc.disconnect('p', 42);
|
|
expect(svc.status('p', 42).connected).toBe(false);
|
|
});
|
|
});
|