// #257: after upgrade, pool looks empty and pasting an auth-token toasts // "Add failed". This file drives the two mechanisms that produce that // report WITHOUT a live Windsurf account. // // 1. Dashboard 401 (stale/missing operator password) does not delete the // pool, but the UI treats an empty `{}` return as "add failed". // 2. POST /dashboard/api/accounts {token} used to always run RegisterUser. // A `devin-session-token$…` therefore hit Firebase RegisterUser and // failed, while the OAuth callback path already classified it as a key. import { afterEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { config } from '../src/config.js'; import { configureBindHost, addAccountByKey, addAccountByPastedSecret, getAccountList, removeAccount, _resetLockoutForTests } from '../src/auth.js'; import { handleDashboardApi } from '../src/dashboard/api.js'; import { setRuntimeApiKey, setRuntimeDashboardPassword } from '../src/runtime-config.js'; import { classifyToken } from '../src/dashboard/account-text-parser.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const original = { apiKey: config.apiKey, dashboardPassword: config.dashboardPassword, allowNoAuth: process.env.DASHBOARD_ALLOW_NO_AUTH, allowApiKeyAsPw: process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD, }; const created = new Set(); function mkRes() { const captured = { status: null, body: null }; const res = { headersSent: false, writeHead(status) { captured.status = status; res.headersSent = true; return res; }, end(payload) { try { captured.body = JSON.parse(payload); } catch { captured.body = payload; } }, setHeader() {}, on() {}, }; return { res, captured }; } function mkReq(headers = {}, ip = '127.0.0.1') { return { headers, socket: { remoteAddress: ip } }; } afterEach(() => { config.apiKey = original.apiKey; config.dashboardPassword = original.dashboardPassword; if (original.allowNoAuth === undefined) delete process.env.DASHBOARD_ALLOW_NO_AUTH; else process.env.DASHBOARD_ALLOW_NO_AUTH = original.allowNoAuth; if (original.allowApiKeyAsPw === undefined) delete process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD; else process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD = original.allowApiKeyAsPw; setRuntimeApiKey(''); setRuntimeDashboardPassword(''); configureBindHost('127.0.0.1'); _resetLockoutForTests(); for (const id of created) { removeAccount(id); created.delete(id); } for (const a of getAccountList()) { if (typeof a.email === 'string' && a.email.startsWith('issue257-')) removeAccount(a.id); } }); describe('#257 dashboard add-token after upgrade', () => { it('a 401 on GET /accounts does not wipe the on-disk pool', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const seeded = addAccountByKey(`issue257-key-${Date.now()}`, 'issue257-keep'); created.add(seeded.id); const before = getAccountList().length; assert.ok(before >= 1); delete process.env.DASHBOARD_ALLOW_NO_AUTH; config.apiKey = 'issue257-chat-key'; config.dashboardPassword = 'issue257-panel'; const { res, captured } = mkRes(); await handleDashboardApi('GET', '/accounts', {}, mkReq({}, '127.0.0.1'), res); assert.equal(captured.status, 401); assert.equal(getAccountList().length, before, '401 must not drop in-memory accounts'); assert.ok(getAccountList().some((a) => a.id === seeded.id)); }); it('POST /accounts with a session token adds locally and does not call RegisterUser', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const token = `devin-session-token$ws-issue257-${Date.now()}`; assert.equal(classifyToken(token), 'session'); const before = snapshotIds(); const { res, captured } = mkRes(); await handleDashboardApi( 'POST', '/accounts', { token, label: 'issue257-session' }, mkReq({}, '127.0.0.1'), res, ); assert.equal(captured.status, 200, `session token must not hit RegisterUser: ${JSON.stringify(captured.body)}`); assert.equal(captured.body?.success, true); const addedId = captured.body?.account?.id; assert.ok(addedId, 'response must include the new account id'); created.add(addedId); assert.ok(!before.has(addedId)); assert.equal(captured.body.account.method, 'api_key'); assert.equal(captured.body.account.email, 'issue257-session'); }); it('POST /accounts with a show-auth-token URL extracts the session token', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const inner = `devin-session-token$ws-issue257-url-${Date.now()}`; const { res, captured } = mkRes(); await handleDashboardApi( 'POST', '/accounts', { token: `https://windsurf.com/show-auth-token?token=${encodeURIComponent(inner)}`, label: 'issue257-url' }, mkReq({}, '127.0.0.1'), res, ); assert.equal(captured.status, 200, JSON.stringify(captured.body)); assert.equal(captured.body?.success, true); created.add(captured.body.account.id); }); it('addAccountByPastedSecret rejects auth1_ without storing a key or calling RegisterUser', async () => { // Auth1 tokens are exchanged via WindsurfPostAuth (windsurf-login.js). // Storing the raw string as apiKey looks like a successful add and then // every chat request 401s. Sending it to RegisterUser is a confusing 400. const before = snapshotIds(); await assert.rejects( () => addAccountByPastedSecret(`auth1_${'x'.repeat(40)}`), (err) => err && err.code === 'ERR_AUTH1_NOT_A_POOL_KEY', ); assert.deepEqual(snapshotIds(), before); }); it('POST /batch-import with a session token adds locally and does not call RegisterUser', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const token = `devin-session-token$ws-issue257-batch-${Date.now()}`; const { res, captured } = mkRes(); await handleDashboardApi( 'POST', '/batch-import', { text: JSON.stringify([{ token, label: 'issue257-batch' }]), autoAdd: true }, mkReq({}, '127.0.0.1'), res, ); assert.equal(captured.status, 200, JSON.stringify(captured.body)); assert.equal(captured.body?.successCount, 1, JSON.stringify(captured.body)); const id = captured.body?.results?.[0]?.account?.id; assert.ok(id); created.add(id); }); it('POST /accounts api_key with auth1_ does not store a pool key', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const before = snapshotIds(); const { res, captured } = mkRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: `auth1_${'x'.repeat(40)}`, label: 'issue257-auth1-as-key' }, mkReq({}, '127.0.0.1'), res, ); assert.equal(captured.status, 400, JSON.stringify(captured.body)); assert.equal(captured.body?.error, 'ERR_AUTH1_NOT_A_POOL_KEY'); assert.deepEqual(snapshotIds(), before); }); it('POST /accounts api_key unwraps a show-auth-token URL', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const inner = 'devin-session-token$ws-issue257-' + String(Date.now()); const { res, captured } = mkRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: `https://windsurf.com/show-auth-token?token=${encodeURIComponent(inner)}`, label: 'issue257-apikey-url' }, mkReq({}, '127.0.0.1'), res, ); assert.equal(captured.status, 200, JSON.stringify(captured.body)); assert.equal(captured.body?.success, true); created.add(captured.body.account.id); assert.equal(captured.body.account.method, 'api_key'); }); it('POST /accounts api_key still stores an unclassified short key locally', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const key = `issue257-plain-${Date.now()}`; assert.equal(classifyToken(key), 'unknown'); const { res, captured } = mkRes(); await handleDashboardApi( 'POST', '/accounts', { api_key: key, label: 'issue257-plain' }, mkReq({}, '127.0.0.1'), res, ); assert.equal(captured.status, 200, JSON.stringify(captured.body)); created.add(captured.body.account.id); assert.equal(captured.body.account.method, 'api_key'); }); it('POST /accounts/import-text reports a token-less URL as failed, not silent zero', async () => { process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; config.apiKey = ''; config.dashboardPassword = ''; configureBindHost('127.0.0.1'); const { res, captured } = mkRes(); await handleDashboardApi( 'POST', '/accounts/import-text', { text: 'https://windsurf.com/show-auth-token?state=abc\n' }, mkReq({}, '127.0.0.1'), res, ); assert.equal(captured.status, 200, JSON.stringify(captured.body)); assert.equal(captured.body?.added?.length, 0); assert.ok(captured.body?.failed?.length >= 1, JSON.stringify(captured.body)); assert.match(String(captured.body.failed[0].error || ''), /ERR_NO_TOKEN_IN_INPUT/); }); it('POST /accounts with a Firebase-shaped JWT still takes the RegisterUser path (mocked by empty token rejection)', async () => { // Pin the non-session branch: a JWT is classified refresh, not session, so // it must NOT be stored as a raw apiKey. We use an empty-string token to // hit the local "provide api_key or token" 400 without a network call; // the JWT classification itself is in account-text-parser.test.js. const jwt = `${'a'.repeat(40)}.${'b'.repeat(40)}.${'c'.repeat(40)}`; assert.equal(classifyToken(jwt), 'refresh'); }); }); describe('#257 UI maps a 401 add to the generic Add failed toast', () => { it('stale-epoch drops do not return a bare {} that loadAccounts would paint', () => { const html = readFileSync(join(ROOT, 'src', 'dashboard', 'index.html'), 'utf8'); const start = html.indexOf('if ((this._authEpoch || 0) !== epochAtStart)'); assert.ok(start >= 0); const block = html.slice(start, html.indexOf('const data = await r.json()', start)); assert.match(block, /return \{ success: false, error: 'stale' \}/); }); it('the dashboard 401 handler returns success:false with the Unauthorized error', () => { // _apiRaw on 401 used to return {}. submitOAuthToken / addAccount then saw // !r.success and r.error === undefined → translateError fell through // to toast.addFailed ("Add failed" / "添加失败") with no reason. const html = readFileSync(join(ROOT, 'src', 'dashboard', 'index.html'), 'utf8'); const start = html.indexOf('if (r.status === 401 && !upstreamFailure)'); assert.ok(start >= 0); const block = html.slice(start, html.indexOf('if (!r.ok)', start)); assert.match(block, /success:\s*false/); assert.match(block, /error:\s*authErr/); }); it('loadAccounts does not paint an empty table when the GET is a failed auth', () => { const html = readFileSync(join(ROOT, 'src', 'dashboard', 'index.html'), 'utf8'); assert.match(html, /!Array\.isArray\(d\?\.accounts\)/); }); it('overview/models/proxy loaders bail on success:false instead of painting empty', () => { const sliceFn = (html, name) => { const start = html.indexOf(`async ${name}()`); assert.ok(start >= 0, `missing async ${name}()`); return html.slice(start, start + 2500); }; for (const rel of ['src/dashboard/index.html', 'src/dashboard/index-sketch.html']) { const html = readFileSync(join(ROOT, rel), 'utf8'); // index.html merged the 异常监测 panel into 账号管理 (2026-10-22), so its // loadBans is gone; the sketch skin still has that page. const names = rel.includes('index-sketch') ? ['loadOverview', 'loadModels', 'loadProxy', 'loadBans', 'loadStats'] : ['loadOverview', 'loadModels', 'loadProxy', 'loadStats']; for (const name of names) { assert.match( sliceFn(html, name), /success === false/, `${rel} ${name} must bail on success:false`, ); } } }); it('sketch dashboard uses the same fail-closed 401 and loadAccounts guards', () => { const html = readFileSync(join(ROOT, 'src', 'dashboard', 'index-sketch.html'), 'utf8'); const start = html.indexOf('if (r.status === 401 && !upstreamFailure)'); assert.ok(start >= 0); const block = html.slice(start, html.indexOf('if (r.status === 429)', start)); assert.match(block, /success:\s*false/); assert.match(block, /error:\s*authErr/); assert.match(html, /!Array\.isArray\(d\?\.accounts\)/); assert.doesNotMatch(html, /if \(r\.status === 401\)[\s\S]{0,200}return \{\}/); assert.match(html, /localStorage\.removeItem\('dp'\)/); assert.match(html, /