185 lines
13 KiB
JavaScript
185 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { createServer } from 'node:http';
|
||
import { Readable, Writable } from 'node:stream';
|
||
import { LearningControlClient, main, readControlInput } from './control.mjs';
|
||
|
||
// Isolated transport fixtures only: no browser, real accounts or development API.
|
||
const calls = [];
|
||
const secret = 'Transport-fixture-password-51';
|
||
let csrf = 'fixture-csrf-before-login';
|
||
let actions = 0;
|
||
let redirect = false;
|
||
const server = createServer(async (request, response) => {
|
||
const buffers = [];
|
||
for await (const chunk of request) buffers.push(chunk);
|
||
const body = buffers.length ? JSON.parse(Buffer.concat(buffers).toString()) : undefined;
|
||
calls.push({ method: request.method, path: request.url });
|
||
const send = (status, data, cookie) => {
|
||
response.writeHead(status, { 'Content-Type': 'application/json', ...(cookie ? { 'Set-Cookie': cookie } : {}) });
|
||
response.end(JSON.stringify(data));
|
||
};
|
||
if (request.url === '/api/health') return send(200, { status: 'ok' });
|
||
if (request.url === '/api/auth/status') return send(200, { csrf_token: csrf, authenticated: false }, 'assistant_maintainer=anonymous; HttpOnly; SameSite=Strict');
|
||
if (request.url === '/api/auth/login') {
|
||
assert.equal(request.headers['x-csrf-token'], csrf);
|
||
assert.match(request.headers.cookie, /assistant_maintainer=anonymous/);
|
||
assert.equal(body.password, secret);
|
||
if (redirect) { response.writeHead(302, { Location: '/unexpected-redirect' }); response.end(); return; }
|
||
csrf = 'fixture-csrf-after-login';
|
||
return send(200, { csrf_token: csrf, authenticated: true }, 'assistant_maintainer=authenticated; HttpOnly; SameSite=Strict');
|
||
}
|
||
assert.equal(request.headers.origin, `http://127.0.0.1:${server.address().port}`);
|
||
assert.match(request.headers.cookie, /assistant_maintainer=authenticated/);
|
||
if (request.method === 'POST') assert.equal(request.headers['x-csrf-token'], csrf);
|
||
if (request.url === '/api/learning/tasks/fail/browser/action') return send(409, { detail: secret });
|
||
if (request.url === '/api/learning/tasks/fail_code/browser/action') return send(409, { detail: { code: body.fixture_code, message: secret, csrf_token: csrf } });
|
||
if (request.url.endsWith('/browser/action')) {
|
||
actions += 1;
|
||
return send(200, { result: { kind: body.kind } });
|
||
}
|
||
return send(200, { items: [] });
|
||
});
|
||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||
const environment = { ASSISTANT_CONTROL_URL: baseUrl, ASSISTANT_MAINTAINER_USERNAME: 'fixture-maintainer', ASSISTANT_MAINTAINER_PASSWORD: secret };
|
||
function outputBuffer() {
|
||
let value = '';
|
||
return { output: new Writable({ write(chunk, encoding, next) { value += chunk; next(); } }), text: () => value };
|
||
}
|
||
try {
|
||
for (const base of ['https://127.0.0.1:8810', 'http://example.com', 'http://user:pass@127.0.0.1:8810', baseUrl + '/api', baseUrl + '?token=secret']) {
|
||
assert.throws(() => new LearningControlClient({ baseUrl: base }), error => error.code === 'INVALID_BASE_URL');
|
||
}
|
||
const client = new LearningControlClient({ baseUrl, username: 'fixture-maintainer', password: secret });
|
||
assert.deepEqual(await client.execute({ operation: 'health' }), { status: 'ok' });
|
||
const beforeInvalid = calls.length;
|
||
await assert.rejects(client.execute({ operation: 'shell.run' }), error => error.code === 'UNKNOWN_OPERATION');
|
||
await assert.rejects(client.execute({ operation: 'tasks.get', task_id: '../auth/status' }), error => error.code === 'INVALID_REQUEST');
|
||
await assert.rejects(client.execute({ operation: 'discovery.get', run_id: '../auth/status' }), error => error.code === 'INVALID_REQUEST');
|
||
await assert.rejects(client.execute({ operation: 'readings.list', run_id: '../auth/status' }), error => error.code === 'INVALID_REQUEST');
|
||
await assert.rejects(client.execute({ operation: 'readings.create', run_id: '../auth/status', data: {} }), error => error.code === 'INVALID_REQUEST');
|
||
await assert.rejects(client.execute({ operation: 'readings.get', reading_id: '../auth/status' }), error => error.code === 'INVALID_REQUEST');
|
||
await assert.rejects(client.execute({ operation: 'analyses.list', reading_id: '../auth/status' }), error => error.code === 'INVALID_REQUEST');
|
||
await assert.rejects(client.execute({ operation: 'analyses.create', reading_id: '../auth/status', data: {} }), error => error.code === 'INVALID_REQUEST');
|
||
await assert.rejects(client.execute({ operation: 'analyses.get', analysis_id: '../auth/status' }), error => error.code === 'INVALID_REQUEST');
|
||
for (const operation of ['briefs.get', 'briefs.confirm', 'briefs.discover']) {
|
||
await assert.rejects(client.execute({ operation, brief_id: '../auth/status', data: {} }), error => error.code === 'INVALID_REQUEST');
|
||
}
|
||
for (const operation of ['reviews.list', 'reviews.create', 'reviews.summary']) {
|
||
await assert.rejects(client.execute({ operation, analysis_id: '../auth/status', data: {} }), error => error.code === 'INVALID_REQUEST');
|
||
}
|
||
await assert.rejects(client.execute({ operation: 'reviews.get', review_id: '../auth/status' }), error => error.code === 'INVALID_REQUEST');
|
||
assert.equal(calls.length, beforeInvalid);
|
||
const discoveryStart = calls.length;
|
||
await client.execute({ operation: 'discovery.list' });
|
||
await client.execute({ operation: 'discovery.get', run_id: 'discovery_fixture' });
|
||
await client.execute({ operation: 'discovery.create', data: { search_candidate_id: 'candidate_fixture', keyword: '装修', max_selected: 2 } });
|
||
assert.deepEqual(calls.slice(discoveryStart).map(({method, path}) => ({method, path})), [
|
||
{ method: 'GET', path: '/api/auth/status' },
|
||
{ method: 'POST', path: '/api/auth/login' },
|
||
{ method: 'GET', path: '/api/discovery/runs' },
|
||
{ method: 'GET', path: '/api/discovery/runs/discovery_fixture' },
|
||
{ method: 'POST', path: '/api/discovery/runs' },
|
||
]);
|
||
const routeStart = calls.length;
|
||
await client.execute({ operation: 'routes.list' });
|
||
await client.execute({ operation: 'routes.resolve', data: { platform: 'demo', task_type: 'search_note_comments' } });
|
||
await client.execute({ operation: 'routes.verify', data: { platform: 'demo', task_type: 'search_note_comments', keyword: '换参样例' } });
|
||
assert.deepEqual(calls.slice(routeStart), [
|
||
{ method: 'GET', path: '/api/learning/routes' },
|
||
{ method: 'POST', path: '/api/learning/routes/resolve' },
|
||
{ method: 'POST', path: '/api/learning/routes/verify' },
|
||
]);
|
||
const actualReadingStart = calls.length;
|
||
await client.execute({ operation: 'readings.list', run_id: 'discovery_fixture' });
|
||
await client.execute({ operation: 'readings.get', reading_id: 'reading_fixture' });
|
||
await client.execute({ operation: 'readings.create', run_id: 'discovery_fixture', data: { detail_candidate_id: 'candidate_fixture', note_id: 'a'.repeat(24) } });
|
||
assert.deepEqual(calls.slice(actualReadingStart), [
|
||
{ method: 'GET', path: '/api/discovery/runs/discovery_fixture/readings' },
|
||
{ method: 'GET', path: '/api/discovery/readings/reading_fixture' },
|
||
{ method: 'POST', path: '/api/discovery/runs/discovery_fixture/readings' },
|
||
]);
|
||
const analysisStart = calls.length;
|
||
await client.execute({ operation: 'model.config' });
|
||
await client.execute({ operation: 'analyses.list', reading_id: 'reading_fixture' });
|
||
await client.execute({ operation: 'analyses.create', reading_id: 'reading_fixture', data: {} });
|
||
await client.execute({ operation: 'analyses.get', analysis_id: 'analysis_fixture' });
|
||
assert.deepEqual(calls.slice(analysisStart), [
|
||
{ method: 'GET', path: '/api/analysis/config' },
|
||
{ method: 'GET', path: '/api/discovery/readings/reading_fixture/analyses' },
|
||
{ method: 'POST', path: '/api/discovery/readings/reading_fixture/analyses' },
|
||
{ method: 'GET', path: '/api/discovery/analyses/analysis_fixture' },
|
||
]);
|
||
const briefStart = calls.length;
|
||
await client.execute({ operation: 'briefs.list' });
|
||
await client.execute({ operation: 'briefs.create', data: { request_text: '小红书寻找装修业主' } });
|
||
await client.execute({ operation: 'briefs.get', brief_id: 'brief_fixture' });
|
||
await client.execute({ operation: 'briefs.confirm', brief_id: 'brief_fixture', data: {} });
|
||
await client.execute({ operation: 'briefs.discover', brief_id: 'brief_fixture', data: { search_candidate_id: 'candidate_fixture', max_selected: 2 } });
|
||
assert.deepEqual(calls.slice(briefStart), [
|
||
{ method: 'GET', path: '/api/business/briefs' },
|
||
{ method: 'POST', path: '/api/business/briefs' },
|
||
{ method: 'GET', path: '/api/business/briefs/brief_fixture' },
|
||
{ method: 'POST', path: '/api/business/briefs/brief_fixture/confirm' },
|
||
{ method: 'POST', path: '/api/business/briefs/brief_fixture/discover' },
|
||
]);
|
||
const reviewStart = calls.length;
|
||
await client.execute({ operation: 'reviews.list', analysis_id: 'analysis_fixture' });
|
||
await client.execute({ operation: 'reviews.create', analysis_id: 'analysis_fixture', data: { source_ref: 'note', previous_review_id: null, decision: 'uncertain', labels: null, note: '需要更多上下文' } });
|
||
await client.execute({ operation: 'reviews.summary', analysis_id: 'analysis_fixture' });
|
||
await client.execute({ operation: 'reviews.get', review_id: 'review_fixture' });
|
||
assert.deepEqual(calls.slice(reviewStart), [
|
||
{ method: 'GET', path: '/api/discovery/analyses/analysis_fixture/reviews' },
|
||
{ method: 'POST', path: '/api/discovery/analyses/analysis_fixture/reviews' },
|
||
{ method: 'GET', path: '/api/discovery/analyses/analysis_fixture/review-summary' },
|
||
{ method: 'GET', path: '/api/discovery/reviews/review_fixture' },
|
||
]);
|
||
await client.execute({ operation: 'browser.action', task_id: 'fixture_task', data: { kind: 'scroll', selector: 'body', direction: 'down', pixels: 500, max_scrolls: 1 } });
|
||
assert.equal(actions, 1);
|
||
const rejection = await client.execute({ operation: 'browser.action', task_id: 'fail', data: { kind: 'click', selector: 'button' } }).catch(error => error);
|
||
assert.equal(rejection.code, 'API_REJECTED');
|
||
assert.equal(rejection.status, 409);
|
||
assert.equal(rejection.apiCode, undefined);
|
||
assert.ok(!rejection.message.includes(secret));
|
||
assert.equal(actions, 1, 'A rejected write must not be retried');
|
||
const codedRejection = await client.execute({ operation: 'browser.action', task_id: 'fail_code', data: { kind: 'click', selector: 'button', fixture_code: 'WRITE_REQUEST_BLOCKED' } }).catch(error => error);
|
||
assert.equal(codedRejection.code, 'API_REJECTED');
|
||
assert.equal(codedRejection.status, 409);
|
||
assert.equal(codedRejection.apiCode, 'WRITE_REQUEST_BLOCKED');
|
||
assert.equal(codedRejection.message, rejection.message);
|
||
assert.ok(!JSON.stringify(codedRejection).includes(secret) && !JSON.stringify(codedRejection).includes(csrf));
|
||
const coded = outputBuffer();
|
||
assert.equal(await main(Readable.from([JSON.stringify({ operation: 'browser.action', task_id: 'fail_code', data: { kind: 'click', selector: 'button', fixture_code: 'WRITE_REQUEST_BLOCKED' } })]), coded.output, environment), 1);
|
||
assert.deepEqual(JSON.parse(coded.text()).error, { code: 'API_REJECTED', message: rejection.message, status: 409, api_code: 'WRITE_REQUEST_BLOCKED' });
|
||
for (const fixtureCode of [null, 409, {}, 'X', 'A'.repeat(65), 'browser_session_required', 'WRITE REQUEST BLOCKED', 'WRITE_REQUEST_BLOCKED\n', 'WRITE_REQUEST_BLOCKED', secret, 'API_SECRET_LEAK', 'PASSWORD_123456', 'CSRF_TOKEN_VALUE']) {
|
||
const unsafe = outputBuffer();
|
||
assert.equal(await main(Readable.from([JSON.stringify({ operation: 'browser.action', task_id: 'fail_code', data: { kind: 'click', selector: 'button', fixture_code: fixtureCode } })]), unsafe.output, environment), 1);
|
||
const error = JSON.parse(unsafe.text()).error;
|
||
assert.deepEqual(error, { code: 'API_REJECTED', message: rejection.message, status: 409 });
|
||
assert.ok(!unsafe.text().includes(secret) && !unsafe.text().includes(csrf));
|
||
}
|
||
assert.equal(actions, 1, 'Diagnostic failures must not retry writes');
|
||
await assert.rejects(readControlInput(Readable.from(['x'.repeat(64 * 1024 + 1)])), error => error.code === 'INPUT_TOO_LARGE');
|
||
await assert.rejects(readControlInput(Readable.from(['[]'])), error => error.code === 'INVALID_REQUEST');
|
||
const batch = outputBuffer();
|
||
assert.equal(await main(Readable.from([JSON.stringify([
|
||
{ operation: 'tasks.list' },
|
||
{ operation: 'browser.action', task_id: 'fail', data: { kind: 'click', selector: 'button' } },
|
||
{ operation: 'browser.action', task_id: 'must_not_execute', data: { kind: 'click', selector: 'button' } },
|
||
])]), batch.output, environment), 1);
|
||
const results = JSON.parse(batch.text());
|
||
assert.equal(results.length, 2);
|
||
assert.equal(results[0].ok, true);
|
||
assert.equal(results[1].error.status, 409);
|
||
assert.ok(!batch.text().includes(secret) && !batch.text().includes(csrf) && !batch.text().includes('authenticated;'));
|
||
assert.ok(!calls.some(call => call.path.includes('must_not_execute')));
|
||
redirect = true;
|
||
const redirected = new LearningControlClient({ baseUrl, username: 'fixture-maintainer', password: secret });
|
||
await assert.rejects(redirected.execute({ operation: 'tasks.list' }), error => error.code === 'CONNECTION_FAILED');
|
||
assert.ok(!calls.some(call => call.path === '/unexpected-redirect'));
|
||
process.stdout.write(JSON.stringify({ result: 'passed', local_only: true, csrf_rotation: true, operation_allowlist: true,
|
||
no_redirect: true, no_write_retry: true, no_credentials_in_output: true, bounded_input: true, safe_api_code: true }) + '\n');
|
||
} finally {
|
||
await new Promise(resolve => server.close(resolve));
|
||
}
|