camofox-browser/tests/unit/accessKey.test.js

552 lines
18 KiB
JavaScript

/**
* Tests for accessKeyMiddleware (global access-key gate) and
* requireAuth interaction with CAMOFOX_ACCESS_KEY (superkey behavior).
*
* Uses mock req/res objects -- no server spawn needed.
*/
import { describe, test, expect } from '@jest/globals';
import { jest } from '@jest/globals';
import { accessKeyMiddleware, requireAuth } from '../../lib/auth.js';
// --- Helpers ---
function mockReq({ method = 'GET', path = '/', headers = {}, remoteAddress = '10.0.0.1' } = {}) {
return {
method,
path,
headers,
socket: { remoteAddress },
};
}
function mockRes() {
const res = {
_status: null,
_json: null,
_headers: {},
status(code) { res._status = code; return res; },
json(body) { res._json = body; return res; },
set(key, val) { res._headers[key.toLowerCase()] = val; return res; },
};
return res;
}
const ACCESS_KEY = 'ak-test-secret-key-12345';
const API_KEY = 'api-test-secret-key-67890';
const ADMIN_KEY = 'admin-test-secret-key-99999';
const WRONG_KEY = 'wrong-key-nope';
// --------------------------------------------------------------------
// accessKeyMiddleware -- global gate
// --------------------------------------------------------------------
describe('accessKeyMiddleware', () => {
describe('when accessKey is NOT set (backward-compatible)', () => {
const mw = accessKeyMiddleware({ accessKey: '' });
test('passes through all requests without Authorization header', () => {
const req = mockReq({ path: '/tabs' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
expect(res._status).toBeNull();
});
test('passes through gated route without header', () => {
const req = mockReq({ method: 'POST', path: '/tabs' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('passes through /health', () => {
const req = mockReq({ path: '/health' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('when accessKey IS set', () => {
const mw = accessKeyMiddleware({ accessKey: ACCESS_KEY, apiKey: API_KEY, adminKey: ADMIN_KEY });
// --- Exemptions ---
test('exempts GET /health (Docker/Fly healthcheck)', () => {
const req = mockReq({ path: '/health' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
expect(res._status).toBeNull();
});
test('exempts POST /sessions/:userId/cookies when apiKey is set', () => {
const req = mockReq({ method: 'POST', path: '/sessions/user123/cookies' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('exempts POST /stop when adminKey is set', () => {
const req = mockReq({ method: 'POST', path: '/stop' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('does NOT exempt GET /sessions/:userId/cookies (only POST is exempt)', () => {
const req = mockReq({ method: 'GET', path: '/sessions/user123/cookies' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('does NOT exempt GET /stop (only POST is exempt)', () => {
const req = mockReq({ method: 'GET', path: '/stop' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
// --- Rejection cases ---
test('rejects request with no Authorization header -> 401', () => {
const req = mockReq({ method: 'POST', path: '/tabs' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
expect(res._json).toEqual({ error: 'Unauthorized' });
});
test('includes WWW-Authenticate header on 401', () => {
const req = mockReq({ method: 'POST', path: '/tabs' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(res._headers['www-authenticate']).toBe('Bearer realm="camofox"');
});
test('rejects request with wrong bearer token -> 401', () => {
const req = mockReq({
method: 'POST',
path: '/tabs',
headers: { authorization: `Bearer ${WRONG_KEY}` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('rejects request with empty bearer token -> 401', () => {
const req = mockReq({
path: '/tabs',
headers: { authorization: 'Bearer ' },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('rejects Basic auth scheme -> 401', () => {
const req = mockReq({
path: '/tabs',
headers: { authorization: `Basic ${ACCESS_KEY}` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
// --- Happy path ---
test('allows request with valid bearer token', () => {
const req = mockReq({
method: 'POST',
path: '/tabs',
headers: { authorization: `Bearer ${ACCESS_KEY}` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
expect(res._status).toBeNull();
});
test('allows GET request with valid bearer token', () => {
const req = mockReq({
path: '/tabs/abc123/snapshot',
headers: { authorization: `Bearer ${ACCESS_KEY}` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('allows DELETE request with valid bearer token', () => {
const req = mockReq({
method: 'DELETE',
path: '/tabs/abc123',
headers: { authorization: `Bearer ${ACCESS_KEY}` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('bearer prefix is case-insensitive', () => {
const req = mockReq({
path: '/tabs',
headers: { authorization: `bearer ${ACCESS_KEY}` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('trims trailing whitespace from bearer token', () => {
const req = mockReq({
path: '/tabs',
headers: { authorization: `Bearer ${ACCESS_KEY} ` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
// --- Route coverage ---
test('gates /tabs/:tabId/evaluate (arbitrary JS execution)', () => {
const req = mockReq({ method: 'POST', path: '/tabs/t1/evaluate' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('gates /metrics', () => {
const req = mockReq({ path: '/metrics' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('gates /openapi.json', () => {
const req = mockReq({ path: '/openapi.json' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('gates / (root)', () => {
const req = mockReq({ path: '/' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('gates DELETE /sessions/:userId', () => {
const req = mockReq({ method: 'DELETE', path: '/sessions/user1' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('gates legacy POST /navigate', () => {
const req = mockReq({ method: 'POST', path: '/navigate' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
});
// --- Defense-in-depth: conditional exemptions ---
describe('defense-in-depth: exemptions only when dedicated key is set', () => {
test('does NOT exempt POST /stop when adminKey is NOT set', () => {
const mw = accessKeyMiddleware({ accessKey: ACCESS_KEY, adminKey: '', apiKey: API_KEY });
const req = mockReq({ method: 'POST', path: '/stop' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('does NOT exempt POST /sessions/:userId/cookies when apiKey is NOT set', () => {
const mw = accessKeyMiddleware({ accessKey: ACCESS_KEY, apiKey: '', adminKey: ADMIN_KEY });
const req = mockReq({ method: 'POST', path: '/sessions/u1/cookies' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
test('POST /stop with access key passes when adminKey is NOT set', () => {
const mw = accessKeyMiddleware({ accessKey: ACCESS_KEY, adminKey: '', apiKey: API_KEY });
const req = mockReq({
method: 'POST',
path: '/stop',
headers: { authorization: `Bearer ${ACCESS_KEY}` },
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});
// --------------------------------------------------------------------
// requireAuth -- per-route middleware with access-key superkey behavior
// --------------------------------------------------------------------
describe('requireAuth with accessKey (superkey)', () => {
describe('both apiKey and accessKey set (double-auth scenario)', () => {
const config = { apiKey: API_KEY, accessKey: ACCESS_KEY, nodeEnv: 'production' };
test('accepts API key bearer token', () => {
const mw = requireAuth(config);
const req = mockReq({ headers: { authorization: `Bearer ${API_KEY}` } });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('accepts access key bearer token (superkey)', () => {
const mw = requireAuth(config);
const req = mockReq({ headers: { authorization: `Bearer ${ACCESS_KEY}` } });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('rejects wrong token', () => {
const mw = requireAuth(config);
const req = mockReq({ headers: { authorization: `Bearer ${WRONG_KEY}` } });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('rejects missing Authorization header', () => {
const mw = requireAuth(config);
const req = mockReq({});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('trims trailing whitespace from bearer token', () => {
const mw = requireAuth(config);
const req = mockReq({ headers: { authorization: `Bearer ${API_KEY} ` } });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('only accessKey set (no apiKey)', () => {
const config = { apiKey: '', accessKey: ACCESS_KEY, nodeEnv: 'production' };
test('accepts access key bearer token', () => {
const mw = requireAuth(config);
const req = mockReq({ headers: { authorization: `Bearer ${ACCESS_KEY}` } });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('rejects wrong token -- does NOT fall through to loopback', () => {
const mw = requireAuth(config);
const req = mockReq({
headers: { authorization: `Bearer ${WRONG_KEY}` },
remoteAddress: '127.0.0.1',
});
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('rejects loopback without token -- accessKey gates even loopback', () => {
const devConfig = { apiKey: '', accessKey: ACCESS_KEY, nodeEnv: 'development' };
const mw = requireAuth(devConfig);
const req = mockReq({ remoteAddress: '127.0.0.1' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
});
describe('only apiKey set (no accessKey -- original behavior)', () => {
const config = { apiKey: API_KEY, accessKey: '', nodeEnv: 'production' };
test('accepts API key', () => {
const mw = requireAuth(config);
const req = mockReq({ headers: { authorization: `Bearer ${API_KEY}` } });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('rejects wrong key', () => {
const mw = requireAuth(config);
const req = mockReq({ headers: { authorization: `Bearer ${WRONG_KEY}` } });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
});
describe('neither key set (original loopback behavior)', () => {
const config = { apiKey: '', accessKey: '', nodeEnv: 'development' };
test('allows loopback', () => {
const mw = requireAuth(config);
const req = mockReq({ remoteAddress: '127.0.0.1' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
});
test('rejects non-loopback', () => {
const mw = requireAuth(config);
const req = mockReq({ remoteAddress: '192.168.1.1' });
const res = mockRes();
const next = jest.fn();
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
});
});
// --------------------------------------------------------------------
// End-to-end double-auth simulation
// (accessKeyMiddleware -> requireAuth on the same request)
// --------------------------------------------------------------------
describe('double-auth chain (access-key middleware -> requireAuth)', () => {
const config = { apiKey: API_KEY, accessKey: ACCESS_KEY, adminKey: ADMIN_KEY, nodeEnv: 'production' };
const globalMw = accessKeyMiddleware(config);
const routeMw = requireAuth(config);
function runChain(req) {
const res = mockRes();
let chainResult = null;
const globalNext = jest.fn(() => {
// Simulate Express calling the next middleware (route-level auth)
routeMw(req, res, jest.fn(() => { chainResult = 'allowed'; }));
});
globalMw(req, res, globalNext);
return { res, chainResult, globalNextCalled: globalNext.mock.calls.length > 0 };
}
test('access key passes both middlewares (superkey on trace-like routes)', () => {
const req = mockReq({
path: '/sessions/u1/traces',
headers: { authorization: `Bearer ${ACCESS_KEY}` },
});
const { chainResult } = runChain(req);
expect(chainResult).toBe('allowed');
});
test('API key does NOT pass global middleware (different from access key)', () => {
const req = mockReq({
path: '/sessions/u1/traces',
headers: { authorization: `Bearer ${API_KEY}` },
});
const { res, globalNextCalled } = runChain(req);
// Global middleware rejects because API_KEY ≠ ACCESS_KEY
expect(globalNextCalled).toBe(false);
expect(res._status).toBe(401);
});
test('wrong key fails at global middleware', () => {
const req = mockReq({
path: '/sessions/u1/traces',
headers: { authorization: `Bearer ${WRONG_KEY}` },
});
const { res, globalNextCalled } = runChain(req);
expect(globalNextCalled).toBe(false);
expect(res._status).toBe(401);
});
test('access key on non-trace route passes global then reaches handler', () => {
const req = mockReq({
method: 'POST',
path: '/tabs',
headers: { authorization: `Bearer ${ACCESS_KEY}` },
});
const res = mockRes();
const next = jest.fn();
globalMw(req, res, next);
expect(next).toHaveBeenCalled();
});
});
// --------------------------------------------------------------------
// Named middleware functions (debuggability)
// --------------------------------------------------------------------
describe('middleware function naming', () => {
test('accessKeyMiddleware returns named function', () => {
const mw = accessKeyMiddleware({ accessKey: '' });
expect(mw.name).toBe('accessKeyCheck');
});
test('requireAuth returns named function', () => {
const mw = requireAuth({ apiKey: '', nodeEnv: 'development' });
expect(mw.name).toBe('requireAuthCheck');
});
});