import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import request from 'supertest'; // The control router is a pure proxy: it forwards /api/control/* to // ${IVCTL_URL} injecting X-Admin-Token. We mock global fetch (the upstream // ivctl call) and assert: owner-gate enforced, path forwarded, token injected, // query + JSON body passed through, and 503 when IVCTL_URL is unset. let createApp, app; const owner = r => r.set('Authorization', 'Bearer test-token'); beforeAll(async () => { process.env.OWNER_TOKEN = 'test-token'; process.env.IVCTL_URL = 'http://ivctl.test:8080'; process.env.IVCTL_ADMIN_TOKEN = 'admin-secret'; ({ createApp } = await import('../../server.js')); app = createApp(); }); let fetchSpy; function mockUpstream(status = 200, json = { ok: true }) { fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(json), { status, headers: { 'content-type': 'application/json' } })); } afterEach(() => { fetchSpy?.mockRestore(); fetchSpy = undefined; }); describe('/api/control proxy', () => { beforeEach(() => { process.env.IVCTL_URL = 'http://ivctl.test:8080'; }); it('requires the owner token (401 without bearer)', async () => { mockUpstream(); const res = await request(app).get('/api/control/admin/applicants'); expect(res.status).toBe(401); expect(fetchSpy).not.toHaveBeenCalled(); }); it('rejects agent tokens / non-owner (403) — owner-only', async () => { mockUpstream(); // A syntactically valid bearer that is NOT the owner token → agentOrOwner // tries agent verify and fails → 401 before reaching requireOwner. Either // way it must NOT reach the upstream. const res = await request(app).get('/api/control/admin/applicants').set('Authorization', 'Bearer not-the-owner'); expect([401, 403]).toContain(res.status); expect(fetchSpy).not.toHaveBeenCalled(); }); it('forwards GET path + query and injects X-Admin-Token', async () => { mockUpstream(200, [{ id: 1, status: 'pending' }]); const res = await owner(request(app).get('/api/control/admin/applicants?status=pending')); expect(res.status).toBe(200); expect(res.body).toEqual([{ id: 1, status: 'pending' }]); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toBe('http://ivctl.test:8080/admin/applicants?status=pending'); expect(opts.method).toBe('GET'); expect(opts.headers['X-Admin-Token']).toBe('admin-secret'); // owner bearer must NOT be forwarded upstream expect(opts.headers.authorization).toBeUndefined(); }); it('forwards a JSON body on POST (approve → claim_code)', async () => { mockUpstream(200, { claim_code: 'ABC123' }); const res = await owner(request(app).post('/api/control/admin/applicants/7/approve')).send({ group_id: 3 }); expect(res.status).toBe(200); expect(res.body.claim_code).toBe('ABC123'); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toBe('http://ivctl.test:8080/admin/applicants/7/approve'); expect(opts.method).toBe('POST'); expect(JSON.parse(opts.body)).toEqual({ group_id: 3 }); expect(opts.headers['X-Admin-Token']).toBe('admin-secret'); }); it('passes PATCH bodies through (license update)', async () => { mockUpstream(200, { id: 5, status: 'suspended' }); const res = await owner(request(app).patch('/api/control/admin/licenses/5')).send({ status: 'suspended' }); expect(res.status).toBe(200); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toBe('http://ivctl.test:8080/admin/licenses/5'); expect(opts.method).toBe('PATCH'); expect(JSON.parse(opts.body)).toEqual({ status: 'suspended' }); }); it('streams the upstream status code back (e.g. 404)', async () => { mockUpstream(404, { error: 'not_found' }); const res = await owner(request(app).get('/api/control/admin/tickets/999')); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'not_found' }); }); it('returns 503 ivctl_not_configured when IVCTL_URL is unset', async () => { delete process.env.IVCTL_URL; mockUpstream(); const res = await owner(request(app).get('/api/control/admin/applicants')); expect(res.status).toBe(503); expect(res.body.error).toBe('ivctl_not_configured'); expect(fetchSpy).not.toHaveBeenCalled(); }); });