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.
2492 lines
165 KiB
JavaScript
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);
|
|
});
|
|
}
|
|
}
|