import { Request, Response, NextFunction } from 'express'; import { requireAdminGate } from '../../src/middleware/adminGate'; // The gate is the whole point of #63, so both directions are tested: it must // let the right request through, and it must refuse the wrong one. A gate that // only ever refuses is as broken as one that only ever allows — it would take // the admin panel down rather than protect it. interface Harness { req: Request; res: Response; next: NextFunction; status: jest.Mock; json: jest.Mock; } function harness(header?: string): Harness { const status = jest.fn().mockReturnThis(); const json = jest.fn().mockReturnThis(); const req = { method: 'POST', originalUrl: '/api/admin/items', get: (name: string) => (name.toLowerCase() === 'x-admin-gate' ? header : undefined) } as unknown as Request; const res = { status, json } as unknown as Response; return { req, res, next: jest.fn() as unknown as NextFunction, status, json }; } describe('requireAdminGate', () => { const original = process.env.ADMIN_GATE_SECRET; let warn: jest.SpyInstance; beforeEach(() => { warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); }); afterEach(() => { warn.mockRestore(); // Restored rather than deleted unconditionally, so this file cannot change // the behaviour of anything that runs after it in the same process. if (original === undefined) { delete process.env.ADMIN_GATE_SECRET; } else { process.env.ADMIN_GATE_SECRET = original; } }); describe('when no secret is configured', () => { // Today's behaviour, and local dev and every admin test depend on it. it('lets the request through', () => { delete process.env.ADMIN_GATE_SECRET; const h = harness(); requireAdminGate(h.req, h.res, h.next); expect(h.next).toHaveBeenCalledTimes(1); expect(h.status).not.toHaveBeenCalled(); }); // An empty value cannot mean "enforce", because then any caller sending an // empty header would pass. It has to mean the same as unset. it('treats an empty secret as unconfigured rather than as a secret', () => { process.env.ADMIN_GATE_SECRET = ''; const h = harness(''); requireAdminGate(h.req, h.res, h.next); expect(h.next).toHaveBeenCalledTimes(1); expect(h.status).not.toHaveBeenCalled(); }); }); describe('when a secret is configured', () => { const SECRET = 'a-real-gate-secret'; beforeEach(() => { process.env.ADMIN_GATE_SECRET = SECRET; }); it('lets a request carrying the right header through', () => { const h = harness(SECRET); requireAdminGate(h.req, h.res, h.next); expect(h.next).toHaveBeenCalledTimes(1); expect(h.status).not.toHaveBeenCalled(); }); it('refuses a request with no header at all', () => { const h = harness(); requireAdminGate(h.req, h.res, h.next); expect(h.next).not.toHaveBeenCalled(); expect(h.status).toHaveBeenCalledWith(403); expect(h.json).toHaveBeenCalledWith({ error: 'forbidden' }); }); it('refuses a request with the wrong header', () => { const h = harness('not-the-secret-but-same-length'); requireAdminGate(h.req, h.res, h.next); expect(h.next).not.toHaveBeenCalled(); expect(h.status).toHaveBeenCalledWith(403); }); // The trap in timingSafeEqual: it throws on buffers of unequal length, so // comparing raw values would turn a short header into a 500 instead of a // 403. Hashing both sides first is what makes the lengths always equal. it('refuses a header of a different length without throwing', () => { const h = harness('short'); expect(() => requireAdminGate(h.req, h.res, h.next)).not.toThrow(); expect(h.next).not.toHaveBeenCalled(); expect(h.status).toHaveBeenCalledWith(403); }); it('refuses a header that merely starts with the secret', () => { const h = harness(`${SECRET}-extra`); requireAdminGate(h.req, h.res, h.next); expect(h.status).toHaveBeenCalledWith(403); }); // A silent 403 is very hard to diagnose from the other side of a proxy. it('logs the refusal without echoing the value it was sent', () => { const h = harness('some-guessed-value'); requireAdminGate(h.req, h.res, h.next); expect(warn).toHaveBeenCalledTimes(1); const logged = warn.mock.calls[0]?.[0] as string; expect(logged).toContain('[admin-gate]'); expect(logged).toContain('/api/admin/items'); expect(logged).not.toContain('some-guessed-value'); }); }); });