Files
auto-team-custom/server/roxy-integration.test.mjs
小夜 cd31096486 fix: improve Roxy cleanup and account operations
Update Roxy session cleanup, bounded proxy retries and Team authorization selection. Persist mailbox export usage and strengthen scoped member removal. Update isolated Roxy fixtures; all 808 tests pass.
2026-10-11 14:18:15 +08:00

2492 lines
165 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createDecipheriv, createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { openRoxyBrowserSession } from './roxy-browser.mjs';
const serverScript = fileURLToPath(new URL('./index.mjs', import.meta.url));
const roxyModule = new URL('./roxy-browser.mjs', import.meta.url).href;
const loginModule = new URL('./openai-login.mjs', import.meta.url).href;
const proxyChainModule = new URL('./proxy-chain.mjs', import.meta.url).href;
const puppeteerModule = import.meta.resolve('puppeteer-core');
const dataKey = 'isolated-roxy-state-encryption-key';
const secret = 'private-roxy-fixture-api-key';
const children = [
{ id: 'child_alpha', email: 'alpha@example.test', password: 'private-alpha-password' },
{ id: 'child_beta', email: 'beta@example.test', password: 'private-beta-password' },
];
function oauthToken(child, workspaceId = `personal-${child.id}`, plan = 'free') {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const claims = Buffer.from(JSON.stringify({ sub: child.id, exp: Math.floor(Date.now() / 1000) + 3600,
'https://api.openai.com/auth': { chatgpt_account_id: workspaceId, chatgpt_plan_type: plan },
'https://api.openai.com/profile': { email: child.email },
})).toString('base64url');
return { access_token: `${header}.${claims}.private-fixture-signature`,
refresh_token: `private-fixture-rt-${child.id}`, expires_in: 3600 };
}
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
return server.address().port;
}
async function unusedPort() {
const server = createServer();
const port = await listen(server);
await new Promise((resolve) => server.close(resolve));
return port;
}
async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = false, occupiedProfile = false, sentinelChallenge = false,
priorFields = {}, browserVerification = false, passwordFailure = null, proxyFlow = false, holdBrowser = false,
accountId = 'child_alpha', createFailure = null, chainFlow = false, concurrentAccounts = null, failBrowserFor = [], cleanupFailureFor = [], connectFailureFor = [], holdQuotaFor = [], holdOpenFor = [], timeoutBeforeSessionFor = [], profileCapacity = Infinity, remoteProfiles = null, browserFailureSequences = {},
deleteFailure = false, deleteStillListed = false, holdDeleteFor = [], tokenFields = {}, openFailureFor = [], unexpectedLoginErrorFor = [] } = {}) {
const workspaceId = 'target_team_workspace';
const allChildren = concurrentAccounts || children;
const account = allChildren.find((child) => child.id === accountId);
let protocolState = '';
const mothers = teamFlow ? [{ id: 'fixture_mother', email: 'owner@example.test', accountId: workspaceId,
accessToken: oauthToken({ id: 'owner', email: 'owner@example.test' }, workspaceId, 'team').access_token }] : [];
const requests = [];
const profiles = remoteProfiles ? structuredClone(remoteProfiles) : [{ dirId: 'roxy_profile_a', windowName: 'Fixture profile', windowSortNum: 1,
...(Number.isFinite(profileCapacity) ? { windowRemark: 'auto-team:child:child_alpha' } : {}) }];
let releaseBrowser;
const heldBrowsers = new Map();
const heldQuotas = new Map();
const heldOpens = new Map();
const heldDeletes = new Map();
const openedProfiles = new Set();
const deletedProfiles = new Set();
const removedMembers = new Set();
let browserPeak = 0;
let profilePeak = profiles.length;
let nextProfileId = profiles.length;
const upstream = createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
if (proxyFlow && ['/api/oauth/oauth2/token', '/oauth/token'].includes(url.pathname)) url.pathname = '/fixture/token';
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const tokenPayload = String(req.headers.authorization || '').split('.')[1];
const recorded = { path: url.pathname, query: Object.fromEntries(url.searchParams), method: req.method,
token: req.headers.token, proxyAuth: req.headers['proxy-authorization'], body: Buffer.concat(chunks).toString(),
subject: tokenPayload ? JSON.parse(Buffer.from(tokenPayload, 'base64url')).sub : undefined,
...(url.pathname === '/browser/connection_info' ? { openedProfiles: [...openedProfiles] } : {}) };
requests.push(recorded);
if (url.pathname === '/fixture/browser-held') {
await new Promise((resolve) => {
releaseBrowser = resolve;
heldBrowsers.set(url.searchParams.get('email') || account.email, resolve);
});
res.writeHead(200, { 'content-type': 'application/json' });
return res.end('{}');
}
if (url.pathname === '/backend-api/wham/usage' && holdQuotaFor.length) {
const payload = String(req.headers.authorization || '').split('.')[1];
const subject = payload ? JSON.parse(Buffer.from(payload, 'base64url')).sub : '';
if (holdQuotaFor.includes(subject)) await new Promise((resolve) => heldQuotas.set(subject, resolve));
}
if (url.pathname === '/browser/open') {
const dirId = JSON.parse(Buffer.concat(chunks).toString()).dirId;
if (openFailureFor.includes(dirId)) {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ code: -1, msg: 'fixture browser startup failed' }));
}
if (deletedProfiles.has(dirId)) {
res.writeHead(409, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ code: -1, msg: 'fixture environment was deleted' }));
}
openedProfiles.add(dirId);
browserPeak = Math.max(browserPeak, openedProfiles.size);
if (holdOpenFor.includes(dirId)) await new Promise((resolve) => heldOpens.set(dirId, resolve));
}
if (url.pathname === '/browser/close') {
const dirId = JSON.parse(Buffer.concat(chunks).toString()).dirId;
if (!cleanupFailureFor.includes(dirId)) openedProfiles.delete(dirId);
else {
res.writeHead(503, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ code: -1, msg: 'fixture close failed' }));
}
}
if (url.pathname === '/browser/delete') {
const { workspaceId: requestedWorkspace, dirIds, isSoftDelete } = JSON.parse(recorded.body);
recorded.openedProfiles = [...openedProfiles];
const stored = (await saved()).state;
recorded.persistedJson = stored.children.map((child) => ({ id: child.id,
free: Boolean(child.accessToken && child.refreshToken),
team: Boolean(child.workspaceTokens?.[workspaceId]?.accessToken && child.workspaceTokens?.[workspaceId]?.refreshToken) }));
if (Array.isArray(dirIds) && dirIds.some((dirId) => holdDeleteFor.includes(dirId))) {
await new Promise((resolve) => heldDeletes.set(dirIds[0], resolve));
}
res.writeHead(200, { 'content-type': 'application/json' });
if (requestedWorkspace !== 'roxy_workspace_a' || !Array.isArray(dirIds) || dirIds.length !== 1 || isSoftDelete !== true) {
return res.end(JSON.stringify({ code: -1, msg: 'unexpected fixture delete contract' }));
}
if (deleteFailure) return res.end(JSON.stringify({ code: -1, msg: 'fixture delete failed' }));
if (!deleteStillListed) {
for (const dirId of dirIds) {
const index = profiles.findIndex((profile) => profile.dirId === dirId);
if (index >= 0) profiles.splice(index, 1);
deletedProfiles.add(dirId);
}
}
return res.end(JSON.stringify({ code: 0, data: null }));
}
if (req.method === 'DELETE' && url.pathname.startsWith(`/backend-api/accounts/${workspaceId}/users/`)) {
removedMembers.add(decodeURIComponent(url.pathname.split('/').pop()));
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ success: true }));
}
if (url.pathname === '/browser/create') {
res.writeHead(200, { 'content-type': 'application/json' });
if (createFailure) return res.end(JSON.stringify(createFailure));
if (profiles.length >= profileCapacity) return res.end(JSON.stringify({ code: -1, msg: '窗口数量已达到上限' }));
const body = JSON.parse(Buffer.concat(chunks).toString());
const created = { dirId: `created_profile_${nextProfileId++}`, windowName: body.windowName, windowRemark: body.windowRemark };
profiles.push(created);
profilePeak = Math.max(profilePeak, profiles.length);
return res.end(JSON.stringify({ code: 0, data: { dirId: created.dirId } }));
}
if (browserFlow && url.pathname === '/oauth/authorize') {
protocolState = url.searchParams.get('state');
res.writeHead(302, { location: 'https://auth.openai.com/log-in' });
return res.end();
}
if (browserFlow && ['/log-in', '/workspace', '/fixture/sentinel', '/api/accounts/authorize/continue',
'/api/accounts/password/verify', '/sign-in-with-chatgpt/codex/consent', '/api/accounts/workspace/select'].includes(url.pathname)) {
if (url.pathname === '/api/accounts/password/verify' && passwordFailure) {
res.writeHead(passwordFailure.status, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: { code: passwordFailure.code || 'fixture_verification_required',
message: `private-upstream-error ${secret} ${account.password} private-fixture-rt-child_alpha` } }));
}
const payload = url.pathname === '/fixture/sentinel' ? { token: 'fixture-sentinel-token',
...(sentinelChallenge ? { turnstile: { dx: 'fixture-required-verification' } } : {}) }
: url.pathname === '/api/accounts/authorize/continue' ? { continue_url: 'https://auth.openai.com/log-in/password' }
: url.pathname === '/api/accounts/password/verify' ? { continue_url: 'https://auth.openai.com/workspace' }
: url.pathname === '/api/accounts/workspace/select' ? { continue_url: `http://localhost:1455/auth/callback?code=fixture-automatic-code&state=${protocolState}` }
: {};
const workspaceCookie = Buffer.from(JSON.stringify({ workspaces: [
{ id: workspaceId, kind: 'team' }, { id: `personal-${account.id}`, kind: 'personal' },
] })).toString('base64url');
res.writeHead(200, { 'content-type': 'application/json', ...(url.pathname === '/sign-in-with-chatgpt/codex/consent'
? { 'set-cookie': `oai-client-auth-session=${workspaceCookie}.signature; Path=/; Secure; HttpOnly` } : {}) });
return res.end(JSON.stringify(payload));
}
res.writeHead(apiFailure?.status || 200, { 'content-type': 'application/json' });
if (apiFailure) return res.end(JSON.stringify({ code: apiFailure.code ?? -1,
msg: `private-upstream-error ${secret} http://private-user:private-password@remote.example.test`, data: null }));
const recognized = ['/health', '/browser/workspace', '/browser/list_v3', '/browser/connection_info',
'/browser/open', '/browser/close', '/browser/mdf', '/fixture/browser-options', '/fixture/browser-launch', '/fixture/browser-connect', '/fixture/context-create', '/fixture/context-close', '/fixture/context-auth', '/fixture/token', '/fixture/chain-create', '/fixture/chain-close',
`/backend-api/accounts/${workspaceId}/users`, '/backend-api/wham/usage'].includes(url.pathname);
const data = url.pathname === '/health' ? { running: true }
: url.pathname === '/browser/workspace' ? { total: 1, rows: [{ id: 'roxy_workspace_a', workspaceName: 'Fixture team' }] }
: url.pathname === '/browser/list_v3' ? { total: profiles.length, rows: profiles }
: url.pathname === '/browser/connection_info' ? (occupiedProfile ? [{ dirId: 'roxy_profile_a' }] : [...openedProfiles].map((dirId) => ({ dirId })))
: url.pathname === '/browser/open' ? { ws: `ws://127.0.0.1:${serverPort}/devtools/browser/fixture` }
: url.pathname === '/fixture/token' ? { ...oauthToken(concurrentAccounts
? allChildren.find((child) => new URLSearchParams(Buffer.concat(chunks).toString()).get('code') === `fixture-automatic-code-${child.email}`) || account : account,
teamFlow ? workspaceId : undefined, teamFlow ? 'team' : 'free'), ...tokenFields }
: url.pathname === `/backend-api/accounts/${workspaceId}/users` ? { total: 1 + (concurrentAccounts || [account]).filter((child) => !removedMembers.has(child.id)).length, items: [
{ id: 'owner', email: 'owner@example.test', role: 'account-owner', seat_type: 'default' },
...(concurrentAccounts || [account]).filter((child) => !removedMembers.has(child.id)).map((child) => ({ id: child.id, email: child.email, role: 'standard-user', seat_type: 'default' })),
] }
: url.pathname === '/backend-api/wham/usage' ? { rate_limit: {
primary_window: { used_percent: 25, limit_window_seconds: 18000 },
secondary_window: { used_percent: 10, limit_window_seconds: 604800 },
} }
: null;
// OAuth and usage mocks return their normal domain shape; the Local API
// always retains its documented code/data envelope.
if (url.pathname === '/fixture/token' || url.pathname.startsWith('/backend-api/')) return res.end(JSON.stringify(data));
res.end(JSON.stringify({ code: recognized ? 0 : -1, msg: recognized ? 'ok' : 'unexpected fixture endpoint', data }));
});
const roxyPort = await listen(upstream);
const serverPort = roxyPort;
const dataDir = await mkdtemp(path.join(tmpdir(), 'roxy-integration-'));
const port = await unusedPort();
await writeFile(path.join(dataDir, 'state.json'), JSON.stringify({
version: 1, settings: { autoRefill: false, integrations: {
mailbox: { enabled: false, serviceType: 'manual', endpoint: 'http://mailbox.example.test', apiKey: 'private-mailbox-key' },
sub2apis: [{ id: 'fixture-sub2api', name: 'Fixture Sub2API', enabled: false,
baseUrl: 'http://sub2api.example.test', apiKey: 'private-sub2api-key', groupId: 7 }],
} }, children: allChildren.map((child) => child.id === account.id || concurrentAccounts
? { ...child, ...(teamFlow ? { team: workspaceId } : {}), ...priorFields }
: child), mothers, history: [],
}));
// Only the fixture API may receive network calls. A mistaken automatic
// login cannot launch Chromium or reach an official authentication service.
const preload = `
${browserFlow ? `
import { registerHooks } from 'node:module';
registerHooks({ resolve(specifier, context, nextResolve) {
if (${unexpectedLoginErrorFor.length > 0} && specifier === './openai-login.mjs') return { url: 'fixture:unexpected-login-error', shortCircuit: true };
if (specifier === './openai-browser-login.mjs') return { url: 'fixture:roxy-browser-flow', shortCircuit: true };
if (specifier === './roxy-browser.mjs') return { url: 'fixture:roxy-session', shortCircuit: true };
if (${chainFlow} && specifier === './proxy-chain.mjs') return { url: 'fixture:proxy-chain', shortCircuit: true };
return nextResolve(specifier, context);
}, load(url, context, nextLoad) {
if (url === 'fixture:unexpected-login-error') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(`
export * from ${JSON.stringify(loginModule)};
import { loginFreeAccount as actualLogin } from ${JSON.stringify(loginModule)};
export async function loginFreeAccount(options) {
const result = await actualLogin(options);
if (${JSON.stringify(unexpectedLoginErrorFor)}.includes(options.email)) throw new Error('private-unexpected-login-error');
return result;
}
`)} };
if (url === 'fixture:proxy-chain') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(`
export * from ${JSON.stringify(proxyChainModule)};
export async function createProxyChainRelay(options) {
await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/chain-create`)}, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(options),
});
return { url: ${JSON.stringify(`http://fixture-chain:private-relay@127.0.0.1:${roxyPort}`)},
close: async () => { await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/chain-close`)}, { method: 'POST' }); },
};
}
`)} };
if (url === 'fixture:roxy-session') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(`
export * from ${JSON.stringify(roxyModule)};
import { openRoxyBrowserSession as actualOpen } from ${JSON.stringify(roxyModule)};
let contextSequence = 0;
export function openRoxyBrowserSession(options) {
return actualOpen(options, { connect: async () => {
if (${JSON.stringify(connectFailureFor)}.includes(options.childId)) throw new Error('fixture connection failed');
return {
newPage: async () => ({ close: async () => {}, authenticate: async () => {} }), disconnect: async () => {},
createBrowserContext: async () => {
const context = { contextId: ++contextSequence, childId: options.childId, dirId: options.profile?.dirId };
await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/context-create`)}, { method: 'POST', body: JSON.stringify(context) });
return { newPage: async () => ({ close: async () => {}, authenticate: async ({ username }) => {
await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/context-auth`)}, { method: 'POST', body: JSON.stringify({ ...context, username }) });
} }), close: async () => {
await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/context-close`)}, { method: 'POST', body: JSON.stringify(context) });
} };
},
}; } });
}
`)} };
if (url === 'fixture:roxy-browser-flow') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(`
const browserAttempts = new Map();
export async function completeBrowserOAuth(options) {
if (typeof options.acquireSession !== 'function') throw Object.assign(new Error('fixture Chrome disabled'), { code: 'fixture_chrome_disabled', status: 409 });
await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/browser-options`)}, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
email: options.email, workspaceId: options.workspaceId, workspaceMode: options.workspaceMode,
stateMatches: new URL(options.authUrl).searchParams.get('state') === options.expectedState,
}),
});
options.onProgress('roxy_start', 'fixture');
const acquiring = options.acquireSession({ timeoutMs: 1000 });
if (${JSON.stringify(timeoutBeforeSessionFor)}.includes(options.email)) {
void acquiring.catch(() => {});
throw Object.assign(new Error('fixture timeout while opening'), { code: 'browser_oauth_timeout', status: 408, browserRequired: true });
}
const session = await acquiring;
let authorizationError;
try {
if (${holdBrowser}) await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/browser-held`)} + '?email=' + encodeURIComponent(options.email));
const attempt = browserAttempts.get(options.email) || 0;
browserAttempts.set(options.email, attempt + 1);
const injected = ${JSON.stringify(browserFailureSequences)}[options.email]?.[attempt];
if (injected) throw Object.assign(new Error('fixture browser authorization failure'), {
code: typeof injected === 'string' ? injected : injected.code,
status: typeof injected === 'string' ? 502 : injected.status,
browserRequired: true, needsInput: true,
});
if (${browserVerification} || ${JSON.stringify(failBrowserFor)}.includes(options.email)) throw Object.assign(new Error('fixture official verification required'), {
code: 'browser_verification_required', status: 403, browserRequired: true, needsInput: true,
});
const callback = new URL(options.redirectUri);
callback.searchParams.set('state', options.expectedState);
callback.searchParams.set('code', ${concurrentAccounts !== null} ? 'fixture-automatic-code-' + options.email : 'fixture-automatic-code');
return { callbackUrl: callback.toString() };
} catch (error) {
authorizationError = error;
throw error;
} finally {
try { await session.close({ keepOpen: session.keepOpenOnFailure === true && ${JSON.stringify(failBrowserFor)}.includes(options.email) }); }
catch (error) {
// Match the real browser failure path: an unsuccessful close
// must not replace the official login error already captured.
if (!authorizationError) throw error;
options.onProgress('roxy_cleanup_failed');
}
}
}
`)} };
return nextLoad(url, context);
} });
` : ''}
${proxyFlow ? `
import http from 'node:http';
import https from 'node:https';
import { syncBuiltinESMExports } from 'node:module';
// Exercise the actual proxy transport, but never tunnel TLS or connect to
// an official host: the loopback fixture is the only HTTP proxy endpoint.
https.request = (input, options, callback) => {
const url = new URL(input);
if (!['auth.openai.com', 'sentinel.openai.com'].includes(url.hostname) || !options?.agent) {
throw new Error('isolated_roxy_https_network_disabled');
}
return http.request('http://isolated-auth.invalid' + url.pathname + url.search, options, callback);
};
syncBuiltinESMExports();
` : ''}
import puppeteer from ${JSON.stringify(puppeteerModule)};
puppeteer.launch = async () => {
await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/browser-launch`)}, { method: 'POST' });
throw new Error('isolated_roxy_browser_disabled');
};
puppeteer.connect = async () => {
await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/browser-connect`)}, { method: 'POST' });
throw new Error('isolated_roxy_browser_disabled');
};
const nativeFetch = globalThis.fetch;
globalThis.fetch = (input, options) => {
const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url);
if (${browserFlow} && url.hostname === 'auth.openai.com') {
const pathname = ['/api/oauth/oauth2/token', '/oauth/token'].includes(url.pathname) ? '/fixture/token' : url.pathname;
return nativeFetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}`)} + pathname + url.search, options);
}
if (${browserFlow} && url.hostname === 'sentinel.openai.com') return nativeFetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/sentinel`)}, options);
if (url.hostname !== '127.0.0.1' || Number(url.port) !== ${roxyPort}) throw new Error('isolated_roxy_external_network_disabled');
return nativeFetch(input, options);
};
`;
const app = spawn(process.execPath, ['--import', `data:text/javascript,${encodeURIComponent(preload)}`, serverScript], {
env: { ...process.env, HOST: '127.0.0.1', PORT: String(port), TEAM_ROTATION_DATA_DIR: dataDir,
TEAM_ROTATION_DATA_KEY: dataKey, TEAM_ROTATION_API_TOKEN: '', TEAM_ROTATION_LOGIN_PASSWORD: 'isolated-roxy-password',
CHATGPT_BASE_URL: `http://127.0.0.1:${roxyPort}`,
DISABLE_MAINTENANCE: 'true', OPENAI_CALLBACK_ENABLED: 'false' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
app.stdout.on('data', (chunk) => { output += chunk; });
app.stderr.on('data', (chunk) => { output += chunk; });
t.after(async () => {
releaseBrowser?.();
for (const release of heldBrowsers.values()) release();
for (const release of heldQuotas.values()) release();
for (const release of heldOpens.values()) release();
for (const release of heldDeletes.values()) release();
if (app.exitCode === null && app.signalCode === null) {
const stopped = new Promise((resolve) => app.once('exit', resolve));
app.kill();
await stopped;
}
upstream.closeAllConnections();
await new Promise((resolve) => upstream.close(resolve));
assert.equal(path.dirname(dataDir), path.resolve(tmpdir()));
assert.match(path.basename(dataDir), /^roxy-integration-/);
await rm(dataDir, { recursive: true, force: true });
});
const baseUrl = `http://127.0.0.1:${port}`;
let ready = false;
for (let attempt = 0; attempt < 100; attempt += 1) {
if (app.exitCode !== null) throw new Error(`fixture server exited: ${output}`);
try {
const response = await fetch(`${baseUrl}/api/auth/session`, { signal: AbortSignal.timeout(500) });
await response.arrayBuffer();
if (response.ok) { ready = true; break; }
} catch { /* Wait for the isolated application to listen. */ }
await delay(25);
}
assert.equal(ready, true, output);
const login = await fetch(`${baseUrl}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'isolated-roxy-password' }), signal: AbortSignal.timeout(5000) });
assert.equal(login.status, 200);
const cookie = login.headers.get('set-cookie').split(';', 1)[0];
const config = { enabled: true, baseUrl: `http://127.0.0.1:${roxyPort}`, apiKey: secret,
workspaceId: 'roxy_workspace_a', profiles: [{ childId: 'child_alpha', dirId: 'roxy_profile_a' }] };
async function request(route, { method = 'GET', body, authenticated = true } = {}) {
const response = await fetch(`${baseUrl}${route}`, { method,
headers: { 'content-type': 'application/json', ...(authenticated ? { cookie } : {}) },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000) });
return { status: response.status, data: await response.json() };
}
async function patch(roxy, extra = {}) { return request('/api/integrations', { method: 'PATCH', body: { ...extra, roxy } }); }
async function saved() {
const raw = await readFile(path.join(dataDir, 'state.json'), 'utf8');
const envelope = JSON.parse(raw);
if (!envelope.encrypted) return { raw, envelope, state: envelope };
const decipher = createDecipheriv('aes-256-gcm', createHash('sha256').update(dataKey).digest(), Buffer.from(envelope.iv, 'base64'));
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
const state = JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]).toString());
return { raw, envelope, state };
}
async function waitJob(id) {
for (let attempt = 0; attempt < 100; attempt += 1) {
const status = await request(`/api/children/auto-json/${id}`);
assert.equal(status.status, 200);
if (status.data.job.status === 'complete') return status.data.job;
await delay(20);
}
throw new Error('isolated Roxy automatic JSON task did not finish');
}
async function runJob(body) {
const started = await request('/api/children/auto-json', { method: 'POST', body });
assert.equal(started.status, 202, JSON.stringify(started.data));
return waitJob(started.data.job.id);
}
async function addProxies({ enabled = true, names = ['alpha', 'beta', 'gamma'] } = {}) {
const added = await request('/api/proxy/entries', { method: 'POST', body: { values: names.map(
(name) => `http://fixture-proxy-${name}:private-proxy-password@127.0.0.1:${roxyPort}`) } });
assert.equal(added.status, 200, JSON.stringify(added.data));
assert.equal(added.data.added.length, names.length);
const updated = await request('/api/proxy', { method: 'PATCH', body: { enabled, strategy: 'round_robin', maxRetries: 2 } });
assert.equal(updated.status, 200);
return added.data.added;
}
async function waitBrowser() {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (releaseBrowser) return;
await delay(20);
}
throw new Error('isolated Roxy browser did not reach the held authorization stage');
}
return { request, patch, config, saved, requests, runJob, waitJob, addProxies, waitBrowser,
heldBrowsers, heldQuotas, heldOpens, heldDeletes, openedProfiles, deletedProfiles,
remoteProfiles: profiles, browserPeak: () => browserPeak, profilePeak: () => profilePeak,
releaseAccount: (email) => { heldBrowsers.get(email)?.(); heldBrowsers.delete(email); },
releaseBrowser: () => releaseBrowser?.(), workspaceId, output: () => output };
}
function assertNoSecrets(value) {
assert.doesNotMatch(JSON.stringify(value), /private-roxy|private-upstream|private-user|private-password|private-proxy|private-mailbox|private-sub2api|private-alpha-password|private-fixture-(?:rt|signature)|private-(?:old|callback)-(?:verifier|code)/);
}
const parallelChildren = ['alpha', 'beta', 'gamma', 'delta', 'epsilon'].map((name) => ({
id: `child_${name}`, email: `${name}@example.test`, password: `private-${name}-password`,
}));
async function waitForFixture(condition, description) {
for (let attempt = 0; attempt < 150; attempt += 1) {
if (condition()) return;
await delay(10);
}
assert.fail(description);
}
async function configureParallel(app, accounts, concurrency) {
app.remoteProfiles.push(...accounts.map((child) => ({ dirId: `profile_${child.id}`,
windowName: `Fixture ${child.id}`, windowRemark: `auto-team:child:${child.id}` })));
const result = await app.patch({ ...app.config, concurrency,
profiles: accounts.map((child) => ({ childId: child.id, dirId: `profile_${child.id}` })) });
assert.equal(result.status, 200, JSON.stringify(result.data));
}
async function startTeamJob(app, accounts) {
const result = await app.request('/api/children/auto-json', { method: 'POST', body: {
scope: 'team', motherId: 'fixture_mother', ids: accounts.map((child) => child.id),
} });
assert.equal(result.status, 202, JSON.stringify(result.data));
return result.data.job;
}
function assertConfirmedClosures(requests) {
for (const [index, request] of requests.entries()) {
if (request.path !== '/browser/close') continue;
const { dirId } = JSON.parse(request.body);
const nextOpen = requests.findIndex((entry, next) => next > index && entry.path === '/browser/open'
&& JSON.parse(entry.body).dirId === dirId);
const afterClose = requests.slice(index + 1, nextOpen < 0 ? undefined : nextOpen);
assert.ok(afterClose.some((entry) => entry.path === '/browser/connection_info' && entry.query.dirIds === dirId
&& !entry.openedProfiles.includes(dirId)), `closure of ${dirId} must be confirmed before another account reuses it`);
}
}
for (const concurrency of [1, 3]) test(`nine accounts reuse at most three pooled environments at concurrency ${concurrency} with isolated sessions`, { timeout: 20000 }, async (t) => {
const accounts = Array.from({ length: 9 }, (_, i) => ({ id: i ? `pool_child_${i}` : 'child_alpha', email: `pool-${i}@example.test`, password: `private-pool-${i}` }));
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts, profileCapacity: 3,
failBrowserFor: [accounts[1].email] });
const patched = await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency });
assert.equal(patched.status, 200, JSON.stringify(patched.data));
const job = await app.waitJob((await startTeamJob(app, accounts)).id);
assert.equal(job.succeeded, 8, JSON.stringify(job));
assert.equal(job.needsAttention, 1);
assert.equal(job.completed, 9);
const opened = app.requests.filter((request) => request.path === '/browser/open');
assert.equal(opened.length, 9, 'each account runs once');
assert.ok(new Set(opened.map((request) => JSON.parse(request.body).dirId)).size <= 3);
assert.ok(app.requests.filter((request) => request.path === '/browser/create').length <= 2, 'existing owned environment is reused');
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-create').length, 9);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 9);
assert.equal(app.openedProfiles.size, 0);
assert.ok(app.browserPeak() <= concurrency);
assert.ok(job.logs.some((entry) => entry.message.includes('隔离登录会话')));
const saved = (await app.saved()).state;
for (const account of accounts.filter((item) => item !== accounts[1])) {
assert.equal(saved.children.find((item) => item.id === account.id).workspaceTokens[app.workspaceId].accountId, app.workspaceId);
}
assertNoSecrets(job);
});
for (const concurrency of [1, 3]) test(`nine legacy dedicated bindings recover a missing pool slot using three existing environments at concurrency ${concurrency}`, { timeout: 20000 }, async (t) => {
const accounts = Array.from({ length: 9 }, (_, index) => ({ id: index ? `legacy_child_${index}` : 'child_alpha',
email: `legacy-${index}@example.test`, password: `private-legacy-${index}` }));
const remoteProfiles = accounts.slice(0, 3).map((child) => ({ dirId: `legacy_profile_${child.id}`,
windowName: `AutoTeam ${child.email}`, windowRemark: `auto-team:child:${child.id}` }));
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true,
concurrentAccounts: accounts, profileCapacity: 3, remoteProfiles });
const proxies = await app.addProxies();
const legacyBindings = accounts.map((child, index) => ({ childId: child.id, dirId: `legacy_profile_${child.id}`,
proxyId: proxies[(index + 1) % proxies.length].id }));
const configured = await app.patch({ ...app.config, profileMode: 'dedicated', useProxyPool: true,
profiles: legacyBindings, poolProfiles: [{ slot: 0, dirId: 'deleted_pool_profile' }] });
assert.equal(configured.status, 200, JSON.stringify(configured.data));
assert.equal((await app.patch({ profileMode: 'pooled', poolSize: 3, concurrency })).status, 200);
const started = await startTeamJob(app, accounts);
assert.equal(started.concurrency, concurrency);
await waitForFixture(() => app.heldBrowsers.size === concurrency, 'all configured workers must acquire existing environments');
for (const child of accounts) {
await waitForFixture(() => app.heldBrowsers.has(child.email), `legacy account ${child.id} must reach its isolated login`);
app.releaseAccount(child.email);
}
const job = await app.waitJob(started.id);
assert.equal(job.succeeded, 9, JSON.stringify(job));
assert.equal(job.completed, 9);
assert.equal(job.needsAttention, 0);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0,
'stale local bindings must not cause creation beyond the three existing remote environments');
const opened = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body));
const validIds = new Set(remoteProfiles.map((profile) => profile.dirId));
assert.equal(opened.length, 9);
assert.ok(opened.every((request) => validIds.has(request.dirId) && request.forceOpen === false));
assert.equal(new Set(opened.map((request) => request.dirId)).size, concurrency);
assert.equal(app.browserPeak(), concurrency);
assert.equal(app.openedProfiles.size, 0);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 9);
assertConfirmedClosures(app.requests);
const contexts = app.requests.filter((request) => request.path === '/fixture/context-create').map((request) => JSON.parse(request.body));
const closedContexts = app.requests.filter((request) => request.path === '/fixture/context-close').map((request) => JSON.parse(request.body));
assert.equal(contexts.length, 9);
assert.equal(new Set(contexts.map((context) => context.contextId)).size, 9, 'each account must receive a fresh browser context');
assert.deepEqual(new Set(contexts.map((context) => context.childId)), new Set(accounts.map((child) => child.id)));
assert.ok(contexts.every((context) => validIds.has(context.dirId)));
assert.deepEqual(closedContexts.toSorted((a, b) => a.contextId - b.contextId), contexts.toSorted((a, b) => a.contextId - b.contextId));
const authContexts = app.requests.filter((request) => request.path === '/fixture/context-auth').map((request) => JSON.parse(request.body));
assert.equal(authContexts.length, 9);
const saved = (await app.saved()).state;
assert.deepEqual(saved.settings.integrations.roxy.profiles, legacyBindings);
assert.ok(saved.settings.integrations.roxy.poolProfiles.every((profile) => validIds.has(profile.dirId)));
for (const [index, child] of accounts.entries()) {
const proxyIndex = (index + 1) % proxies.length;
const username = `fixture-proxy-${['alpha', 'beta', 'gamma'][proxyIndex]}`;
const expectedAuth = `Basic ${Buffer.from(`${username}:private-proxy-password`).toString('base64')}`;
const stored = saved.children.find((entry) => entry.id === child.id);
assert.equal(stored.roxyProxyId, legacyBindings[index].proxyId);
assert.equal(stored.workspaceTokens[app.workspaceId].accountId, app.workspaceId);
assert.equal(authContexts.find((context) => context.childId === child.id)?.username, username);
const tokenRequests = app.requests.filter((request) => request.path === '/fixture/token'
&& new URLSearchParams(request.body).get('code') === `fixture-automatic-code-${child.email}`);
assert.equal(tokenRequests.length, 1);
assert.equal(tokenRequests[0].proxyAuth, expectedAuth);
const quotas = app.requests.filter((request) => request.path === '/backend-api/wham/usage' && request.subject === child.id);
assert.equal(quotas.length, 1);
assert.equal(quotas[0].proxyAuth, expectedAuth, 'quota must retain the account proxy after its browser is closed');
}
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test('an occupied first pooled slot falls through to an existing idle slot without opening or closing the occupied window', { timeout: 20000 }, async (t) => {
const remoteProfiles = ['a', 'b', 'c'].map((name, slot) => ({ dirId: `roxy_profile_${name}`,
windowName: `Fixture pool ${slot}`, windowRemark: `auto-team:pool:${slot}` }));
const app = await fixture(t, { browserFlow: true, teamFlow: true, occupiedProfile: true, profileCapacity: 3, remoteProfiles });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1,
poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
assert.deepEqual(app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId), ['roxy_profile_b']);
assert.deepEqual(app.requests.filter((request) => request.path === '/browser/close').map((request) => JSON.parse(request.body).dirId), ['roxy_profile_b']);
assertConfirmedClosures(app.requests);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-create').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 1);
assert.equal(app.browserPeak(), 1);
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(job);
});
test('shrinking a pool from three slots to one reuses an existing higher-slot environment after slot zero disappears', { timeout: 20000 }, async (t) => {
const dirId = 'retained_pool_profile';
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 1,
remoteProfiles: [{ dirId, windowName: 'Fixture previous pool slot', windowRemark: 'auto-team:pool:1' }] });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3,
poolProfiles: [{ slot: 0, dirId: 'deleted_pool_profile' }, { slot: 1, dirId }] })).status, 200);
assert.equal((await app.patch({ poolSize: 1 })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.concurrency, 1);
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
assert.deepEqual(app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId), [dirId]);
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId }],
'moving the retained environment to slot zero must remove its obsolete higher-slot binding');
assert.equal((await app.patch({ headless: true })).status, 200, 'the recovered pool configuration must remain valid on later saves');
assertConfirmedClosures(app.requests);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-create').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 1);
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(job);
});
test('pooled configuration caps batch concurrency at the environment count and survives partial saves', async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 1, concurrency: 3 })).status, 200);
const first = await app.runJob({ scope: 'team', motherId: 'fixture_mother', ids: ['child_alpha'] });
assert.equal(first.concurrency, 1);
assert.equal(first.succeeded, 1);
const before = (await app.saved()).state.settings.integrations.roxy;
assert.equal((await app.patch({ concurrency: 2 })).status, 200);
const after = (await app.saved()).state.settings.integrations.roxy;
assert.deepEqual(after.poolProfiles, before.poolProfiles);
assert.equal(after.profileMode, 'pooled');
assert.equal(after.poolSize, 1);
});
test('pooled authorization preserves account proxy routing across new leases, cached RT refresh and quota checks', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 3,
priorFields: { refreshToken: 'private-fixture-rt-child_alpha' } });
const proxies = await app.addProxies();
const expectedProxy = proxies[1];
const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`;
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, useProxyPool: true,
profiles: [{ ...app.config.profiles[0], proxyId: expectedProxy.id }] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 1, JSON.stringify(job));
let stored = (await app.saved()).state;
assert.equal(stored.children.find((child) => child.id === 'child_alpha').roxyProxyId, expectedProxy.id);
assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId: 'roxy_profile_a' }]);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal(JSON.parse(app.requests.find((request) => request.path === '/browser/mdf').body).proxyInfo.proxyUserName, 'fixture-proxy-beta');
assert.ok(app.requests.some((request) => request.path === '/fixture/token' && request.proxyAuth === expectedAuth));
const automaticQuotas = app.requests.filter((request) => request.path === '/backend-api/wham/usage');
assert.equal(automaticQuotas.length, 1);
assert.equal(automaticQuotas[0].proxyAuth, expectedAuth, 'the initial automatic quota request must retain the account proxy after OAuth releases its browser lease');
// Keep the saved account proxy but rebuild its pooled lease. The outer
// authorization network snapshot predates this newly allocated pool slot.
assert.equal((await app.patch({ poolProfiles: [] })).status, 200);
const beforeNewLease = app.requests.length;
const reacquired = await app.request('/api/children/child_alpha/team-auth', { method: 'POST',
body: { motherId: 'fixture_mother', force: true } });
assert.equal(reacquired.status, 200, JSON.stringify(reacquired.data));
assert.equal(reacquired.data.ok, true);
const leasedRequests = app.requests.slice(beforeNewLease);
assert.equal(leasedRequests.filter((request) => request.path === '/browser/open').length, 1);
assert.ok(leasedRequests.some((request) => request.path === '/fixture/token' && request.proxyAuth === expectedAuth));
stored = (await app.saved()).state;
assert.equal(stored.children.find((child) => child.id === 'child_alpha').roxyProxyId, expectedProxy.id);
assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId: 'roxy_profile_a' }]);
const beforeCachedRequests = app.requests.length;
const refreshed = await app.request('/api/children/child_alpha/acquire', { method: 'POST', body: { mode: 'refresh-at' } });
assert.equal(refreshed.status, 200, JSON.stringify(refreshed.data));
assert.equal(refreshed.data.source, 'refresh_token');
for (let attempt = 0; attempt < 3; attempt += 1) {
const quota = await app.request('/api/children/child_alpha/team-quota', { method: 'POST',
body: { motherId: 'fixture_mother', workspaceId: app.workspaceId } });
assert.equal(quota.status, 200, JSON.stringify(quota.data));
assert.equal(quota.data.quota5h, 75);
assert.equal(quota.data.quota7d, 90);
}
const cachedRequests = app.requests.slice(beforeCachedRequests);
assert.ok(!cachedRequests.some((request) => request.path.startsWith('/browser/')), 'cached refresh and quota checks must not acquire a Roxy environment');
const refreshRequest = cachedRequests.find((request) => request.path === '/fixture/token');
assert.equal(new URLSearchParams(refreshRequest?.body).get('grant_type'), 'refresh_token');
const routedRequests = cachedRequests.filter((request) => request.path === '/fixture/token' || request.path === '/backend-api/wham/usage');
assert.equal(routedRequests.length, 4);
assert.ok(routedRequests.every((request) => request.proxyAuth === expectedAuth), 'round-robin must not replace the saved account proxy after its browser lease ends');
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(job);
assertNoSecrets(reacquired.data);
assertNoSecrets(refreshed.data);
assertNoSecrets(app.output());
});
for (const profileMode of ['pooled', 'dedicated']) for (const scope of ['free', 'team']) {
test(`${profileMode} ${scope} success deletes only its closed saved environment and retains the account proxy`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: scope === 'team', profileCapacity: 3 });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode, poolSize: 3, useProxyPool: true, deleteProfileAfterSuccess: true,
profiles: [{ ...app.config.profiles[0], proxyId: proxies[1].id }, { childId: 'child_beta', dirId: 'untouched_beta_profile', proxyId: proxies[2].id }],
poolProfiles: [{ slot: 0, dirId: 'roxy_profile_a' }] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], ...(scope === 'team' ? { scope, motherId: 'fixture_mother' } : {}) });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results[0].roxyCleanup?.deleted, true, 'successful JSON must report released environment capacity');
const deleted = app.requests.filter((request) => request.path === '/browser/delete');
assert.equal(deleted.length, 1);
assert.equal(deleted[0].method, 'POST');
assert.deepEqual(JSON.parse(deleted[0].body), { workspaceId: app.config.workspaceId, dirIds: ['roxy_profile_a'], isSoftDelete: true });
assert.equal(deleted[0].persistedJson.find((child) => child.id === 'child_alpha')[scope], true,
'complete JSON must already be on disk when Roxy receives deletion');
assert.deepEqual(deleted[0].openedProfiles, []);
const deletionIndex = app.requests.indexOf(deleted[0]);
const closedIndex = app.requests.findIndex((request) => request.path === '/browser/close');
assert.ok(closedIndex >= 0 && closedIndex < deletionIndex);
assert.ok(app.requests.slice(closedIndex + 1, deletionIndex).some((request) => request.path === '/browser/connection_info'
&& request.query.dirIds === 'roxy_profile_a' && !request.openedProfiles.includes('roxy_profile_a')));
const stored = (await app.saved()).state;
assert.deepEqual(stored.settings.integrations.roxy.profiles,
[{ childId: 'child_beta', dirId: 'untouched_beta_profile', proxyId: proxies[2].id }]);
assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, []);
assert.equal(stored.children.find((child) => child.id === 'child_alpha').roxyProxyId, proxies[1].id);
assert.equal(app.remoteProfiles.length, 0);
assert.equal(app.openedProfiles.size, 0);
const beforeCached = app.requests.length;
const cached = await app.request(scope === 'team' ? '/api/children/child_alpha/team-quota' : '/api/children/child_alpha/acquire', {
method: 'POST', body: scope === 'team' ? { motherId: 'fixture_mother', workspaceId: app.workspaceId } : { mode: 'refresh-at' } });
assert.equal(cached.status, 200, JSON.stringify(cached.data));
const routed = app.requests.slice(beforeCached).filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path));
assert.equal(routed.length, 1);
assert.equal(routed[0].proxyAuth, `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`);
assert.ok(!app.requests.slice(beforeCached).some((request) => request.path.startsWith('/browser/')));
assertNoSecrets(job);
assertNoSecrets(app.output());
});
}
for (const deletionFailure of ['deleteFailure', 'deleteStillListed']) {
test(`${deletionFailure} retains saved JSON and reports a cleanup warning without another authorization`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, [deletionFailure]: true });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, deleteProfileAfterSuccess: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results[0].status, 'ready');
assert.equal(job.results[0].roxyCleanup?.deleted, false);
assert.ok(job.logs.some((entry) => entry.status === 'warning'), 'environment release failure must remain visible as a warning');
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1);
assert.equal(app.remoteProfiles.length, 1);
assert.equal(app.openedProfiles.size, 0);
const saved = (await app.saved()).state;
assert.ok(saved.children.find((child) => child.id === 'child_alpha').workspaceTokens[app.workspaceId].refreshToken);
assert.deepEqual(saved.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId: 'roxy_profile_a' }]);
const beforeSkip = app.requests.length;
const repeated = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother', retryAccount: true });
assert.equal(repeated.succeeded, 1, JSON.stringify(repeated));
assert.equal(repeated.results[0].reused, true);
assert.deepEqual(app.requests.slice(beforeSkip), [], 'saved credentials must skip both reauthorization and unrelated cleanup');
assertNoSecrets(job);
assertNoSecrets(repeated);
});
}
for (const scenario of ['incomplete-json', 'verification', 'close-failure', 'manual-profile']) {
test(`successful-environment deletion is suppressed for ${scenario}`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true,
...(scenario === 'manual-profile' ? {} : { profileCapacity: 3 }),
...(scenario === 'incomplete-json' ? { tokenFields: { refresh_token: '' } } : {}),
...(scenario === 'verification' ? { browserVerification: true } : {}),
...(scenario === 'close-failure' ? { cleanupFailureFor: ['roxy_profile_a'] } : {}) });
assert.equal((await app.patch({ ...app.config, profileMode: scenario === 'manual-profile' ? 'dedicated' : 'pooled',
poolSize: 3, deleteProfileAfterSuccess: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, scenario === 'manual-profile' ? 1 : 0, JSON.stringify(job));
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 0);
assert.equal(app.remoteProfiles.length, 1);
assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].dirId, 'roxy_profile_a');
assertNoSecrets(job);
});
}
for (const failure of [
{ name: 'rejected credentials', code: 'browser_oauth_credentials_rejected', status: 401, profileMode: 'pooled', scope: 'team' },
{ name: 'official verification', code: 'browser_verification_required', status: 403, profileMode: 'dedicated', scope: 'team' },
{ name: 'OAuth timeout', code: 'browser_oauth_timeout', status: 408, profileMode: 'pooled', scope: 'free' },
{ name: 'incomplete JSON', profileMode: 'pooled', scope: 'team' },
]) test(`failed-environment release recycles ${failure.name} without changing the failed result`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: failure.scope === 'team', profileCapacity: 3,
...(failure.code ? { browserFailureSequences: { [children[0].email]: [failure] } } : { tokenFields: { refresh_token: '' } }) });
assert.equal((await app.patch({ ...app.config, profileMode: failure.profileMode, poolSize: 3,
deleteProfileAfterFailure: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], ...(failure.scope === 'team' ? { scope: 'team', motherId: 'fixture_mother' } : {}) });
assert.equal(job.succeeded, 0, JSON.stringify(job));
assert.ok(['failed', 'needs_attention'].includes(job.results[0].status));
if (failure.code) assert.equal(job.results[0].code, failure.code);
assert.equal(job.results[0].roxyCleanup?.deleted, true);
assert.ok(job.logs.some((entry) => entry.message.includes('回收站') && entry.message.includes('失败')),
'release must be visible in the running log without claiming the authorization succeeded');
const deleted = app.requests.filter((request) => request.path === '/browser/delete');
assert.equal(deleted.length, 1);
assert.deepEqual(JSON.parse(deleted[0].body), { workspaceId: app.config.workspaceId, dirIds: ['roxy_profile_a'], isSoftDelete: true });
assert.deepEqual(deleted[0].openedProfiles, []);
assert.ok(app.requests.findIndex((request) => request.path === '/browser/close') < app.requests.indexOf(deleted[0]));
assert.equal(app.openedProfiles.size, 0);
assert.equal(app.remoteProfiles.length, 0);
const saved = (await app.saved()).state;
assert.equal(saved.children.length, children.length, 'releasing browser capacity must preserve local accounts');
assert.deepEqual(saved.settings.integrations.roxy.profiles, []);
assert.deepEqual(saved.settings.integrations.roxy.poolProfiles || [], []);
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test('failed-environment release closes a headed dedicated verification window even when the browser asks to keep it open', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, failBrowserFor: [children[0].email] });
assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', headless: false, deleteProfileAfterFailure: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.results[0].code, 'browser_verification_required');
assert.equal(job.results[0].roxyCleanup?.deleted, true);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1);
assert.equal(app.openedProfiles.size, 0);
assert.equal(app.remoteProfiles.length, 0);
assertNoSecrets(job);
});
test('failed-environment release reclaims a newly created environment when Roxy refuses to open it', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3,
accountId: 'child_beta', openFailureFor: ['created_profile_1'] });
assert.equal((await app.patch({ ...app.config, deleteProfileAfterFailure: true })).status, 200);
const job = await app.runJob({ ids: ['child_beta'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.results[0].code, 'roxy_api_failed');
assert.equal(job.results[0].roxyCleanup?.deleted, true);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 1);
assert.deepEqual(app.requests.filter((request) => request.path === '/browser/delete').map((request) => JSON.parse(request.body).dirIds), [['created_profile_1']]);
assert.deepEqual(app.remoteProfiles.map((profile) => profile.dirId), ['roxy_profile_a']);
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, app.config.profiles);
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(job);
});
test('failed-environment release reclaims an existing owned dedicated environment when Roxy refuses to open it', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, openFailureFor: ['roxy_profile_a'] });
assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', deleteProfileAfterFailure: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.results[0].code, 'roxy_api_failed');
assert.equal(job.results[0].roxyCleanup?.deleted, true);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
const deleted = app.requests.filter((request) => request.path === '/browser/delete');
assert.equal(deleted.length, 1);
assert.deepEqual(JSON.parse(deleted[0].body).dirIds, ['roxy_profile_a']);
assert.deepEqual(deleted[0].openedProfiles, []);
assert.equal(app.remoteProfiles.length, 0);
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, []);
assertNoSecrets(job);
});
test('failed-environment release survives an unexpected post-browser exception and reports cleanup on the failed result', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3,
unexpectedLoginErrorFor: [children[0].email] });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', deleteProfileAfterFailure: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 0, JSON.stringify(job));
assert.equal(job.results[0].status, 'failed');
assert.equal(job.results[0].roxyCleanup?.deleted, true, 'an unexpected exception must not lose the successful capacity release report');
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1);
assert.equal(app.remoteProfiles.length, 0);
assert.equal(app.openedProfiles.size, 0);
assert.equal((await app.saved()).state.children[0].workspaceTokens?.[app.workspaceId], undefined);
assert.doesNotMatch(JSON.stringify(job), /private-unexpected-login-error/);
assertNoSecrets(job);
});
for (const deletionFailure of ['deleteFailure', 'deleteStillListed']) {
test(`failed-environment ${deletionFailure} preserves the login error and leaves the saved binding available for recovery`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, [deletionFailure]: true,
browserFailureSequences: { [children[0].email]: [{ code: 'browser_oauth_credentials_rejected', status: 401 }] } });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', deleteProfileAfterFailure: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 0);
assert.equal(job.results[0].code, 'browser_oauth_credentials_rejected');
assert.equal(job.results[0].roxyCleanup?.deleted, false);
assert.ok(job.logs.some((entry) => entry.status === 'warning'));
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1);
assert.equal(app.remoteProfiles.length, 1);
assert.equal(app.openedProfiles.size, 0);
assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].dirId, 'roxy_profile_a');
assertNoSecrets(job);
});
}
for (const scenario of ['close-failure', 'manual-profile']) {
test(`failed-environment release refuses to delete a ${scenario}`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true,
...(scenario === 'close-failure' ? { profileCapacity: 3, cleanupFailureFor: ['roxy_profile_a'] } : {}),
browserFailureSequences: { [children[0].email]: [{ code: 'browser_oauth_credentials_rejected', status: 401 }] } });
assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', deleteProfileAfterFailure: true })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 0);
assert.equal(job.results[0].code, 'browser_oauth_credentials_rejected');
assert.equal(job.results[0].roxyCleanup?.deleted, false);
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 0);
assert.equal(app.remoteProfiles.length, 1);
assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].dirId, 'roxy_profile_a');
assertNoSecrets(job);
});
}
test('failed-environment release waits through the three-proxy budget and logs allow retrying only the failed account in a new environment', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, concurrentAccounts: children, profileCapacity: 3,
browserFailureSequences: { [children[0].email]: Array(3).fill('browser_oauth_proxy_connection_failed') } });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1, useProxyPool: true,
deleteProfileAfterSuccess: true, deleteProfileAfterFailure: true,
profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200);
const initial = await app.waitJob((await startTeamJob(app, children)).id);
assert.equal(initial.succeeded, 1, JSON.stringify(initial));
const failed = initial.results.find((result) => result.id === children[0].id);
assert.equal(failed.code, 'browser_oauth_proxy_connection_failed');
assert.equal(failed.roxyCleanup?.deleted, true);
const firstDelete = app.requests.findIndex((request) => request.path === '/browser/delete');
const priorOpens = app.requests.slice(0, firstDelete).filter((request) => request.path === '/browser/open');
assert.equal(priorOpens.length, 3, 'internal proxy retries must finish before deleting their shared environment');
assert.ok(priorOpens.every((request) => JSON.parse(request.body).dirId === 'roxy_profile_a'));
assert.equal(app.requests.slice(0, firstDelete).filter((request) => request.path === '/browser/create').length, 0);
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 2);
assert.equal(app.remoteProfiles.length, 0);
const beforeRetry = app.requests.length;
const retried = await app.runJob({ ids: [children[0].id], scope: 'team', motherId: 'fixture_mother',
retryFailed: true, retryJobId: initial.id });
assert.equal(retried.succeeded, 1, JSON.stringify(retried));
assert.deepEqual(retried.results.map((result) => result.id), [children[0].id]);
assert.equal(retried.results[0].roxyCleanup?.deleted, true);
const retryRequests = app.requests.slice(beforeRetry);
assert.equal(retryRequests.filter((request) => request.path === '/browser/create').length, 1);
const retryOpen = retryRequests.filter((request) => request.path === '/browser/open');
assert.equal(retryOpen.length, 1);
assert.notEqual(JSON.parse(retryOpen[0].body).dirId, 'roxy_profile_a');
assert.equal(app.remoteProfiles.length, 0);
assert.equal(app.openedProfiles.size, 0);
const beforeSkip = app.requests.length;
const skipped = await app.runJob({ ids: [children[1].id], scope: 'team', motherId: 'fixture_mother', retryAccount: true });
assert.equal(skipped.results[0].reused, true);
assert.deepEqual(app.requests.slice(beforeSkip), []);
assertNoSecrets(initial);
assertNoSecrets(retried);
});
test('failed-environment release holds the serial queue until deletion is confirmed before the next account starts', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: children, profileCapacity: 3,
holdDeleteFor: ['roxy_profile_a'], browserFailureSequences: { [children[0].email]: ['browser_oauth_timeout'] } });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1,
deleteProfileAfterFailure: true, deleteProfileAfterSuccess: true })).status, 200);
const started = await startTeamJob(app, children);
await waitForFixture(() => app.heldDeletes.has('roxy_profile_a'), 'failed account deletion should remain in flight');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
assert.equal((await app.request(`/api/children/auto-json/${started.id}`)).data.job.status, 'running');
app.heldDeletes.get('roxy_profile_a')();
const job = await app.waitJob(started.id);
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results.find((result) => result.id === children[0].id).roxyCleanup?.deleted, true);
const opens = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId);
assert.equal(opens.length, 2);
assert.notEqual(opens[0], opens[1]);
assert.equal(app.browserPeak(), 1);
assert.equal(app.remoteProfiles.length, 0);
assertNoSecrets(job);
});
test('failed-environment release reclaims a closed concurrent account even while another owned window cannot close', { timeout: 20000 }, async (t) => {
const remoteProfiles = children.map((child, slot) => ({ dirId: `failure_profile_${slot}`,
windowName: `Fixture failed ${child.id}`, windowRemark: `auto-team:pool:${slot}` }));
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: children, holdBrowser: true,
profileCapacity: 3, remoteProfiles, cleanupFailureFor: [remoteProfiles[0].dirId],
browserFailureSequences: Object.fromEntries(children.map((child) => [child.email,
[{ code: 'browser_oauth_credentials_rejected', status: 401 }]])) });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 2,
deleteProfileAfterFailure: true, profiles: [],
poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200);
const started = await startTeamJob(app, children);
await waitForFixture(() => app.heldBrowsers.size === 2, 'both accounts must acquire their windows before either fails');
app.releaseAccount(children[0].email);
let firstResult;
for (let attempt = 0; attempt < 100; attempt += 1) {
firstResult = (await app.request(`/api/children/auto-json/${started.id}`)).data.job.results.find((result) => result.id === children[0].id);
if (firstResult) break;
await delay(20);
}
assert.equal(firstResult?.code, 'browser_oauth_credentials_rejected');
assert.equal(firstResult.roxyCleanup?.deleted, false);
assert.equal(app.openedProfiles.has(remoteProfiles[0].dirId), true);
app.releaseAccount(children[1].email);
const job = await app.waitJob(started.id);
const secondResult = job.results.find((result) => result.id === children[1].id);
assert.equal(secondResult.code, 'browser_oauth_credentials_rejected');
assert.equal(secondResult.roxyCleanup?.deleted, true, 'an unrelated close failure must not block reclaiming this already closed environment');
assert.deepEqual(app.requests.filter((request) => request.path === '/browser/delete').map((request) => JSON.parse(request.body).dirIds), [[remoteProfiles[1].dirId]]);
assert.deepEqual(app.remoteProfiles.map((profile) => profile.dirId), [remoteProfiles[0].dirId]);
assert.deepEqual([...app.openedProfiles], [remoteProfiles[0].dirId]);
assertNoSecrets(job);
});
test('failed-environment release waits for a late browser startup and confirmed closure before removing its environment', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3,
holdOpenFor: ['roxy_profile_a'], timeoutBeforeSessionFor: [children[0].email] });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', deleteProfileAfterFailure: true })).status, 200);
const started = await startTeamJob(app, [children[0]]);
await waitForFixture(() => app.heldOpens.has('roxy_profile_a'), 'Roxy open should remain pending after OAuth timeout');
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 0);
app.heldOpens.get('roxy_profile_a')();
const job = await app.waitJob(started.id);
assert.equal(job.results[0].code, 'browser_oauth_timeout');
assert.equal(job.results[0].roxyCleanup?.deleted, true);
const deletion = app.requests.find((request) => request.path === '/browser/delete');
assert.deepEqual(deletion.openedProfiles, []);
assert.ok(app.requests.findIndex((request) => request.path === '/browser/close') < app.requests.indexOf(deletion));
assert.equal(app.openedProfiles.size, 0);
assert.equal(app.remoteProfiles.length, 0);
assertNoSecrets(job);
});
test('deleting saved environments holds all three leases until removal and the next account never reopens a deleted directory', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 4);
const remoteProfiles = accounts.slice(0, 3).map((child, slot) => ({ dirId: `delete_profile_${slot}`,
windowName: `Fixture delete ${slot}`, windowRemark: `auto-team:pool:${slot}` }));
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true,
concurrentAccounts: accounts, profileCapacity: 3, remoteProfiles, holdDeleteFor: remoteProfiles.map((profile) => profile.dirId) });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3,
useProxyPool: true, deleteProfileAfterSuccess: true,
profiles: accounts.map((child, slot) => ({ childId: child.id, dirId: `delete_profile_${slot}`, proxyId: proxies[slot % 3].id })),
poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200);
const started = await startTeamJob(app, accounts);
await waitForFixture(() => app.heldBrowsers.size === 3, 'the first three accounts must occupy separate environments');
for (const child of accounts.slice(0, 3)) app.releaseAccount(child.email);
await waitForFixture(() => app.heldDeletes.size === 1
&& app.requests.filter((request) => request.path === '/browser/close').length === 3,
'successful accounts must retain their leases while deletion is pending under the provisioning lock');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 3);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
for (const request of app.requests.filter((request) => request.path === '/browser/delete')) {
assert.deepEqual(request.openedProfiles.filter((dirId) => JSON.parse(request.body).dirIds.includes(dirId)), []);
assert.ok(request.persistedJson.some((child) => child.team));
}
for (let completed = 0; completed < 3; completed += 1) {
await waitForFixture(() => app.heldDeletes.size > 0, 'each owned environment should reach serialized deletion');
const [dirId, release] = app.heldDeletes.entries().next().value;
release();
app.heldDeletes.delete(dirId);
}
await waitForFixture(() => app.heldBrowsers.has(accounts[3].email), 'the fourth account must create a replacement only after deletion completes');
const opened = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId);
assert.equal(opened.length, 4);
assert.ok(!remoteProfiles.some((profile) => profile.dirId === opened[3]));
app.releaseAccount(accounts[3].email);
const job = await app.waitJob(started.id);
assert.equal(job.succeeded, 4, JSON.stringify(job));
assert.ok(job.results.every((result) => result.roxyCleanup?.deleted === true));
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 4);
assert.equal(app.remoteProfiles.length, 0);
assert.equal(app.browserPeak(), 3);
assert.equal(app.profilePeak(), 3, 'saved Roxy environments must never exceed the three-slot capacity');
assert.equal(app.openedProfiles.size, 0);
assertConfirmedClosures(app.requests);
const stored = (await app.saved()).state;
assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, []);
assert.ok(stored.settings.integrations.roxy.profiles.every((profile) => !app.deletedProfiles.has(profile.dirId)));
for (const [index, child] of accounts.entries()) {
assert.equal(stored.children.find((entry) => entry.id === child.id).roxyProxyId, proxies[index % 3].id);
}
assertNoSecrets(job);
});
test('six accounts with saved Team JSON skip environment deletion even when release-after-success is enabled', { timeout: 20000 }, async (t) => {
const workspaceId = 'target_team_workspace';
const accounts = Array.from({ length: 6 }, (_, index) => ({ id: index ? `saved_child_${index}` : 'child_alpha',
email: `saved-${index}@example.test`, password: `private-saved-${index}` })).map((child) => ({ ...child,
workspaceTokens: { [workspaceId]: { accessToken: oauthToken(child, workspaceId, 'team').access_token,
refreshToken: `private-fixture-rt-${child.id}` } } }));
const remoteProfiles = Array.from({ length: 3 }, (_, slot) => ({ dirId: `other_profile_${slot}`,
windowName: `Unrelated saved profile ${slot}`, windowRemark: `auto-team:pool:${slot}` }));
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts, profileCapacity: 3, remoteProfiles });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, deleteProfileAfterSuccess: true,
poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200);
const job = await app.waitJob((await startTeamJob(app, accounts)).id);
assert.equal(job.succeeded, 6, JSON.stringify(job));
assert.ok(job.results.every((result) => result.reused === true));
assert.deepEqual(app.requests, [], 'cached successful JSON must not inspect, open, close or delete any environment');
assert.deepEqual(app.remoteProfiles, remoteProfiles);
assertNoSecrets(job);
});
test('saved-environment cleanup rejects unauthenticated, disabled and malformed requests without contacting Roxy', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const route = '/api/integrations/roxy/cleanup-successful';
assert.equal((await app.request(route, { method: 'POST', body: { childIds: ['child_alpha'] }, authenticated: false })).status, 401);
assert.equal((await app.request(route, { method: 'POST', body: { childIds: ['child_alpha'] } })).status, 409);
assert.equal((await app.patch({ ...app.config, deleteProfileAfterSuccess: true })).status, 200);
for (const body of [{}, { childIds: [] }, { childIds: 'child_alpha' }, { childIds: null }, { childIds: Array(1001).fill('child_alpha') }]) {
const rejected = await app.request(route, { method: 'POST', body });
assert.equal(rejected.status, 400, JSON.stringify(rejected.data));
assert.equal(rejected.data.code, 'child_ids_required');
}
assert.deepEqual(app.requests, []);
});
test('saved-environment cleanup deletes only selected owned profiles with complete JSON and keeps other bindings and proxies', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 3).map((child, index) => ({ ...child,
accessToken: oauthToken(child).access_token,
...(index !== 1 ? { refreshToken: `private-fixture-rt-${child.id}` } : {}) }));
const remoteProfiles = accounts.map((child, index) => ({ dirId: `saved_cleanup_${index}`, windowName: `Fixture saved ${index}`,
...(index !== 2 ? { windowRemark: `auto-team:child:${child.id}` } : {}) }));
const app = await fixture(t, { concurrentAccounts: accounts, remoteProfiles });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', useProxyPool: true, deleteProfileAfterSuccess: true,
profiles: accounts.map((child, index) => ({ childId: child.id, dirId: remoteProfiles[index].dirId, proxyId: proxies[index].id })),
poolProfiles: [{ slot: 0, dirId: remoteProfiles[0].dirId }] })).status, 200);
const result = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST',
body: { childIds: [...accounts.map((child) => child.id), 'missing_child'] } });
assert.equal(result.status, 200, JSON.stringify(result.data));
assert.equal(result.data.deleted, 1);
assert.equal(result.data.results.find((entry) => entry.childId === accounts[0].id)?.deleted, true);
assert.ok(!result.data.results.some((entry) => entry.childId === accounts[1].id), 'a missing refresh token cannot qualify for environment deletion');
assert.equal(result.data.results.find((entry) => entry.childId === accounts[2].id)?.deleted, false, 'an explicitly mapped manual environment must be preserved');
assert.deepEqual(app.requests.filter((request) => request.path === '/browser/delete').map((request) => JSON.parse(request.body).dirIds), [[remoteProfiles[0].dirId]]);
assert.ok(!app.requests.some((request) => ['/browser/open', '/browser/close', '/fixture/token'].includes(request.path)));
assert.deepEqual(app.remoteProfiles.map((profile) => profile.dirId), remoteProfiles.slice(1).map((profile) => profile.dirId));
const stored = (await app.saved()).state;
assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, []);
assert.deepEqual(stored.settings.integrations.roxy.profiles.map((profile) => profile.childId), accounts.slice(1).map((child) => child.id));
assert.equal(stored.children.find((child) => child.id === accounts[0].id).roxyProxyId, proxies[0].id);
for (const child of accounts.filter((_, index) => index !== 1)) {
assert.equal(stored.children.find((entry) => entry.id === child.id).refreshToken, child.refreshToken);
}
assertNoSecrets(result.data);
});
test('saved-environment cleanup rejects another cleanup while deletion is pending', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { profileCapacity: 3, holdDeleteFor: ['roxy_profile_a'],
priorFields: { accessToken: oauthToken(children[0]).access_token, refreshToken: 'private-fixture-rt-child_alpha' } });
assert.equal((await app.patch({ ...app.config, deleteProfileAfterSuccess: true })).status, 200);
const body = { childIds: ['child_alpha'] };
const first = app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body });
void first.catch(() => {});
await waitForFixture(() => app.heldDeletes.has('roxy_profile_a'), 'the first cleanup should hold the profile deletion operation');
const second = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body });
assert.equal(second.status, 409);
assert.equal(second.data.code, 'roxy_profile_busy');
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1);
app.heldDeletes.get('roxy_profile_a')();
assert.equal((await first).data.deleted, 1);
assert.equal(app.remoteProfiles.length, 0);
});
test('saved-environment cleanup rejects requests while an account authorization holds a browser lease', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true, holdBrowser: true, profileCapacity: 3 });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, deleteProfileAfterSuccess: true })).status, 200);
const started = await startTeamJob(app, [children[0]]);
await app.waitBrowser();
const beforeCleanup = app.requests.length;
const rejected = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body: { childIds: ['child_alpha'] } });
assert.equal(rejected.status, 409);
assert.equal(rejected.data.code, 'roxy_profile_busy');
assert.deepEqual(app.requests.slice(beforeCleanup), []);
app.releaseAccount(children[0].email);
assert.equal((await app.waitJob(started.id)).succeeded, 1);
});
test('saved-environment cleanup ignores credentials belonging to a different account', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { profileCapacity: 3,
priorFields: { accessToken: oauthToken(children[1]).access_token, refreshToken: 'private-fixture-rt-child_beta' } });
assert.equal((await app.patch({ ...app.config, deleteProfileAfterSuccess: true })).status, 200);
const result = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body: { childIds: ['child_alpha'] } });
assert.equal(result.status, 200);
assert.equal(result.data.deleted, 0);
assert.deepEqual(result.data.results, []);
assert.deepEqual(app.requests, []);
assert.equal(app.remoteProfiles.length, 1);
});
test('a new authorization waits for the whole historical cleanup batch before leasing either selected environment', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 3).map((child, index) => ({ ...child,
...(index < 2 ? { accessToken: oauthToken(child).access_token, refreshToken: `private-fixture-rt-${child.id}` } : {}) }));
const remoteProfiles = accounts.slice(0, 2).map((child, index) => ({ dirId: `historical_profile_${index}`,
windowName: `Historical ${index}`, windowRemark: `auto-team:child:${child.id}` }));
const app = await fixture(t, { browserFlow: true, teamFlow: true, holdBrowser: true, concurrentAccounts: accounts,
remoteProfiles, profileCapacity: 3, holdDeleteFor: remoteProfiles.map((profile) => profile.dirId) });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3, deleteProfileAfterSuccess: true,
profiles: accounts.slice(0, 2).map((child, index) => ({ childId: child.id, dirId: remoteProfiles[index].dirId })) })).status, 200);
const cleanup = app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST',
body: { childIds: accounts.slice(0, 2).map((child) => child.id) } });
void cleanup.catch(() => {});
await waitForFixture(() => app.heldDeletes.has(remoteProfiles[0].dirId), 'historical cleanup must start its first deletion');
const started = await startTeamJob(app, [accounts[2]]);
let queued = false;
for (let attempt = 0; attempt < 100; attempt += 1) {
const current = await app.request(`/api/children/auto-json/${started.id}`);
queued = current.data.job.logs.some((entry) => entry.message.includes('等待空闲 Roxy'));
if (queued) break;
await delay(10);
}
assert.equal(queued, true, 'the new account should reach the environment queue while cleanup is held');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 0);
app.heldDeletes.get(remoteProfiles[0].dirId)();
await waitForFixture(() => app.heldDeletes.has(remoteProfiles[1].dirId), 'the cleanup batch must continue to its second selected environment');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 0,
'completion of one historical deletion must not let a new authorization lease the next cleanup candidate');
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
app.heldDeletes.get(remoteProfiles[1].dirId)();
const released = await cleanup;
assert.equal(released.status, 200);
assert.equal(released.data.deleted, 2);
await waitForFixture(() => app.heldBrowsers.has(accounts[2].email), 'the new account should start after the complete cleanup batch finishes');
const opened = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId);
assert.equal(opened.length, 1);
assert.ok(!remoteProfiles.some((profile) => profile.dirId === opened[0]));
app.releaseAccount(accounts[2].email);
const job = await app.waitJob(started.id);
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 3);
assert.equal(app.remoteProfiles.length, 0);
assertNoSecrets(job);
assertNoSecrets(released.data);
});
for (const profileMode of ['pooled', 'dedicated']) test(`${profileMode} proxy failover closes the first attempt, reserves both proxies and persists the successful route`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true, profileCapacity: 3,
browserFailureSequences: { [children[0].email]: ['browser_oauth_proxy_connection_failed'] } });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode, poolSize: 3, useProxyPool: true,
profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200);
const started = await startTeamJob(app, [children[0]]);
await app.waitBrowser();
app.releaseAccount(children[0].email);
await waitForFixture(() => app.requests.filter((request) => request.path === '/browser/mdf').length === 2
&& app.heldBrowsers.has(children[0].email), 'the second proxy must receive a fresh browser attempt after closure');
for (const proxy of proxies.slice(0, 2)) {
const deletion = await app.request(`/api/proxy/entries/${proxy.id}`, { method: 'DELETE' });
assert.equal(deletion.status, 409, 'all proxies used by the in-flight authorization must remain reserved');
assert.equal(deletion.data.code || deletion.data.message, 'proxy_in_use');
}
app.releaseAccount(children[0].email);
const job = await app.waitJob(started.id);
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results[0].quotaChecked, true);
const updates = app.requests.filter((request) => request.path === '/browser/mdf').map((request) => JSON.parse(request.body));
assert.deepEqual(updates.map((request) => request.proxyInfo.proxyUserName), ['fixture-proxy-alpha', 'fixture-proxy-beta']);
assert.deepEqual(updates.map((request) => request.dirId), ['roxy_profile_a', 'roxy_profile_a']);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 2);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 2);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
assertConfirmedClosures(app.requests);
const contexts = app.requests.filter((request) => request.path === '/fixture/context-create').map((request) => JSON.parse(request.body));
assert.equal(contexts.length, profileMode === 'pooled' ? 2 : 0);
assert.equal(new Set(contexts.map((context) => context.contextId)).size, contexts.length);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, contexts.length);
assert.equal(app.browserPeak(), 1);
assert.equal(app.openedProfiles.size, 0);
const stored = (await app.saved()).state;
assert.equal(profileMode === 'pooled' ? stored.children.find((child) => child.id === 'child_alpha').roxyProxyId
: stored.settings.integrations.roxy.profiles.find((profile) => profile.childId === 'child_alpha').proxyId, proxies[1].id);
const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`;
const routed = app.requests.filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path));
assert.equal(routed.length, 2);
assert.ok(routed.every((request) => request.proxyAuth === expectedAuth), 'token exchange and quota must use the successful browser proxy');
const beforeQuota = app.requests.length;
const quota = await app.request('/api/children/child_alpha/team-quota', { method: 'POST',
body: { motherId: 'fixture_mother', workspaceId: app.workspaceId } });
assert.equal(quota.status, 200, JSON.stringify(quota.data));
assert.ok(app.requests.slice(beforeQuota).filter((request) => request.path === '/backend-api/wham/usage')
.every((request) => request.proxyAuth === expectedAuth));
assert.ok(!app.requests.slice(beforeQuota).some((request) => request.path.startsWith('/browser/')));
for (const proxy of proxies.slice(0, 2)) {
assert.equal((await app.request(`/api/proxy/entries/${proxy.id}`, { method: 'DELETE' })).status, 200,
'the authorization must release both proxy reservations when it completes');
}
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test('proxy failover tries at most three distinct proxies per account and lets the next account complete with its own budget', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, concurrentAccounts: children, profileCapacity: 3,
browserFailureSequences: { [children[0].email]: Array(4).fill('browser_oauth_proxy_connection_failed'),
[children[1].email]: ['browser_oauth_proxy_connection_failed'] } });
const proxies = await app.addProxies({ names: ['alpha', 'beta', 'gamma', 'delta'] });
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1, useProxyPool: true,
profiles: children.map((child, index) => ({ childId: child.id, dirId: index ? 'old_beta_profile' : 'roxy_profile_a', proxyId: proxies[0].id })) })).status, 200);
const job = await app.waitJob((await startTeamJob(app, children)).id);
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.completed, 2);
assert.equal(job.results.find((result) => result.id === children[0].id).code, 'browser_oauth_proxy_connection_failed');
assert.equal(job.results.find((result) => result.id === children[1].id).quotaChecked, true);
const contexts = app.requests.filter((request) => request.path === '/fixture/context-auth').map((request) => JSON.parse(request.body));
assert.deepEqual(contexts.filter((context) => context.childId === children[0].id).map((context) => context.username),
['fixture-proxy-alpha', 'fixture-proxy-beta', 'fixture-proxy-gamma']);
assert.deepEqual(contexts.filter((context) => context.childId === children[1].id).map((context) => context.username),
['fixture-proxy-alpha', 'fixture-proxy-beta']);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 5);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 5);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 5);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
assertConfirmedClosures(app.requests);
assert.equal(app.browserPeak(), 1);
assert.equal(app.openedProfiles.size, 0);
const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`;
const routed = app.requests.filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path));
assert.equal(routed.length, 2);
assert.ok(routed.every((request) => request.proxyAuth === expectedAuth));
assertNoSecrets(job);
assertNoSecrets(app.output());
});
for (const failure of [
{ code: 'browser_oauth_failed', status: 403 },
{ code: 'browser_oauth_verification_required', status: 403 },
{ code: 'browser_oauth_credentials_rejected', status: 401 },
]) test(`proxy failover does not change routes after ${failure.code} HTTP ${failure.status}`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 3,
browserFailureSequences: { [children[0].email]: [failure] } });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, useProxyPool: true,
profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 0);
assert.equal(job.results[0].code, failure.code);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/mdf').length, 1);
assert.equal((await app.saved()).state.children.find((child) => child.id === 'child_alpha').roxyProxyId, proxies[0].id);
assert.ok(!app.requests.some((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path)));
assertConfirmedClosures(app.requests);
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(job);
});
test('proxy failover never starts another attempt when the failed browser cannot be confirmed closed', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 3,
cleanupFailureFor: ['roxy_profile_a'], browserFailureSequences: { [children[0].email]: ['browser_oauth_proxy_connection_failed'] } });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, useProxyPool: true,
profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 0);
assert.equal(job.results[0].code, 'roxy_cleanup_failed');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/mdf').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 1);
assert.ok(!app.requests.some((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path)));
assert.equal(app.browserPeak(), 1);
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test('three concurrent proxy failovers reuse their existing pool slots with fresh sessions and never open a fourth environment', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 3);
const remoteProfiles = accounts.map((child, slot) => ({ dirId: `retry_profile_${slot}`,
windowName: `Fixture retry ${slot}`, windowRemark: `auto-team:pool:${slot}` }));
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true,
profileCapacity: 3, remoteProfiles, concurrentAccounts: accounts,
browserFailureSequences: Object.fromEntries(accounts.map((child) => [child.email, ['browser_oauth_proxy_connection_failed']])) });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3, useProxyPool: true,
profiles: accounts.map((child, slot) => ({ childId: child.id, dirId: `old_retry_${slot}`, proxyId: proxies[slot].id })),
poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200);
const started = await startTeamJob(app, accounts);
await waitForFixture(() => app.heldBrowsers.size === 3, 'all three initial attempts should hold a pool slot');
for (const child of accounts) app.releaseAccount(child.email);
await waitForFixture(() => app.requests.filter((request) => request.path === '/browser/open').length === 6
&& app.heldBrowsers.size === 3, 'all three proxy failovers should reuse the occupied worker slots');
assert.equal(app.browserPeak(), 3);
for (const child of accounts) app.releaseAccount(child.email);
const job = await app.waitJob(started.id);
assert.equal(job.succeeded, 3, JSON.stringify(job));
const contexts = app.requests.filter((request) => request.path === '/fixture/context-create').map((request) => JSON.parse(request.body));
assert.equal(contexts.length, 6);
assert.equal(new Set(contexts.map((context) => context.contextId)).size, 6);
assert.equal(new Set(contexts.map((context) => context.dirId)).size, 3);
for (const child of accounts) {
const attempts = contexts.filter((context) => context.childId === child.id);
assert.equal(attempts.length, 2);
assert.equal(attempts[0].dirId, attempts[1].dirId, 'proxy failover must preserve its current pool slot');
}
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 6);
assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 6);
assertConfirmedClosures(app.requests);
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test('Roxy concurrency partial API saves persist without changing secrets, profiles or protocol concurrency', async (t) => {
const app = await fixture(t);
assert.equal((await app.request('/api/state')).data.settings.integrations.roxy.concurrency, 1);
assert.equal((await app.patch(app.config)).status, 200);
const before = (await app.saved()).state;
const result = await app.patch({ concurrency: 3 });
assert.equal(result.status, 200);
assert.equal(result.data.settings.integrations.roxy.concurrency, 3);
const saved = (await app.saved()).state;
assert.deepEqual(saved.settings.integrations.roxy, { ...before.settings.integrations.roxy, concurrency: 3 });
assert.equal(saved.settings.concurrency, before.settings.concurrency);
for (const value of [0, 4, 1.5, '2', null]) {
const invalid = await app.patch({ concurrency: value });
assert.equal(invalid.status, 400);
assert.equal(invalid.data.code, 'roxy_concurrency_invalid');
assert.deepEqual((await app.saved()).state.settings, saved.settings);
}
});
for (const concurrency of [1, 3]) {
test(`same-Team batch starts ${concurrency} Roxy windows and fills each slot after the preceding account finishes`, { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 4);
const app = await fixture(t, { browserFlow: true, teamFlow: true, holdBrowser: true, concurrentAccounts: accounts });
await configureParallel(app, accounts, concurrency);
const job = await startTeamJob(app, accounts);
assert.equal(job.concurrency, concurrency);
await waitForFixture(() => app.heldBrowsers.size === concurrency, 'expected simultaneous same-Team browser authorizations');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, concurrency);
assert.equal((await app.patch({ concurrency: concurrency === 1 ? 3 : 1 })).status, 409);
app.releaseAccount(accounts[0].email);
await waitForFixture(() => app.requests.filter((request) => request.path === '/browser/open').length === concurrency + 1,
'the next account should start when one complete account frees its worker');
const opens = app.requests.filter((request) => request.path === '/browser/open');
const quota = app.requests.find((request) => request.path === '/backend-api/wham/usage');
assert.ok(quota && app.requests.indexOf(quota) < app.requests.indexOf(opens[concurrency]), 'the account worker includes token saving and quota, not only browser closure');
for (const child of accounts.slice(1)) {
await waitForFixture(() => app.heldBrowsers.has(child.email), `queued browser should start for ${child.id}`);
app.releaseAccount(child.email);
}
const finished = await app.waitJob(job.id);
assert.equal(finished.succeeded, 4, JSON.stringify(finished));
assert.equal(app.browserPeak(), concurrency);
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(finished);
});
}
test('independent automatic jobs and a direct Team login share the global three-browser limit', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren;
const app = await fixture(t, { browserFlow: true, teamFlow: true, holdBrowser: true, concurrentAccounts: accounts });
await configureParallel(app, accounts, 3);
const first = await startTeamJob(app, accounts.slice(0, 3));
await waitForFixture(() => app.heldBrowsers.size === 3, 'first job should occupy three browser slots');
const second = await startTeamJob(app, [accounts[3]]);
const manual = app.request(`/api/children/${accounts[4].id}/team-auth`, { method: 'POST', body: { motherId: 'fixture_mother' } });
await waitForFixture(() => app.requests.filter((request) => request.path.endsWith('/users')).length >= 4, 'second job should pass membership checks without the browser slot');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 3);
for (const child of accounts) {
await waitForFixture(() => app.heldBrowsers.has(child.email), 'all submitted browser operations should eventually run');
app.releaseAccount(child.email);
}
assert.equal((await app.waitJob(first.id)).succeeded, 3);
assert.equal((await app.waitJob(second.id)).succeeded, 1);
assert.equal((await manual).status, 200);
assert.equal(app.browserPeak(), 3);
});
test('a failed account closes its owned batch window and the next account completes', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 2);
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts, failBrowserFor: [accounts[0].email] });
await configureParallel(app, accounts, 1);
const job = await app.waitJob((await startTeamJob(app, accounts)).id);
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.needsAttention, 1);
assert.equal(app.browserPeak(), 1);
assert.equal(app.openedProfiles.size, 0);
});
for (const during of ['authorization', 'connection']) test(`failed window cleanup during ${during} blocks later opens until the exact owned window is confirmed closed`, { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 2);
const cleanupFailureFor = [`profile_${accounts[0].id}`];
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts, cleanupFailureFor,
connectFailureFor: during === 'connection' ? [accounts[0].id] : [] });
await configureParallel(app, accounts, 1);
const job = await app.waitJob((await startTeamJob(app, accounts)).id);
assert.equal(job.results.find((result) => result.id === accounts[1].id).code, 'roxy_cleanup_failed');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
cleanupFailureFor.length = 0;
app.openedProfiles.clear(); // User closed the failed owned window in the fixture Local API.
const retried = await app.waitJob((await startTeamJob(app, [accounts[1]])).id);
assert.equal(retried.succeeded, 1, JSON.stringify(retried));
});
test('removing a member while quota is pending does not resurrect its active membership', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 2);
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts, holdQuotaFor: [accounts[0].id] });
await configureParallel(app, accounts, 3);
const job = await startTeamJob(app, accounts);
await waitForFixture(() => app.heldQuotas.has(accounts[0].id), 'first account should reach the held quota request');
const removed = await app.request(`/api/children/${accounts[0].id}/kick`, { method: 'POST', body: {} });
assert.equal(removed.status, 200, JSON.stringify(removed.data));
app.heldQuotas.get(accounts[0].id)();
const finished = await app.waitJob(job.id);
assert.equal(finished.results.find((result) => result.id === accounts[0].id).code, 'team_membership_changed');
const child = (await app.saved()).state.children.find((item) => item.id === accounts[0].id);
assert.equal(child.team, null);
assert.equal(child.workspaceHistory.filter((entry) => entry.status === 'active').length, 0);
assert.equal(child.workspaceHistory.at(-1).status, 'kicked');
});
test('OAuth timeout during a pending browser open retains its concurrency slot until late cleanup finishes', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 2);
const dirId = `profile_${accounts[0].id}`;
const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts,
holdOpenFor: [dirId], timeoutBeforeSessionFor: [accounts[0].email] });
await configureParallel(app, accounts, 1);
const job = await startTeamJob(app, accounts);
await waitForFixture(() => app.heldOpens.has(dirId), 'first owned window should be pending startup after OAuth timed out');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal((await app.request(`/api/children/auto-json/${job.id}`)).data.job.status, 'running');
app.heldOpens.get(dirId)();
const finished = await app.waitJob(job.id);
assert.equal(finished.succeeded, 1, JSON.stringify(finished));
assert.equal(finished.results.find((result) => result.id === accounts[0].id).code, 'browser_oauth_timeout');
assert.equal(app.browserPeak(), 1);
assert.equal(app.openedProfiles.size, 0);
});
test('removing a member while its Team OAuth is running cannot recreate membership or save its token', { timeout: 20000 }, async (t) => {
const accounts = parallelChildren.slice(0, 2);
const app = await fixture(t, { browserFlow: true, teamFlow: true, holdBrowser: true, concurrentAccounts: accounts });
await configureParallel(app, accounts, 3);
const job = await startTeamJob(app, accounts);
await waitForFixture(() => app.heldBrowsers.size === 2, 'both member logins should be running');
const removed = await app.request(`/api/children/${accounts[0].id}/kick`, { method: 'POST', body: {} });
assert.equal(removed.status, 200, JSON.stringify(removed.data));
accounts.forEach((child) => app.releaseAccount(child.email));
const finished = await app.waitJob(job.id);
assert.equal(finished.results.find((result) => result.id === accounts[0].id).code, 'team_membership_changed');
assert.equal(finished.succeeded, 1);
const child = (await app.saved()).state.children.find((item) => item.id === accounts[0].id);
assert.equal(child.team, null);
assert.equal(child.workspaceHistory.filter((entry) => entry.status === 'active').length, 0);
assert.equal(child.workspaceHistory.at(-1).status, 'kicked');
assert.equal(child.workspaceTokens?.[app.workspaceId], undefined);
});
test('Roxy config defaults are public and API routes require dashboard authentication', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const current = await app.request('/api/state');
assert.equal(current.status, 200);
const config = current.data.settings.integrations.roxy;
assert.equal(config.enabled, false);
assert.equal(config.apiKeySet, false);
assert.equal(config.baseUrl, 'http://127.0.0.1:50000');
assert.equal(config.headless, false);
assert.equal(config.useProxyPool, false);
assert.deepEqual(config.profiles, []);
assert.equal(Object.hasOwn(config, 'apiKey'), false);
for (const route of ['/api/integrations', '/api/integrations/roxy/check']) {
const response = await app.request(route, { method: route.endsWith('/check') ? 'POST' : 'PATCH',
authenticated: false, body: { roxy: app.config } });
assert.equal(response.status, 401);
assertNoSecrets(response.data);
}
assert.deepEqual(app.requests, []);
});
test('local first-hop proxy settings persist and invalid patches leave every previous field unchanged', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const initial = (await app.request('/api/state')).data.settings.proxy;
assert.equal(initial.localProxyEnabled, false);
assert.equal(initial.localProxyUrl, 'http://127.0.0.1:7897');
const configured = await app.request('/api/proxy', { method: 'PATCH', body: {
enabled: true, localProxyEnabled: true, localProxyUrl: 'socks5h://localhost:7897',
} });
assert.equal(configured.status, 200);
assert.equal(configured.data.proxy.localProxyEnabled, true);
assert.equal(configured.data.proxy.localProxyUrl, 'socks5h://127.0.0.1:7897');
const before = (await app.saved()).state.settings.proxy;
for (const localProxyUrl of ['http://remote.example.test:7897', 'http://user:secret@127.0.0.1:7897',
'http://127.0.0.1:7897/private', 'http://127.0.0.1:7897?secret=yes', 'http://127.0.0.1:7897#fragment',
'https://127.0.0.1:7897', 'socks4://127.0.0.1:7897', '', null]) {
const invalid = await app.request('/api/proxy', { method: 'PATCH', body: { enabled: false, maxRetries: 0, localProxyUrl } });
assert.equal(invalid.status, 400, String(localProxyUrl));
assert.equal(invalid.data.code, 'local_proxy_url_invalid');
assert.deepEqual((await app.saved()).state.settings.proxy, before);
}
const invalidSwitch = await app.request('/api/proxy', { method: 'PATCH', body: { enabled: false, localProxyEnabled: 'true' } });
assert.equal(invalidSwitch.status, 400);
assert.deepEqual((await app.saved()).state.settings.proxy, before);
assert.deepEqual(app.requests, []);
});
test('Roxy retains an account-specific chained relay with a headed failed window and closes it only after that window closes', async () => {
const config = { enabled: true, apiKey: 'fixture-key', workspaceId: 'fixture_workspace', headless: false,
useProxyPool: true, profiles: [{ childId: 'chain_child', dirId: 'chain_profile', proxyId: 'upstream_fixed' }] };
const proxyEntry = { id: 'upstream_fixed', url: 'http://upstream-user:private-upstream-password@upstream.example.test:3000' };
let opened = false;
const relays = [];
const updates = [];
const api = {
async connectionInfo() { return opened ? [{ dirId: 'chain_profile' }] : []; },
async updateProxy(_workspace, _dirId, entry) { updates.push(entry); },
async open() { opened = true; return { ws: 'ws://127.0.0.1:50001/devtools/browser/fixture' }; },
async close() { opened = false; },
};
const dependencies = { client: api, connect: async () => ({
newPage: async () => ({ close: async () => {}, authenticate: async () => {} }), disconnect: async () => {},
}), createRelay: async (options) => {
assert.deepEqual(options, { localProxyUrl: 'http://127.0.0.1:7897', upstreamProxyUrl: proxyEntry.url, timeoutMs: 30000 });
const relay = { url: `http://chain:private-relay-${relays.length}@127.0.0.1:${51000 + relays.length}`,
closed: false, async close() { this.closed = true; } };
relays.push(relay);
return relay;
} };
const input = { config, childId: 'chain_child', proxyEntry, localProxyUrl: 'http://127.0.0.1:7897' };
const session = await openRoxyBrowserSession(input, dependencies);
assert.equal(updates[0].id, 'upstream_fixed');
assert.equal(updates[0].url, relays[0].url);
assert.equal(config.profiles[0].proxyId, 'upstream_fixed');
await session.close({ keepOpen: true });
assert.equal(relays[0].closed, false);
await assert.rejects(() => openRoxyBrowserSession(input, dependencies), { code: 'roxy_profile_busy' });
assert.equal(relays.length, 1);
assert.equal(relays[0].closed, false);
opened = false;
const retry = await openRoxyBrowserSession(input, dependencies);
assert.equal(relays[0].closed, true);
assert.equal(relays.length, 2);
assert.equal(relays[1].closed, false);
await retry.close();
assert.equal(relays[1].closed, true);
});
test('Roxy chain setup failure does not fall back to a direct proxy and a failed launch cleans up only its own relay', async () => {
const config = { enabled: true, apiKey: 'fixture-key', workspaceId: 'fixture_workspace',
useProxyPool: true, profiles: [{ childId: 'chain_failure_child', dirId: 'chain_failure_profile', proxyId: 'upstream_fixed' }] };
const input = { config, childId: 'chain_failure_child', localProxyUrl: 'http://127.0.0.1:7897',
proxyEntry: { id: 'upstream_fixed', url: 'http://upstream.example.test:3000' } };
let updates = 0;
let closed = 0;
const api = { connectionInfo: async () => [], updateProxy: async () => { updates += 1; throw new Error('private-update-error'); } };
await assert.rejects(() => openRoxyBrowserSession(input, { client: api, createRelay: async () => { throw new Error('private-create-error'); } }),
(error) => error.code === 'roxy_proxy_chain_failed' && !error.message.includes('private'));
assert.equal(updates, 0);
await assert.rejects(() => openRoxyBrowserSession(input, { client: api, createRelay: async () => ({
url: 'http://chain:private-relay@127.0.0.1:51000', close: async () => { closed += 1; },
}) }), { code: 'roxy_api_failed' });
assert.equal(updates, 1);
assert.equal(closed, 1);
});
test('Roxy key is encrypted at rest, redacted publicly, and preserved when omitted on later saves', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const patched = await app.patch(app.config);
assert.equal(patched.status, 200, JSON.stringify(patched.data));
assertNoSecrets(patched.data);
const config = patched.data.settings.integrations.roxy;
assert.deepEqual(config, { enabled: true, baseUrl: app.config.baseUrl, apiKeySet: true,
workspaceId: app.config.workspaceId, headless: false, useProxyPool: false, concurrency: 1, profiles: app.config.profiles });
const first = await app.saved();
assert.equal(first.envelope.encrypted, true);
assertNoSecrets(first.raw);
assert.equal(first.state.settings.integrations.roxy.apiKey, secret);
const omitted = await app.patch({ enabled: false, workspaceId: 'roxy_workspace_b' });
assert.equal(omitted.status, 200);
assert.equal(omitted.data.settings.integrations.roxy.apiKeySet, true);
assert.equal((await app.saved()).state.settings.integrations.roxy.apiKey, secret);
const clear = await app.patch({ apiKey: '' });
assert.equal(clear.status, 200);
assert.equal(clear.data.settings.integrations.roxy.apiKeySet, false);
assert.equal((await app.saved()).state.settings.integrations.roxy.apiKey, '');
assert.deepEqual(app.requests, []);
});
test('Roxy rejects non-loopback API origins and unsafe URL forms without network calls', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
assert.equal((await app.patch(app.config)).status, 200);
for (const baseUrl of [
'http://remote.example.test:50000', 'https://127.0.0.1:50000', 'file:///etc/passwd',
'http://127.0.0.1:50000/api', 'http://user:password@127.0.0.1:50000',
'http://127.0.0.1:50000?token=secret', 'http://127.0.0.1:50000#fragment',
]) {
const result = await app.patch({ baseUrl });
assert.equal(result.status, 400, baseUrl);
assert.equal(result.data.code, 'roxy_api_base_invalid');
assertNoSecrets(result.data);
}
assert.equal((await app.saved()).state.settings.integrations.roxy.baseUrl, app.config.baseUrl);
assert.deepEqual(app.requests, []);
});
test('Roxy profile mappings reject duplicate accounts, shared windows, and unknown child records', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
assert.equal((await app.patch(app.config)).status, 200);
for (const profiles of [
[{ childId: 'child_alpha', dirId: 'roxy_profile_a' }, { childId: 'child_alpha', dirId: 'roxy_profile_b' }],
[{ childId: 'child_alpha', dirId: 'roxy_profile_a' }, { childId: 'child_beta', dirId: 'roxy_profile_a' }],
[{ childId: 'child_missing', dirId: 'roxy_profile_a' }],
]) {
const result = await app.patch({ profiles });
assert.equal(result.status, 400, JSON.stringify(result.data));
assert.match(result.data.code, /^roxy_/);
assertNoSecrets(result.data);
}
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, app.config.profiles);
assert.deepEqual(app.requests, []);
});
test('Roxy launch mode and pool bindings persist while automatic assignment stays stable across saves', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const proxies = await app.addProxies();
const configured = await app.patch({ ...app.config, headless: true, useProxyPool: true, profiles: [
...app.config.profiles, { childId: 'child_beta', dirId: 'roxy_profile_b' },
] });
assert.equal(configured.status, 200, JSON.stringify(configured.data));
const roxy = configured.data.settings.integrations.roxy;
assert.equal(roxy.headless, true);
assert.equal(roxy.useProxyPool, true);
assert.deepEqual(roxy.profiles.map((profile) => profile.proxyId), proxies.slice(0, 2).map((proxy) => proxy.id));
const resaved = await app.patch({ headless: false });
assert.equal(resaved.status, 200);
assert.equal(resaved.data.settings.integrations.roxy.headless, false);
assert.deepEqual(resaved.data.settings.integrations.roxy.profiles, roxy.profiles);
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, roxy.profiles);
assertNoSecrets(configured.data);
assert.deepEqual(app.requests, []);
});
test('Roxy pool configuration rejects disabled pools and unknown explicit proxies atomically', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
await app.addProxies({ enabled: false });
assert.equal((await app.patch(app.config)).status, 200);
const original = (await app.saved()).state.settings.integrations;
const disabled = await app.patch({ useProxyPool: true, headless: true });
assert.equal(disabled.status, 400, JSON.stringify(disabled.data));
assert.equal(disabled.data.code, 'roxy_proxy_pool_disabled');
assert.deepEqual((await app.saved()).state.settings.integrations, original);
assert.equal((await app.request('/api/proxy', { method: 'PATCH', body: { enabled: true } })).status, 200);
const missing = await app.patch({ useProxyPool: true, profiles: [
{ ...app.config.profiles[0], proxyId: 'missing_proxy' },
] }, { mailbox: { enabled: true } });
assert.equal(missing.status, 400, JSON.stringify(missing.data));
assert.equal(missing.data.code, 'roxy_proxy_missing');
assert.deepEqual((await app.saved()).state.settings.integrations, original);
assertNoSecrets(disabled.data);
assertNoSecrets(missing.data);
assert.deepEqual(app.requests, []);
});
test('bulk proxy deletion requires authenticated nonempty IDs and rejects unknown IDs without partial deletion', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const proxies = await app.addProxies();
const original = (await app.saved()).state.settings.proxy;
const anonymous = await app.request('/api/proxy/entries', { method: 'DELETE', authenticated: false, body: { ids: [proxies[0].id] } });
assert.equal(anonymous.status, 401);
for (const ids of [undefined, [], 'all', null, [null], [123], ['']]) {
const invalid = await app.request('/api/proxy/entries', { method: 'DELETE', body: { ids } });
assert.equal(invalid.status, 400, JSON.stringify(invalid.data));
assert.equal(invalid.data.code || invalid.data.message, 'proxy_ids_required');
assert.deepEqual((await app.saved()).state.settings.proxy, original);
}
const missing = await app.request('/api/proxy/entries', { method: 'DELETE', body: { ids: [proxies[0].id, 'missing_proxy'] } });
assert.equal(missing.status, 404, JSON.stringify(missing.data));
assert.equal(missing.data.code || missing.data.message, 'proxy_not_found');
assert.deepEqual((await app.saved()).state.settings.proxy, original);
assertNoSecrets(missing.data);
assert.deepEqual(app.requests, []);
});
test('bulk proxy deletion removes only selected entries and clears their Roxy bindings without reassigning others', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, useProxyPool: true, profiles: [
{ ...app.config.profiles[0], proxyId: proxies[0].id },
{ childId: 'child_beta', dirId: 'roxy_profile_b', proxyId: proxies[1].id },
] })).status, 200);
const removed = await app.request('/api/proxy/entries', { method: 'DELETE', body: { ids: [proxies[0].id, proxies[2].id] } });
assert.equal(removed.status, 200, JSON.stringify(removed.data));
assert.deepEqual(removed.data.proxy.entries.map((proxy) => proxy.id), [proxies[1].id]);
const saved = (await app.saved()).state;
assert.equal(saved.settings.proxy.enabled, true);
assert.deepEqual(saved.settings.integrations.roxy.profiles.map((profile) => profile.proxyId), ['', proxies[1].id]);
const last = await app.request('/api/proxy/entries', { method: 'DELETE', body: { ids: [proxies[1].id] } });
assert.equal(last.status, 200, JSON.stringify(last.data));
const empty = (await app.saved()).state.settings;
assert.equal(empty.proxy.enabled, false);
assert.equal(empty.integrations.roxy.useProxyPool, true);
assert.deepEqual(empty.integrations.roxy.profiles.map((profile) => profile.proxyId), ['', '']);
assertNoSecrets(removed.data);
assertNoSecrets(last.data);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 0);
assert.equal(job.results[0].code, 'roxy_proxy_pool_disabled');
const disabledRoxy = await app.patch({ enabled: false });
assert.equal(disabledRoxy.status, 200, JSON.stringify(disabledRoxy.data));
assert.equal(disabledRoxy.data.settings.integrations.roxy.enabled, false);
assert.equal(disabledRoxy.data.settings.integrations.roxy.useProxyPool, true);
assert.equal((await app.saved()).state.settings.integrations.roxy.enabled, false);
assert.deepEqual(app.requests, []);
});
test('single proxy deletion also clears the account binding so it cannot reference a removed entry', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, useProxyPool: true, profiles: [
{ ...app.config.profiles[0], proxyId: proxies[0].id },
] })).status, 200);
const removed = await app.request(`/api/proxy/entries/${proxies[0].id}`, { method: 'DELETE' });
assert.equal(removed.status, 200, JSON.stringify(removed.data));
assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].proxyId, '');
assert.deepEqual(removed.data.proxy.entries.map((proxy) => proxy.id), proxies.slice(1).map((proxy) => proxy.id));
assertNoSecrets(removed.data);
});
test('active Roxy authorization pins its proxy and rejects deletion or profile changes until it completes', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, holdBrowser: true });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, headless: true, useProxyPool: true,
profiles: [{ ...app.config.profiles[0], proxyId: proxies[1].id }] })).status, 200);
const started = await app.request('/api/children/auto-json', { method: 'POST', body: { ids: ['child_alpha'] } });
assert.equal(started.status, 202, JSON.stringify(started.data));
await app.waitBrowser();
const before = (await app.saved()).state.settings;
for (const [route, body] of [
['/api/proxy/entries', { ids: [proxies[0].id, proxies[1].id] }],
[`/api/proxy/entries/${proxies[1].id}`, undefined],
]) {
const rejected = await app.request(route, { method: 'DELETE', body });
assert.equal(rejected.status, 409, JSON.stringify(rejected.data));
assert.equal(rejected.data.code || rejected.data.message, 'proxy_in_use');
}
const changed = await app.patch({ headless: false });
assert.equal(changed.status, 409, JSON.stringify(changed.data));
assert.equal(changed.data.code, 'roxy_profile_busy');
assert.deepEqual((await app.saved()).state.settings, before);
app.releaseBrowser();
const job = await app.waitJob(started.data.job.id);
assert.equal(job.succeeded, 1, JSON.stringify(job));
const launch = app.requests.find((request) => request.path === '/browser/open');
assert.deepEqual(JSON.parse(launch.body), { workspaceId: app.config.workspaceId,
dirId: 'roxy_profile_a', forceOpen: false, headless: true });
const proxyUpdate = app.requests.find((request) => request.path === '/browser/mdf');
assert.ok(proxyUpdate, 'the bound proxy must be installed in the Roxy profile before opening');
assert.ok(app.requests.indexOf(proxyUpdate) < app.requests.indexOf(launch));
assert.deepEqual(JSON.parse(proxyUpdate.body), { workspaceId: app.config.workspaceId, dirId: 'roxy_profile_a', proxyInfo: {
proxyMethod: 'custom', proxyCategory: 'HTTP', ipType: 'IPV4', host: '127.0.0.1',
port: String(proxies[1].port), proxyUserName: 'fixture-proxy-beta', proxyPassword: 'private-proxy-password',
} });
const proxied = app.requests.filter((request) => request.proxyAuth);
assert.ok(proxied.some((request) => request.path === '/fixture/token'), 'OAuth exchange must use the assigned browser proxy');
const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`;
assert.ok(proxied.every((request) => request.proxyAuth === expectedAuth), 'round-robin must not move requests away from the account-bound proxy');
const deleted = await app.request('/api/proxy/entries', { method: 'DELETE', body: { ids: [proxies[1].id] } });
assert.equal(deleted.status, 200, JSON.stringify(deleted.data));
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test('an invalid Roxy config does not partially mutate mailbox or Sub2API settings in the same patch', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
assert.equal((await app.patch(app.config)).status, 200);
const before = (await app.saved()).state.settings.integrations;
const invalid = await app.patch({ enabled: true, apiKey: '' }, {
mailbox: { enabled: true, serviceType: 'fixture-changed', endpoint: 'http://changed.example.test', apiKey: 'private-changed-key' },
sub2api: { name: 'Changed Sub2API', groupId: 99 },
});
assert.equal(invalid.status, 400);
assert.equal(invalid.data.code, 'roxy_api_key_missing');
assert.deepEqual((await app.saved()).state.settings.integrations, before);
const state = (await app.request('/api/state')).data;
assert.equal(state.settings.integrations.mailbox.enabled, before.mailbox.enabled);
assert.equal(state.settings.integrations.sub2apis[0].name, before.sub2apis[0].name);
assertNoSecrets(invalid.data);
});
test('Roxy check uses the official token header and bounded workspace/profile pagination without saving the draft', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const before = (await app.saved()).state.settings.integrations.roxy;
const draftKey = 'private-roxy-draft-key';
const result = await app.request('/api/integrations/roxy/check', { method: 'POST', body: {
roxy: { baseUrl: app.config.baseUrl, apiKey: draftKey, workspaceId: app.config.workspaceId },
} });
assert.equal(result.status, 200, JSON.stringify(result.data));
assert.equal(result.data.ok, true);
assert.ok(result.data.workspaces.some((row) => row.id === 'roxy_workspace_a'));
assert.ok(result.data.profiles.some((row) => row.dirId === 'roxy_profile_a'));
assert.deepEqual(app.requests.map((request) => request.path), ['/health', '/browser/workspace', '/browser/list_v3']);
for (const request of app.requests) {
assert.equal(request.method, 'GET');
assert.equal(request.token, draftKey);
assert.equal(request.body, '');
assert.equal(Object.hasOwn(request.query, 'token'), false);
if (request.path !== '/health') {
assert.equal(request.query.page_index, '1');
assert.ok(Number(request.query.page_size) >= 1 && Number(request.query.page_size) <= 100);
}
}
assert.equal(app.requests[2].query.workspaceId, app.config.workspaceId);
assert.deepEqual((await app.saved()).state.settings.integrations.roxy, before);
assertNoSecrets(result.data);
assertNoSecrets(app.output());
});
test('Roxy check may use the saved secret, but malformed drafts never call the local API', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
assert.equal((await app.patch(app.config)).status, 200);
const result = await app.request('/api/integrations/roxy/check', { method: 'POST', body: { roxy: { workspaceId: app.config.workspaceId } } });
assert.equal(result.status, 200, JSON.stringify(result.data));
assert.ok(app.requests.every((request) => request.token === secret));
const count = app.requests.length;
const invalid = await app.request('/api/integrations/roxy/check', { method: 'POST', body: { roxy: { baseUrl: 'http://remote.example.test:50000' } } });
assert.equal(invalid.status, 400);
assert.equal(invalid.data.code, 'roxy_api_base_invalid');
assert.equal(app.requests.length, count);
assertNoSecrets(result.data);
assertNoSecrets(invalid.data);
});
test('Roxy connection checks can list teams before an enabled draft has selected its workspace', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
const result = await app.request('/api/integrations/roxy/check', { method: 'POST', body: {
roxy: { enabled: true, baseUrl: app.config.baseUrl, apiKey: secret, workspaceId: '' },
} });
assert.equal(result.status, 200, JSON.stringify(result.data));
assert.equal(result.data.ok, true);
assert.deepEqual(result.data.workspaces, [{ id: 'roxy_workspace_a', name: 'Fixture team' }]);
assert.deepEqual(result.data.profiles, []);
assert.deepEqual(app.requests.map((request) => request.path), ['/health', '/browser/workspace']);
assert.equal((await app.request('/api/state')).data.settings.integrations.roxy.enabled, false);
assertNoSecrets(result.data);
});
test('Roxy routes reject null and array config bodies without persisting or accessing its API', { timeout: 20000 }, async (t) => {
const app = await fixture(t);
assert.equal((await app.patch(app.config)).status, 200);
const original = (await app.saved()).state.settings.integrations;
for (const roxy of [null, []]) {
for (const [route, method] of [['/api/integrations', 'PATCH'], ['/api/integrations/roxy/check', 'POST']]) {
const result = await app.request(route, { method, body: { roxy } });
assert.equal(result.status, 400, JSON.stringify(result.data));
assert.equal(result.data.code, 'roxy_config_invalid');
assertNoSecrets(result.data);
}
}
assert.deepEqual((await app.saved()).state.settings.integrations, original);
assert.deepEqual(app.requests, []);
});
for (const [status, code] of [[401, 'roxy_api_auth_failed'], [403, 'roxy_api_auth_failed'], [429, 'roxy_api_rate_limited'], [500, 'roxy_api_failed']]) {
test(`Roxy check maps upstream HTTP ${status} to a safe error without exposing raw response text`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { apiFailure: { status } });
const result = await app.request('/api/integrations/roxy/check', { method: 'POST', body: { roxy: { ...app.config, enabled: false } } });
assert.ok(result.status >= 400, JSON.stringify(result.data));
assert.equal(result.data.code, code);
assertNoSecrets(result.data);
assertNoSecrets(app.output());
assert.equal(app.requests.length, 1);
assert.equal((await app.request('/api/state')).data.settings.integrations.roxy.apiKeySet, false);
});
}
test('Roxy check does not expose the vendor error message from an HTTP 200 application-level failure', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { apiFailure: { status: 200, code: -1 } });
const result = await app.request('/api/integrations/roxy/check', { method: 'POST', body: { roxy: { ...app.config, enabled: false } } });
assert.equal(result.status, 502);
assert.equal(result.data.code, 'roxy_api_failed');
assertNoSecrets(result.data);
assertNoSecrets(app.output());
assert.equal(app.requests.length, 1);
});
test('automatic Free JSON uses the bound Roxy profile and exchanges the original callback without launching Chrome', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true });
assert.equal((await app.patch(app.config)).status, 200);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 1, JSON.stringify(job));
const browserOptions = JSON.parse(app.requests.find((request) => request.path === '/fixture/browser-options').body);
assert.deepEqual(browserOptions, { email: children[0].email, workspaceId: '', workspaceMode: 'free', stateMatches: true });
const opened = app.requests.filter((request) => request.path === '/browser/open');
assert.equal(opened.length, 1);
assert.deepEqual(JSON.parse(opened[0].body), { workspaceId: 'roxy_workspace_a', dirId: 'roxy_profile_a', forceOpen: false, headless: false });
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1);
assert.equal((await app.saved()).state.children[0].refreshToken, 'private-fixture-rt-child_alpha');
assertNoSecrets(job);
});
test('automatic Team JSON selects the target workspace through Roxy and saves only verified Team credentials and quota', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true });
assert.equal((await app.patch(app.config)).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results[0].authScope, 'team');
assert.equal(job.results[0].quotaChecked, true);
assert.equal(job.results[0].quota5h, 75);
assert.equal(job.results[0].quota7d, 90);
const browserOptions = JSON.parse(app.requests.find((request) => request.path === '/fixture/browser-options').body);
assert.deepEqual(browserOptions, { email: children[0].email, workspaceId: app.workspaceId, workspaceMode: 'team', stateMatches: true });
const saved = (await app.saved()).state.children[0];
assert.equal(saved.workspaceTokens[app.workspaceId].refreshToken, 'private-fixture-rt-child_alpha');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1);
assertNoSecrets(job);
});
test('a deleted dedicated binding recovers the existing account environment and retains its fixed proxy', { timeout: 20000 }, async (t) => {
const dirId = 'recovered_profile_alpha';
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 1,
remoteProfiles: [{ dirId, windowName: 'AutoTeam alpha', windowRemark: 'auto-team:child:child_alpha' }] });
const proxies = await app.addProxies();
const expectedProxy = proxies[1];
assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', useProxyPool: true,
profiles: [{ childId: 'child_alpha', dirId: 'deleted_dedicated_profile', proxyId: expectedProxy.id }] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results[0].quotaChecked, true);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0);
const saved = (await app.saved()).state;
assert.deepEqual(saved.settings.integrations.roxy.profiles, [{ childId: 'child_alpha', dirId, proxyId: expectedProxy.id }]);
assert.deepEqual(app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId), [dirId]);
const updated = app.requests.filter((request) => request.path === '/browser/mdf').map((request) => JSON.parse(request.body));
assert.equal(updated.length, 1);
assert.equal(updated[0].dirId, dirId);
assert.equal(updated[0].proxyInfo.proxyUserName, 'fixture-proxy-beta');
assert.equal(saved.children.find((child) => child.id === 'child_alpha').workspaceTokens[app.workspaceId].accountId, app.workspaceId);
const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`;
const routed = app.requests.filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path));
assert.equal(routed.length, 2);
assert.ok(routed.every((request) => request.proxyAuth === expectedAuth));
assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1);
assertConfirmedClosures(app.requests);
assert.equal(app.openedProfiles.size, 0);
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test('an unmapped account creates its own Roxy profile without using the previous account window', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, accountId: 'child_beta' });
assert.equal((await app.patch(app.config)).status, 200);
const job = await app.runJob({ ids: ['child_beta'] });
assert.equal(job.succeeded, 1, JSON.stringify(job));
const created = app.requests.filter((request) => request.path === '/browser/create');
assert.equal(created.length, 1);
assert.equal(JSON.parse(created[0].body).windowRemark, 'auto-team:child:child_beta');
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, [
...app.config.profiles, { childId: 'child_beta', dirId: 'created_profile_1' },
]);
assert.equal(JSON.parse(app.requests.find((request) => request.path === '/browser/open').body).dirId, 'created_profile_1');
const repeated = await app.runJob({ ids: ['child_beta'] });
assert.equal(repeated.succeeded, 1, JSON.stringify(repeated));
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assertNoSecrets(job);
});
test('a failed browser login retains its automatically created profile for the next attempt', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, browserVerification: true });
assert.equal((await app.patch({ ...app.config, profiles: [] })).status, 200);
const initial = await app.runJob({ ids: ['child_alpha'] });
assert.equal(initial.results[0].code, 'browser_verification_required');
const savedProfiles = (await app.saved()).state.settings.integrations.roxy.profiles;
assert.deepEqual(savedProfiles, [{ childId: 'child_alpha', dirId: 'created_profile_1' }]);
const retried = await app.runJob({ ids: ['child_alpha'] });
assert.equal(retried.results[0].code, 'browser_verification_required');
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, savedProfiles);
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assertNoSecrets(retried);
});
test('an automatic Team login provisions and persists one profile before fixing its proxy and exchanging tokens', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true });
const proxies = await app.addProxies();
assert.equal((await app.patch({ ...app.config, useProxyPool: true, profiles: [] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results[0].quotaChecked, true);
const profiles = (await app.saved()).state.settings.integrations.roxy.profiles;
assert.deepEqual(profiles, [{ childId: 'child_alpha', dirId: 'created_profile_1', proxyId: proxies[0].id }]);
const routes = app.requests.map((request) => request.path);
assert.equal(routes.filter((route) => route === '/browser/create').length, 1);
assert.ok(routes.indexOf('/browser/create') < routes.indexOf('/browser/mdf'));
assert.ok(routes.indexOf('/browser/mdf') < routes.indexOf('/browser/open'));
const expected = `Basic ${Buffer.from('fixture-proxy-alpha:private-proxy-password').toString('base64')}`;
assert.ok(app.requests.some((request) => request.path === '/fixture/token' && request.proxyAuth === expected));
assertNoSecrets(job);
});
test('automatic Team authorization passes the same first hop and fixed upstream to Roxy and backend relay consumers', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, chainFlow: true });
const proxies = await app.addProxies();
assert.equal((await app.request('/api/proxy', { method: 'PATCH', body: {
localProxyEnabled: true, localProxyUrl: 'http://127.0.0.1:7897',
} })).status, 200);
assert.equal((await app.patch({ ...app.config, useProxyPool: true, profiles: [
{ ...app.config.profiles[0], proxyId: proxies[0].id },
] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' });
assert.equal(job.succeeded, 1, JSON.stringify(job));
const calls = app.requests.filter((request) => request.path === '/fixture/chain-create').map((request) => JSON.parse(request.body));
assert.ok(calls.length >= 2, 'browser launch and backend token exchange each request a chained relay');
assert.ok(calls.every((call) => call.localProxyUrl === 'http://127.0.0.1:7897'));
const browserUpdate = app.requests.find((request) => request.path === '/browser/mdf');
const browserProxy = JSON.parse(browserUpdate.body).proxyInfo;
assert.equal(browserProxy.host, '127.0.0.1');
assert.equal(browserProxy.proxyUserName, 'fixture-chain');
const token = app.requests.find((request) => request.path === '/fixture/token');
const relayFor = (request) => JSON.parse(app.requests.slice(0, app.requests.indexOf(request)).findLast((item) => item.path === '/fixture/chain-create').body);
assert.equal(relayFor(browserUpdate).upstreamProxyUrl, relayFor(token).upstreamProxyUrl);
assert.ok(relayFor(token).upstreamProxyUrl.includes('fixture-proxy-alpha:'));
assert.equal(token.proxyAuth, `Basic ${Buffer.from('fixture-chain:private-relay').toString('base64')}`);
assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].proxyId, proxies[0].id);
assert.equal(app.requests.filter((request) => request.path === '/fixture/chain-close').length, calls.length);
assertNoSecrets(job);
});
test('concurrent Free and Team tasks for one account persist only one independent Roxy profile', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: true });
assert.equal((await app.patch({ ...app.config, profiles: [] })).status, 200);
const started = await Promise.all([
app.request('/api/children/auto-json', { method: 'POST', body: { ids: ['child_alpha'] } }),
app.request('/api/children/auto-json', { method: 'POST', body: { ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' } }),
]);
for (const item of started) assert.equal(item.status, 202, JSON.stringify(item.data));
const jobs = await Promise.all(started.map((item) => app.waitJob(item.data.job.id)));
for (const job of jobs) assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 1);
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, [{ childId: 'child_alpha', dirId: 'created_profile_1' }]);
assertNoSecrets(jobs);
});
test('profile creation permission errors are reported safely without opening or falsely binding a window', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, createFailure: { code: -1, msg: `订阅权限已过期 ${secret}` } });
assert.equal((await app.patch({ ...app.config, profiles: [] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.results[0].code, 'roxy_entitlement_unavailable');
assert.equal(job.succeeded, 0);
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, []);
assert.ok(!app.requests.some((request) => ['/browser/open', '/fixture/token'].includes(request.path)));
assertNoSecrets(job);
});
test('missing credentials or a disabled proxy pool cannot consume a new Roxy environment', { timeout: 20000 }, async (t) => {
for (const missingCredentials of [true, false]) {
const app = await fixture(t, { browserFlow: true, priorFields: missingCredentials ? { password: '' } : {} });
if (!missingCredentials) await app.addProxies();
assert.equal((await app.patch({ ...app.config, useProxyPool: !missingCredentials, profiles: [] })).status, 200);
if (!missingCredentials) assert.equal((await app.request('/api/proxy', { method: 'PATCH', body: { enabled: false } })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 0);
assert.equal(job.results[0].code, missingCredentials ? 'credentials_required' : 'roxy_proxy_pool_disabled');
assert.ok(!app.requests.some((request) => request.path.startsWith('/browser/')));
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, []);
assertNoSecrets(job);
}
});
for (const mode of ['token', 'refresh', 'callback']) {
test(`an unmapped account with a usable ${mode} completes without creating a Roxy profile`, { timeout: 20000 }, async (t) => {
const token = oauthToken(children[0]);
const priorFields = mode === 'callback' ? { authSession: {
state: 'private-old-state', codeVerifier: 'private-old-verifier',
callbackReceivedAt: new Date().toISOString(),
callbackUrl: 'http://localhost:1455/auth/callback?code=private-callback-code&state=private-old-state',
} } : { accessToken: mode === 'token' ? token.access_token : '', refreshToken: token.refresh_token };
const app = await fixture(t, { browserFlow: true, priorFields });
assert.equal((await app.patch({ ...app.config, profiles: [] })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.ok(!app.requests.some((request) => request.path.startsWith('/browser/')));
assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, []);
assertNoSecrets(job);
});
}
test('an occupied Roxy profile stops the task without closing or force-opening the user window', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, occupiedProfile: true });
assert.equal((await app.patch(app.config)).status, 200);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 0);
assert.equal(job.needsAttention, 1);
assert.equal(job.results[0].code, 'roxy_profile_busy');
assert.ok(!app.requests.some((request) => ['/browser/open', '/browser/close', '/fixture/token'].includes(request.path)));
assertNoSecrets(job);
});
test('disabled Roxy uses the existing protocol workflow without launching Chrome or opening a Roxy window', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true });
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.ok(app.requests.some((request) => request.path === '/api/accounts/authorize/continue'));
assert.ok(app.requests.some((request) => request.path === '/api/accounts/password/verify'));
assert.ok(app.requests.some((request) => request.path === '/api/accounts/workspace/select'));
assert.ok(!app.requests.some((request) => request.path.startsWith('/browser/') || request.path === '/fixture/browser-options'));
assert.ok(!app.requests.some((request) => ['/fixture/browser-launch', '/fixture/browser-connect'].includes(request.path)),
'protocol mode must not even attempt Puppeteer for OAuth or Sentinel');
assert.equal((await app.saved()).state.children[0].refreshToken, 'private-fixture-rt-child_alpha');
assertNoSecrets(job);
});
test('an unconfigured installation uses protocol login without any browser OAuth session', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true });
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.ok(app.requests.some((request) => request.path === '/api/accounts/password/verify'));
assert.ok(!app.requests.some((request) => request.path.startsWith('/browser/') || request.path === '/fixture/browser-options'));
assert.ok(!app.requests.some((request) => ['/fixture/browser-launch', '/fixture/browser-connect'].includes(request.path)),
'an unconfigured installation must not even attempt Puppeteer');
assertNoSecrets(job);
});
test('protocol-only mode stops on a required Sentinel browser verification without attempting Puppeteer', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, sentinelChallenge: true });
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const job = await app.runJob({ ids: ['child_alpha'] });
assert.equal(job.succeeded, 0);
assert.equal(job.needsAttention, 1);
assert.equal(job.results[0].code, 'browser_verification_required');
assert.equal(job.results[0].httpStatus, 200, 'Sentinel can return HTTP 200 with a required browser challenge; do not label the local pause as an upstream HTTP 403');
assert.ok(app.requests.some((request) => request.path === '/fixture/sentinel'));
assert.ok(!app.requests.some((request) => ['/fixture/browser-launch', '/fixture/browser-connect', '/fixture/browser-options',
'/api/accounts/authorize/continue', '/api/accounts/password/verify', '/fixture/token'].includes(request.path)));
assertNoSecrets(job);
});
function priorVerification(scope, { code = 'browser_verification_required', backend = 'protocol' } = {}) {
if (scope === 'team') return { teamLoginBackends: { target_team_workspace: backend }, teamLoginStates: {
target_team_workspace: { status: 'browser_verification_required', code, browserRequired: true, needsInput: true },
} };
return { status: 'login_pending', loginStatus: 'verification_required', loginErrorCode: code,
loginBrowserRequired: true, ...(backend ? { loginBackend: backend } : {}) };
}
function jobSelection(scope) {
return { ids: ['child_alpha'], ...(scope === 'team' ? { scope: 'team', motherId: 'fixture_mother' } : {}) };
}
for (const scope of ['free', 'team']) {
test(`changing a ${scope} Roxy network configuration allows one new attempt but preserves a repeated verification pause`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: scope === 'team', browserVerification: true });
assert.equal((await app.patch(app.config)).status, 200);
const initial = await app.runJob(jobSelection(scope));
assert.equal(initial.succeeded, 0);
assert.equal(initial.needsAttention, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
await app.addProxies();
assert.equal((await app.patch({ useProxyPool: true, headless: true })).status, 200);
const retried = await app.runJob(jobSelection(scope));
assert.equal(retried.succeeded, 0);
assert.equal(retried.needsAttention, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 2,
'a changed network setting may retry the account once through its newly bound proxy');
const repeated = await app.runJob(jobSelection(scope));
assert.equal(repeated.succeeded, 0);
assert.equal(repeated.needsAttention, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 2,
'a second click with the same settings must preserve the verification pause');
assert.ok(!app.requests.some((request) => request.path === '/fixture/token'));
assertNoSecrets(initial);
assertNoSecrets(retried);
assertNoSecrets(repeated);
});
test(`enabling Roxy lets a prior ${scope} protocol verification pause try the newly selected browser backend`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team', priorFields: priorVerification(scope) });
assert.equal((await app.patch(app.config)).status, 200);
const job = await app.runJob(jobSelection(scope));
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
const account = (await app.saved()).state.children[0];
assert.equal(scope === 'team' ? account.teamLoginBackends[app.workspaceId] : account.loginBackend, 'roxy');
assertNoSecrets(job);
});
test(`a ${scope} official verification challenge in Roxy stays paused on the next batch click`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team',
priorFields: priorVerification(scope), browserVerification: true });
assert.equal((await app.patch(app.config)).status, 200);
const initial = await app.runJob(jobSelection(scope));
assert.equal(initial.succeeded, 0);
assert.equal(initial.needsAttention, 1);
assert.equal(initial.results[0].code, 'browser_verification_required');
const attempted = app.requests.filter((request) => request.path === '/fixture/browser-options').length;
assert.equal(attempted, 1);
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
const repeated = await app.runJob(jobSelection(scope));
assert.equal(repeated.succeeded, 0);
assert.equal(repeated.needsAttention, 1);
assert.equal(app.requests.filter((request) => request.path === '/fixture/browser-options').length, attempted,
'the same backend challenge must not trigger another OAuth browser run');
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.ok(!app.requests.some((request) => request.path === '/fixture/token'));
assertNoSecrets(initial);
assertNoSecrets(repeated);
});
for (const code of ['phone_verification_required', 'email_otp_required']) {
test(`enabling Roxy leaves the existing ${scope} ${code} requirement paused`, { timeout: 20000 }, async (t) => {
const prior = priorVerification(scope, { code });
if (scope === 'free') {
prior.loginStatus = code === 'phone_verification_required' ? code : 'waiting_code';
prior.status = code === 'phone_verification_required' ? code : 'login_pending';
} else {
prior.teamLoginStates.target_team_workspace.status = code === 'phone_verification_required' ? code : 'waiting_code';
}
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team', priorFields: prior });
assert.equal((await app.patch(app.config)).status, 200);
const job = await app.runJob(jobSelection(scope));
assert.equal(job.succeeded, 0);
assert.equal(job.needsAttention, 1);
assert.ok(!app.requests.some((request) => ['/fixture/browser-options', '/browser/open', '/fixture/token'].includes(request.path)));
const account = (await app.saved()).state.children[0];
assert.equal(scope === 'team' ? account.teamLoginBackends[app.workspaceId] : account.loginBackend, 'protocol');
assertNoSecrets(job);
});
}
}
test('a legacy Free browser verification without backend metadata may try Roxy once', { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, priorFields: priorVerification('free', { code: '', backend: '' }) });
assert.equal((await app.patch(app.config)).status, 200);
const job = await app.runJob(jobSelection('free'));
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1);
assert.equal((await app.saved()).state.children[0].loginBackend, 'roxy');
assertNoSecrets(job);
});
function priorBrowserSession(scope, { backend = 'roxy', code = 'browser_verification_required', phase = 'roxy_start' } = {}) {
const prior = priorVerification(scope, { code, backend });
const session = { state: 'private-old-state', codeVerifier: 'private-old-verifier', phase,
source: 'sub2api', currentUrl: 'https://auth.openai.com/log-in/password' };
if (scope === 'team') prior.teamAuthSessions = { target_team_workspace: session };
else prior.authSession = session;
return prior;
}
function assertProtocolOnlyRequests(requests) {
assert.ok(!requests.some((request) => request.path.startsWith('/browser/') || [
'/fixture/browser-options', '/fixture/browser-launch', '/fixture/browser-connect',
].includes(request.path)), 'protocol retry must not touch any browser');
}
for (const scope of ['free', 'team']) {
for (const [label, backend, code, phase] of [
['Roxy', 'roxy', 'browser_verification_required', 'roxy_start'],
['legacy browser session', '', '', 'browser_start'],
['legacy browser error', '', 'browser_oauth_verification_required', 'initializing'],
]) {
test(`disabling Roxy allows a prior ${scope} ${label} failure to start a fresh protocol login`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team',
priorFields: priorBrowserSession(scope, { backend, code, phase }) });
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const job = await app.runJob(jobSelection(scope));
assert.equal(job.succeeded, 1, JSON.stringify(job));
assert.equal(job.results[0].authScope, scope);
assert.equal(app.requests.filter((request) => request.path === '/oauth/authorize').length, 1,
'a browser session must not resume as a partial protocol password session');
assert.equal(app.requests.find((request) => request.path === '/oauth/authorize').query.state, 'private-old-state',
'switching the backend must preserve the original OAuth authorization state');
assert.equal(app.requests.filter((request) => request.path === '/api/accounts/authorize/continue').length, 1);
assert.equal(app.requests.filter((request) => request.path === '/api/accounts/password/verify').length, 1);
const exchanged = new URLSearchParams(app.requests.find((request) => request.path === '/fixture/token').body);
assert.ok(exchanged.get('code_verifier'));
assert.equal(exchanged.get('code_verifier'), 'private-old-verifier');
assertProtocolOnlyRequests(app.requests);
const account = (await app.saved()).state.children[0];
assert.equal(scope === 'team' ? account.teamLoginBackends[app.workspaceId] : account.loginBackend, 'protocol');
if (scope === 'team') {
assert.equal(account.workspaceTokens[app.workspaceId].refreshToken, 'private-fixture-rt-child_alpha');
assert.equal(job.results[0].quotaChecked, true);
assert.equal(job.results[0].quota5h, 75);
assert.equal(job.results[0].quota7d, 90);
} else assert.equal(account.refreshToken, 'private-fixture-rt-child_alpha');
assertNoSecrets(job);
assertNoSecrets(app.output());
});
}
test(`a ${scope} legacy verification without backend evidence stays paused in protocol mode`, { timeout: 20000 }, async (t) => {
const prior = priorBrowserSession(scope, { backend: '', code: '', phase: 'initializing' });
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team', priorFields: prior });
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const job = await app.runJob(jobSelection(scope));
assert.equal(job.succeeded, 0);
assert.equal(job.needsAttention, 1);
assert.ok(!app.requests.some((request) => ['/oauth/authorize', '/api/accounts/authorize/continue',
'/api/accounts/password/verify', '/fixture/token'].includes(request.path)));
assertProtocolOnlyRequests(app.requests);
const account = (await app.saved()).state.children[0];
const session = scope === 'team' ? account.teamAuthSessions[app.workspaceId] : account.authSession;
assert.deepEqual(session, scope === 'team' ? prior.teamAuthSessions.target_team_workspace : prior.authSession);
assertNoSecrets(job);
});
test(`a saved ${scope} callback is exchanged before resetting browser login progress for protocol mode`, { timeout: 20000 }, async (t) => {
const prior = priorBrowserSession(scope);
const session = scope === 'team' ? prior.teamAuthSessions.target_team_workspace : prior.authSession;
session.callbackReceivedAt = new Date().toISOString();
session.callbackUrl = 'http://localhost:1455/auth/callback?code=private-callback-code&state=private-old-state';
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team', priorFields: prior });
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const job = await app.runJob(jobSelection(scope));
assert.equal(job.succeeded, 1, JSON.stringify(job));
const exchanges = app.requests.filter((request) => request.path === '/fixture/token');
assert.equal(exchanges.length, 1);
const exchanged = new URLSearchParams(exchanges[0].body);
assert.equal(exchanged.get('code'), 'private-callback-code');
assert.equal(exchanged.get('code_verifier'), 'private-old-verifier');
assert.ok(!app.requests.some((request) => ['/oauth/authorize', '/api/accounts/authorize/continue',
'/api/accounts/password/verify'].includes(request.path)));
assertProtocolOnlyRequests(app.requests);
assertNoSecrets(job);
assertNoSecrets(app.output());
});
test(`a ${scope} protocol HTTP 403 exposes only safe stage/status and stays paused on the next automatic click`, { timeout: 20000 }, async (t) => {
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team',
priorFields: priorBrowserSession(scope), passwordFailure: { status: 403 } });
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const initial = await app.runJob(jobSelection(scope));
assert.equal(initial.succeeded, 0);
assert.equal(initial.needsAttention, 1);
assert.equal(initial.results[0].code, 'protocol_verification_required');
assert.equal(initial.results[0].phase, `${scope}_auth`);
assert.equal(initial.results[0].loginStage, 'protocol_password');
assert.equal(initial.results[0].httpStatus, 403);
assert.ok(initial.logs.some((entry) => entry.stage === `${scope}_auth` && entry.status === 'needs_attention' && entry.httpStatus === 403));
const savedAccount = (await app.saved()).state.children[0];
const savedFailure = scope === 'team' ? savedAccount.teamLoginStates[app.workspaceId] : {
stage: savedAccount.loginFailureStage, httpStatus: savedAccount.loginHttpStatus, code: savedAccount.loginErrorCode,
};
assert.equal(savedFailure.stage, 'protocol_password');
assert.equal(savedFailure.httpStatus, 403);
assert.equal(savedFailure.code, 'protocol_verification_required');
assert.equal(app.requests.filter((request) => request.path === '/api/accounts/password/verify').length, 1);
const initialLoginCalls = app.requests.filter((request) => request.path.startsWith('/oauth/')
|| request.path.startsWith('/api/accounts/') || request.path === '/fixture/sentinel').length;
const repeated = await app.runJob(jobSelection(scope));
assert.equal(repeated.succeeded, 0);
assert.equal(repeated.needsAttention, 1);
assert.equal(repeated.results[0].code, 'protocol_verification_required');
assert.equal(repeated.results[0].phase, `${scope}_auth`);
assert.equal(repeated.results[0].loginStage, 'protocol_password');
assert.equal(repeated.results[0].httpStatus, 403);
assert.equal(app.requests.filter((request) => request.path.startsWith('/oauth/')
|| request.path.startsWith('/api/accounts/') || request.path === '/fixture/sentinel').length, initialLoginCalls,
'the same protocol verification must not submit another login request');
assert.ok(!app.requests.some((request) => request.path === '/fixture/token'));
assertProtocolOnlyRequests(app.requests);
assertNoSecrets(initial);
assertNoSecrets(repeated);
assertNoSecrets(app.output());
});
for (const code of ['phone_verification_required', 'email_otp_required', 'totp_invalid', 'password_invalid']) {
test(`switching ${scope} Roxy to protocol preserves a ${code} pause`, { timeout: 20000 }, async (t) => {
const prior = priorBrowserSession(scope, { code });
if (scope === 'free') prior.loginStatus = code === 'phone_verification_required' ? code
: code === 'password_invalid' ? 'login_required' : 'waiting_code';
else prior.teamLoginStates.target_team_workspace.status = code === 'phone_verification_required' ? code
: code === 'password_invalid' ? 'login_required' : 'waiting_code';
const app = await fixture(t, { browserFlow: true, teamFlow: scope === 'team', priorFields: prior });
assert.equal((await app.patch({ ...app.config, enabled: false })).status, 200);
const job = await app.runJob(jobSelection(scope));
assert.equal(job.succeeded, 0);
assert.equal(job.needsAttention, 1);
assert.equal(job.results[0].code, code);
assert.ok(!app.requests.some((request) => ['/oauth/authorize', '/api/accounts/authorize/continue',
'/api/accounts/password/verify', '/fixture/token'].includes(request.path)));
assertProtocolOnlyRequests(app.requests);
const account = (await app.saved()).state.children[0];
assert.equal(scope === 'team' ? account.teamLoginBackends[app.workspaceId] : account.loginBackend, 'roxy');
assertNoSecrets(job);
});
}
}