'use strict'; // Directory discovery API — security + contract regression coverage. // // These tests run under the jest + docker harness (redis + the test seed). // They lock in the two fixes from the @simpleworkjs/directory-schema release: // 1. /api/discovery/* returns the { results } envelope (not a bare array — // the drift that made jump-host's `data.results || []` collapse to []). // 2. No response path leaks secret metadata (e.g. an OAuth client's // client_secret_hash), regardless of caller. // // The core assertions hold for any authenticated caller. The admin-projection // assertion (fullMetadata for directory admins) additionally requires the `test` // seed user to be a member of app_sso_directory_admin — see setup.js. const { login, request, app } = require('./setup'); let token; beforeAll(async () => { token = await login(); }); function assertNoSecrets(results, path) { for (const r of results || []) { // toBeUndefined() in this jest version takes no message arg, so assert // manually and throw with context — this also surfaces the leaked value // if the projection ever regresses. const secretHash = r.metadata && r.metadata.client_secret_hash; if (secretHash !== undefined) { throw new Error( `client_secret_hash leaked from ${path} on ${r.slug || r.id} (value: ${JSON.stringify(secretHash)})` ); } if (r.metadata) { for (const k of Object.keys(r.metadata)) { if (/secret|password|privatekey/i.test(k)) { throw new Error(`secret-ish key "${k}" leaked from ${path} on ${r.slug || r.id}`); } } } } } describe('Discovery — envelope + security', () => { test('GET /api/discovery/resources returns 200 with { results } (not a bare array)', async () => { const res = await request(app).get('/api/discovery/resources').set('auth-token', token); expect(res.status).toBe(200); expect(Array.isArray(res.body.results)).toBe(true); expect(Array.isArray(res.body)).toBe(false); // never a bare array }); test('GET /api/discovery/resources never leaks client_secret_hash', async () => { const res = await request(app).get('/api/discovery/resources').set('auth-token', token); assertNoSecrets(res.body.results, '/resources'); }); test('GET /api/discovery/resources?group= returns 200 (regression: was 404)', async () => { const res = await request(app) .get('/api/discovery/resources?group=host_web01_access') .set('auth-token', token); expect(res.status).toBe(200); expect(Array.isArray(res.body.results)).toBe(true); }); test('GET /api/discovery/graph returns { results: { resources, edges } } and strips secrets', async () => { const res = await request(app).get('/api/discovery/graph').set('auth-token', token); expect(res.status).toBe(200); expect(res.body.results).toBeDefined(); expect(Array.isArray(res.body.results.resources)).toBe(true); assertNoSecrets(res.body.results.resources, '/graph'); }); test('GET /api/discovery/me returns 200 with { results } and strips secrets', async () => { const res = await request(app).get('/api/discovery/me').set('auth-token', token); expect(res.status).toBe(200); expect(Array.isArray(res.body.results)).toBe(true); assertNoSecrets(res.body.results, '/me'); }); test('GET /api/discovery/resources/:slug returns 200 + { results } for a known slug', async () => { // Seed-dependent: pick the first slug from the list, then fetch it. const list = await request(app).get('/api/discovery/resources').set('auth-token', token); const slug = list.body.results[0] && list.body.results[0].slug; if (!slug) return; // empty seed — skip rather than fail const res = await request(app) .get(`/api/discovery/resources/${encodeURIComponent(slug)}`) .set('auth-token', token); expect(res.status).toBe(200); expect(res.body.results).toBeDefined(); expect(res.body.results.slug).toBe(slug); assertNoSecrets([res.body.results], '/resources/:slug'); }); }); describe('Discovery — admin projection (requires test user in app_sso_directory_admin)', () => { // If the seed `test` user is a directory admin, /resources should keep // admin-only (non-secret) metadata like redirect_uris/token_lifetime for // them. If not, this assertion is skipped — the no-secrets assertion above // already covers the security guarantee for every caller. test('admin callers keep token_lifetime / redirect_uris (non-secret admin keys)', async () => { const res = await request(app).get('/api/discovery/resources?kind=oauth').set('auth-token', token); const oauth = (res.body.results || []).find(r => r.kind === 'oauth'); if (!oauth) return; // no oauth resource seeded // Only meaningful if the caller is an admin; non-admins correctly get // the public allowlist (no redirect_uris). We assert the absence of // secrets regardless, and skip the positive admin check without a known // admin seed. expect(oauth.metadata && oauth.metadata.client_secret_hash).toBeUndefined(); }); });