feat(control): IV Control admin app — owner-gated /api/control proxy to ivctl + Control view (applicants/instances/releases/tickets/groups) + sidebar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-15 01:59:52 +10:00
parent f9d2fa3493
commit 173efc31e5
8 changed files with 645 additions and 1 deletions

99
tests/api/control.test.js Normal file
View File

@@ -0,0 +1,99 @@
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}<path> 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();
});
});