import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import { config } from '../src/config.js'; import { configureBindHost, getAccountList, removeAccount, _resetLockoutForTests } from '../src/auth.js'; import { startServer } from '../src/server.js'; import { handleDashboardApi } from '../src/dashboard/api.js'; import { getEffectiveProxy, removeProxy } from '../src/dashboard/proxy-config.js'; import { setRuntimeApiKey, setRuntimeDashboardPassword } from '../src/runtime-config.js'; const originalAllowPrivate = config.allowPrivateProxyHosts; const originalDashboardPassword = config.dashboardPassword; const originalApiKey = config.apiKey; const originalHost = config.host; const originalPort = config.port; const originalAllowNoAuth = process.env.DASHBOARD_ALLOW_NO_AUTH; const createdAccountIds = new Set(); let runningServer = null; function fakeRes() { return { statusCode: 0, body: '', writeHead(status) { this.statusCode = status; }, end(chunk) { this.body += chunk ? String(chunk) : ''; }, json() { return this.body ? JSON.parse(this.body) : null; }, }; } function snapshotAccountIds() { return getAccountList().map(a => a.id); } beforeEach(() => { // These cases exercise proxy ordering/validation via the dashboard on // localhost with no secret configured. Post-AUTH-1 that is fail-closed by // default, so opt into the open-local convenience to preserve the original // (proxy-focused) intent of each case. process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; }); afterEach(async () => { if (originalAllowNoAuth === undefined) { delete process.env.DASHBOARD_ALLOW_NO_AUTH; } else { process.env.DASHBOARD_ALLOW_NO_AUTH = originalAllowNoAuth; } if (runningServer) { await new Promise(resolve => runningServer.close(resolve)); runningServer = null; } _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.allowPrivateProxyHosts = originalAllowPrivate; config.dashboardPassword = originalDashboardPassword; config.apiKey = originalApiKey; config.host = originalHost; config.port = originalPort; configureBindHost('127.0.0.1'); for (const a of getAccountList()) { if (typeof a.email === 'string' && a.email.startsWith('test-proxy-ordering-')) { removeAccount(a.id); } } for (const id of createdAccountIds) { removeProxy('account', id); createdAccountIds.delete(id); } }); function waitListening(server) { return new Promise(resolve => { if (server.address()) return resolve(); server.once('listening', resolve); }); } async function postJson(port, path, body, headers = {}) { return new Promise((resolve, reject) => { const payload = JSON.stringify(body); const req = http.request({ host: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers, }, }, res => { let raw = ''; res.on('data', chunk => { raw += chunk; }); res.on('end', () => { resolve({ statusCode: res.statusCode, body: raw ? JSON.parse(raw) : null, }); }); }); req.on('error', reject); req.end(payload); }); } describe('POST /accounts proxy ordering (regression for PR #90 follow-up)', () => { it('does NOT create account when proxy format is invalid', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; configureBindHost('127.0.0.1'); const before = snapshotAccountIds(); const res = fakeRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: `test-proxy-ordering-bad-${Date.now()}`, label: `test-proxy-ordering-bad-${Date.now()}`, proxy: 'not-a-valid-proxy-url' }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const after = snapshotAccountIds(); assert.equal(res.statusCode, 400); assert.equal(res.json().error, 'ERR_PROXY_FORMAT_INVALID'); assert.deepEqual(after, before, 'no account should be created when proxy format is invalid'); }); it('does NOT create account when proxy host is private and ALLOW_PRIVATE_PROXY_HOSTS is off', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; config.allowPrivateProxyHosts = false; configureBindHost('127.0.0.1'); const before = snapshotAccountIds(); const res = fakeRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: `test-proxy-ordering-priv-${Date.now()}`, label: `test-proxy-ordering-priv-${Date.now()}`, proxy: 'http://192.168.1.100:8080' }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const after = snapshotAccountIds(); assert.equal(res.statusCode, 400, `expected 400, got ${res.statusCode}: ${res.body}`); assert.ok(/PRIVATE|private|local/i.test(res.json().error || ''), `expected private-host error, got ${res.json().error}`); assert.deepEqual(after, before, 'no account should be created when private proxy is rejected'); }); it('does NOT create account from batch-import when item proxy host is private', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; config.allowPrivateProxyHosts = false; configureBindHost('127.0.0.1'); const before = snapshotAccountIds(); const label = `test-proxy-ordering-batch-${Date.now()}`; const text = JSON.stringify([{ api_key: `key-${label}`, label, proxy: 'http://127.0.0.1:8080', }]); const res = fakeRes(); await handleDashboardApi( 'POST', '/batch-import', { text, autoAdd: true }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const after = snapshotAccountIds(); const body = res.json(); assert.equal(res.statusCode, 200, `expected 200 batch result, got ${res.statusCode}: ${res.body}`); assert.equal(body.successCount, 0); assert.equal(body.failCount, 1); assert.equal(body.results[0].success, false); assert.match(body.results[0].error, /ERR_PROXY_PRIVATE_(HOST|IP)|private|local/i); assert.deepEqual(after, before, 'no account should be created when batch-import proxy is rejected'); }); it('does NOT create account from /auth/login when proxy host is private', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = 'admin-pw'; // H1: /auth/login now needs operator auth config.apiKey = 'test-admin-key'; config.allowPrivateProxyHosts = false; config.host = '127.0.0.1'; config.port = 0; const before = snapshotAccountIds(); runningServer = startServer(); await waitListening(runningServer); const port = runningServer.address().port; const label = `test-proxy-ordering-auth-${Date.now()}`; try { const res = await postJson( port, '/auth/login', { api_key: `key-${label}`, label, proxy: 'http://127.0.0.1:8080', }, { authorization: 'Bearer test-admin-key', 'x-dashboard-password': 'admin-pw' } ); const after = snapshotAccountIds(); assert.equal(res.statusCode, 400, `expected 400, got ${res.statusCode}: ${JSON.stringify(res.body)}`); assert.match(res.body.error, /ERR_PROXY_PRIVATE_(HOST|IP)|private|local/i); assert.deepEqual(after, before, 'no account should be created when /auth/login proxy is rejected'); } finally { config.port = originalPort; } }); it('creates account with valid public proxy and binds account-level proxy', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; configureBindHost('127.0.0.1'); const before = snapshotAccountIds(); const label = `test-proxy-ordering-public-${Date.now()}`; const key = `test-key-${Date.now()}`; const proxy = 'http://1.1.1.1:8080'; const res = fakeRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: key, label, proxy }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const after = snapshotAccountIds(); const body = res.json(); assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); assert.equal(body.success, true); assert.equal(after.length, before.length + 1, 'should create exactly one account'); const accountId = body.account.id; createdAccountIds.add(accountId); const proxyCfg = getEffectiveProxy(accountId); assert.deepEqual(proxyCfg, { type: 'http', host: '1.1.1.1', port: 8080, username: '', password: '', }); }); it('prefers api_key when api_key + token are both provided', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; configureBindHost('127.0.0.1'); const label = `test-proxy-ordering-both-${Date.now()}`; const key = `test-key-both-${Date.now()}`; const res = fakeRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: key, token: 'definitely-not-a-valid-token', label, proxy: 'http://1.1.1.1:8080', }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const body = res.json(); assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); assert.equal(body.success, true); assert.equal(body.account.method, 'api_key', 'api_key should win when both fields are present'); createdAccountIds.add(body.account.id); }); it('returns non-ERR_* proxy validation errors when host validation throws generic errors', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; configureBindHost('127.0.0.1'); const before = snapshotAccountIds(); const label = `test-proxy-ordering-non-err-${Date.now()}`; const key = `test-key-non-err-${Date.now()}`; const res = fakeRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: key, label, proxy: 'http://does-not-exist.invalid:8080' }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const after = snapshotAccountIds(); const body = res.json(); assert.equal(res.statusCode, 400); assert.ok(body.error && !/^ERR_/.test(body.error), `expected non-ERR_* error, got ${body.error}`); assert.deepEqual(after, before); }); it('rejects request with no api_key/token before doing any work', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; configureBindHost('127.0.0.1'); const before = snapshotAccountIds(); const res = fakeRes(); await handleDashboardApi( 'POST', '/accounts', { proxy: 'http://example.com:8080', label: 'no-key' }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const after = snapshotAccountIds(); assert.equal(res.statusCode, 400); assert.match(res.json().error, /Provide api_key or token/); assert.deepEqual(after, before); }); it('creates account with no proxy when none provided', async () => { _resetLockoutForTests(); setRuntimeApiKey(''); setRuntimeDashboardPassword(''); config.dashboardPassword = ''; config.apiKey = ''; configureBindHost('127.0.0.1'); const before = snapshotAccountIds().length; const label = `test-proxy-ordering-noproxy-${Date.now()}`; const res = fakeRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: `key-${label}`, label }, { headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res ); const after = snapshotAccountIds().length; assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); assert.equal(res.json().success, true); assert.equal(after, before + 1, 'exactly one account should be created'); }); });