/** * DC-103 / DC-064: discover-adopt regression suite * * DC-064 fixes the Caddy admin URL hardcode (`http://localhost:2019` → resolved * from the injected caddy context's `adminUrl`) and stops the route from * reaching raw `fetch` — it must use the injected `fetchT` (which carries * Origin + httpAgent plumbing via src/utils/http.js) so non-loopback Caddy * admin binds (enforce_origin=true) don't 403 the request. * * This suite pins all four invariants: * 1. Route module signature accepts `fetchT` (won't throw if ctx doesn't pass it). * 2. The mounted route uses `fetchT` when provided (proves by mock counts). * 3. The Caddy admin URL is resolved from `caddy.adminUrl`, NOT hardcoded. * 4. Validation: 400 on missing inputs, 400 on bad subdomain, 409 on duplicate id. */ const express = require('express'); const request = require('supertest'); function createApp({ servicesStateManager, caddy, fetchT, adminUrl } = {}) { const app = express(); app.use(express.json()); const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); const discoverAdoptRoutes = require('../../routes/discover-adopt'); app.use('/api/v1', discoverAdoptRoutes({ docker: null, servicesStateManager: servicesStateManager || null, caddy: caddy === undefined ? { adminUrl: adminUrl || 'http://localhost:2019' } : caddy, dns: null, siteConfig: { tld: '.sami' }, fetchT, asyncHandler, })); return app; } // Helper state manager so the route always has somewhere to write function makeStateManager(initial = []) { let services = Array.isArray(initial) ? [...initial] : []; return { _services: services, // eslint-disable-next-line require-await read: jest.fn().mockImplementation(async () => services), // eslint-disable-next-line require-await update: jest.fn().mockImplementation(async (mutator) => { const next = mutator(services); services = next; return services; }), }; } describe('DC-064: discover-adopt Caddy admin API safety', () => { describe('DI: fetchT is forwarded to the Caddy admin call', () => { it('uses injected fetchT (not raw fetch) when generating Caddy route', async () => { const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }); const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 }); try { const sm = makeStateManager(); const app = createApp({ servicesStateManager: sm, caddy: { adminUrl: 'http://caddy-admin.local:2019' }, fetchT: fetchTMock, }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080, protocol: 'http', generateDns: false, generateRoute: true, }); expect(res.status).toBe(201); expect(fetchTMock).toHaveBeenCalledTimes(1); expect(fetchTMock.mock.calls[0][0]).toBe('http://caddy-admin.local:2019/config/apps/http/servers/srv0/routes'); expect(fetchTMock.mock.calls[0][1]).toMatchObject({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/json' }), }); // Raw fetch must NOT have been called expect(rawFetchSpy).not.toHaveBeenCalled(); } finally { rawFetchSpy.mockRestore(); } }); it('does NOT hardcode http://localhost:2019 when caddy.adminUrl is provided', async () => { const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }); const sm = makeStateManager(); const app = createApp({ servicesStateManager: sm, caddy: { adminUrl: 'http://caddy-admin.production:2019' }, fetchT: fetchTMock, }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080, }); expect(res.status).toBe(201); const calledUrl = fetchTMock.mock.calls[0][0]; expect(calledUrl.startsWith('http://caddy-admin.production:2019')).toBe(true); expect(calledUrl.includes('localhost:2019')).toBe(false); }); it('falls back to raw fetch when fetchT is omitted (test-only path)', async () => { const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 }); try { const sm = makeStateManager(); const app = createApp({ servicesStateManager: sm, caddy: { adminUrl: 'http://localhost:2019' }, fetchT: null, // explicitly omitted }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080, }); expect(res.status).toBe(201); // Raw fetch used because fetchT is null expect(rawFetchSpy).toHaveBeenCalledTimes(1); } finally { rawFetchSpy.mockRestore(); } }); }); describe('source convention: static scan', () => { const fs = require('fs'); const path = require('path'); it('does not contain the hardcoded Caddy admin URL string', () => { const src = fs.readFileSync( path.join(__dirname, '../../routes/discover-adopt.js'), 'utf8' ); // The exact hardcode from before must be gone const hardcodeMatches = (src.match(/const\s+caddyAdminUrl\s*=\s*['"]http:\/\/localhost:2019['"]/g) || []).length; expect(hardcodeMatches).toBe(0); }); it('does not call raw fetch() — must use httpClient (fetchT or passed fetch)', () => { const src = fs.readFileSync( path.join(__dirname, '../../routes/discover-adopt.js'), 'utf8' ); // Raw `fetch(` for the Caddy admin call would be a regression const rawFetchMatches = (src.match(/await\s+fetch\(/g) || []).length; expect(rawFetchMatches).toBe(0); }); it('declares fetchT in the destructure', () => { const src = fs.readFileSync( path.join(__dirname, '../../routes/discover-adopt.js'), 'utf8' ); expect(src).toMatch(/function\s*\(\s*\{[^}]*fetchT[^}]*\}\s*\)/); }); }); describe('validation unchanged', () => { it('returns 400 when containerId/serviceId/name are missing', async () => { const app = createApp({ servicesStateManager: makeStateManager() }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc', serviceId: '', name: '', }); expect(res.status).toBe(400); }); it('returns 400 on invalid port', async () => { const app = createApp({ servicesStateManager: makeStateManager() }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 99999, }); expect(res.status).toBe(400); }); it('returns 400 on invalid subdomain (must be lowercase, alphanumeric, hyphens)', async () => { const app = createApp({ servicesStateManager: makeStateManager() }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc', serviceId: 'Bad_SubDomain!', name: 'My App', port: 80, }); expect(res.status).toBe(400); }); it('returns 409 on duplicate service id', async () => { const sm = makeStateManager([{ id: 'myapp', name: 'Existing' }]); const app = createApp({ servicesStateManager: sm, caddy: { adminUrl: 'http://localhost:2019' }, fetchT: () => Promise.resolve({ ok: true, status: 200 }), }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc', serviceId: 'myapp', name: 'Dup', port: 80, }); expect(res.status).toBe(409); }); }); describe('Caddy route failure does not corrupt the service entry', () => { it('still returns 200/201 result for service when generateRoute=false', async () => { const sm = makeStateManager(); const app = createApp({ servicesStateManager: sm, caddy: { adminUrl: 'http://localhost:2019' }, fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200 }), }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80, generateRoute: false, generateDns: false, }); expect(res.status).toBe(201); expect(res.body.service).toBeTruthy(); expect(res.body.service.id).toBe('myapp'); expect(sm.update).toHaveBeenCalled(); }); it('captures Caddy API failure in caddyRoute field without rolling back the service', async () => { const sm = makeStateManager(); const app = createApp({ servicesStateManager: sm, caddy: { adminUrl: 'http://localhost:2019' }, fetchT: jest.fn().mockResolvedValue({ ok: false, status: 403 }), }); const res = await request(app).post('/api/v1/discover/adopt').send({ containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80, generateDns: false, generateRoute: true, }); // Service was still written even though route generation failed expect(res.status).toBe(201); expect(res.body.service).toBeTruthy(); expect(res.body.caddyRoute.status).toBe('failed'); expect(res.body.caddyRoute.error).toMatch(/403/); }); }); });