test/auth-admin-gate.test.js
Rama master · réplica de lectura del repositorio original.
// H1 (Grok audit): account-pool management (/auth/login, /auth/accounts,
// DELETE /auth/accounts/:id) must require OPERATOR auth, not just a chat API
// key. A shared chat key used to grant full pool control to any client. These
// tests drive the real server over loopback and assert:
// - bare chat key (Bearer) → 403 admin_required on login/list/delete
// - correct dashboard password (x-dashboard-password) → passes the admin gate
// - /auth/status stays chat-key-visible (counts only, no email list)
import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { startServer } from '../src/server.js';
import { config } from '../src/config.js';
import { _resetLockoutForTests, configureBindHost } from '../src/auth.js';
import { setRuntimeApiKey, setRuntimeDashboardPassword } from '../src/runtime-config.js';
const origApiKey = config.apiKey;
const origPw = config.dashboardPassword;
const origHost = config.host;
const origPort = config.port;
let server = null;
function waitListening(s) {
return new Promise(r => { if (s.address()) return r(); s.once('listening', r); });
}
function req(port, method, path, headers = {}, body = null) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
const r = http.request({ host: '127.0.0.1', port, path, method,
headers: { ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}), ...headers } },
res => { let raw = ''; res.on('data', c => raw += c); res.on('end', () => { let b; try { b = JSON.parse(raw); } catch { b = raw; } resolve({ status: res.statusCode, body: b }); }); });
r.on('error', reject); if (payload) r.write(payload); r.end();
});
}
beforeEach(() => {
_resetLockoutForTests();
setRuntimeApiKey(''); setRuntimeDashboardPassword('');
config.apiKey = 'sk-chat-shared';
config.dashboardPassword = 'operator-pw';
config.host = '127.0.0.1';
configureBindHost('127.0.0.1');
config.port = 0;
});
afterEach(async () => {
if (server) { await new Promise(r => server.close(r)); server = null; }
_resetLockoutForTests();
config.apiKey = origApiKey; config.dashboardPassword = origPw;
config.host = origHost; config.port = origPort;
});
describe('H1: account-pool management requires operator auth', () => {
it('bare chat key → 403 on login / accounts / delete; status still 200', async () => {
server = startServer(); await waitListening(server);
const port = server.address().port;
const chat = { authorization: 'Bearer sk-chat-shared' };
const login = await req(port, 'POST', '/auth/login', chat, { api_key: 'x' });
assert.equal(login.status, 403, 'login with chat key must be 403');
assert.equal(login.body?.error?.code, 'admin_required');
const list = await req(port, 'GET', '/auth/accounts', chat);
assert.equal(list.status, 403, 'accounts list with chat key must be 403');
const del = await req(port, 'DELETE', '/auth/accounts/whatever', chat);
assert.equal(del.status, 403, 'delete with chat key must be 403');
// status stays visible to a chat key (counts only, no email list).
const status = await req(port, 'GET', '/auth/status', chat);
assert.equal(status.status, 200);
assert.equal(status.body?.accounts, undefined, 'status must NOT include the account/email list');
});
it('wrong/no key → 401 before even reaching the admin gate', async () => {
server = startServer(); await waitListening(server);
const port = server.address().port;
const list = await req(port, 'GET', '/auth/accounts', { authorization: 'Bearer wrong' });
assert.equal(list.status, 401, 'wrong chat key is 401 at the outer gate');
});
it('operator dashboard password passes the admin gate (list → 200)', async () => {
server = startServer(); await waitListening(server);
const port = server.address().port;
const list = await req(port, 'GET', '/auth/accounts',
{ authorization: 'Bearer sk-chat-shared', 'x-dashboard-password': 'operator-pw' });
assert.equal(list.status, 200, 'chat key + operator password lists accounts');
assert.ok(Array.isArray(list.body?.accounts), 'returns the account list');
});
});
describe('H-2 (audit 2026-07-13): /auth/* admin gate has brute-force lockout', () => {
it('repeated wrong operator passwords get locked out (429) like /dashboard/api/*', async () => {
server = startServer(); await waitListening(server);
const port = server.address().port;
// 5 wrong operator-password guesses (chat key valid, dashboard pw wrong) → 403 each,
// feeding the lockout; the 6th should be 429 locked_out.
let sawLock = false;
for (let i = 0; i < 7; i++) {
const r = await req(port, 'GET', '/auth/accounts',
{ authorization: 'Bearer sk-chat-shared', 'x-dashboard-password': 'wrong-guess-' + i });
if (r.status === 429 && r.body?.error?.code === 'locked_out') { sawLock = true; break; }
assert.equal(r.status, 403, `attempt ${i} expected 403 before lock, got ${r.status}`);
}
assert.ok(sawLock, 'brute-force lockout must trigger 429 after repeated wrong operator passwords');
});
});