assistant/tools/learning/test-control.mjs

185 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
}