139 lines
7.1 KiB
JavaScript
139 lines
7.1 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { loadConfig } from '../src/config.js';
|
|
import { decryptText, encryptText, hashToken, sameTokenHash } from '../src/crypto.js';
|
|
import { buildServer } from '../src/server.js';
|
|
import { TaskService, classifyExecutionResult, executionStatusImmutable, executionUpdateAllowed } from '../src/task-service.js';
|
|
test('field encryption round-trips without storing plaintext', () => {
|
|
const config = loadConfig({
|
|
NODE_ENV: 'test',
|
|
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64')
|
|
});
|
|
const plaintext = '含有客户信息的测试指令';
|
|
const encrypted = encryptText(config, plaintext);
|
|
assert.notEqual(encrypted, plaintext);
|
|
assert.equal(decryptText(config, encrypted), plaintext);
|
|
});
|
|
test('token hashes compare safely and do not expose the original token', () => {
|
|
const token = 'temporary-session-token';
|
|
const hash = hashToken(token);
|
|
assert.equal(hash.toString('utf8').includes(token), false);
|
|
assert.equal(sameTokenHash(hash, hashToken(token)), true);
|
|
assert.equal(sameTokenHash(hash, hashToken(`${token}-other`)), false);
|
|
});
|
|
test('control plane exposes a live health endpoint without a database connection', async () => {
|
|
const config = loadConfig({
|
|
NODE_ENV: 'test',
|
|
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 8).toString('base64'),
|
|
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
|
|
});
|
|
const { app } = await buildServer({
|
|
config,
|
|
startParserLoop: false,
|
|
parser: {
|
|
async parse() {
|
|
return { blockers: ['test parser'] };
|
|
},
|
|
async checkConnection() {
|
|
return { ok: false, configured: false };
|
|
}
|
|
}
|
|
});
|
|
const response = await app.inject({ method: 'GET', url: '/health/live' });
|
|
assert.equal(response.statusCode, 200);
|
|
assert.deepEqual(response.json(), { ok: true, service: 'ltjt-control-plane' });
|
|
await app.close();
|
|
});
|
|
test('migration contains the durable state tables and safety fields', async () => {
|
|
const { readFile } = await import('node:fs/promises');
|
|
const sql = await readFile(new URL('../migrations/001_initial.sql', import.meta.url), 'utf8');
|
|
for (const marker of ['organizations', 'users', 'sessions', 'tasks', 'task_events', 'task_attempts', 'idempotency_keys', 'audit_events', 'browser_connections', 'outbox_events', 'execution_result']) {
|
|
assert.match(sql, new RegExp(`\\b${marker}\\b`));
|
|
}
|
|
});
|
|
test('ERP execution results fail closed after a possible write', () => {
|
|
assert.deepEqual(classifyExecutionResult({ status: 'running' }), {
|
|
rawStatus: 'running',
|
|
status: 'running',
|
|
terminal: false,
|
|
uncertain: false
|
|
});
|
|
assert.equal(classifyExecutionResult({ status: 'completed', write_attempted: true }).status, 'completed');
|
|
assert.equal(classifyExecutionResult({ status: 'blocked', no_erp_write: true }).status, 'blocked');
|
|
assert.equal(classifyExecutionResult({ status: 'blocked', write_attempted: true }).status, 'reconciliation_pending');
|
|
assert.equal(classifyExecutionResult({ status: 'saved_unverified' }).status, 'reconciliation_pending');
|
|
assert.equal(classifyExecutionResult({ status: 'unexpected_plugin_state' }).status, 'reconciliation_pending');
|
|
assert.equal(executionUpdateAllowed('queued'), true);
|
|
assert.equal(executionUpdateAllowed('confirmed'), false);
|
|
assert.equal(executionStatusImmutable('completed'), true);
|
|
assert.equal(executionStatusImmutable('reconciliation_pending'), true);
|
|
});
|
|
test('ERP migration enforces one execution attempt per task', async () => {
|
|
const { readFile } = await import('node:fs/promises');
|
|
const sql = await readFile(new URL('../migrations/004_erp_execution_idempotency.sql', import.meta.url), 'utf8');
|
|
assert.match(sql, /UNIQUE INDEX[\s\S]+ON task_attempts \(task_id\)[\s\S]+phase = 'erp'/i);
|
|
assert.match(sql, /tasks_erp_lease_idx/);
|
|
});
|
|
test('operator page has a login gate and uses the durable task API', async () => {
|
|
const { readFile } = await import('node:fs/promises');
|
|
const index = await readFile(new URL('../../mock-business-system/index.html', import.meta.url), 'utf8');
|
|
const app = await readFile(new URL('../../mock-business-system/app.js', import.meta.url), 'utf8');
|
|
const bridge = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8');
|
|
const background = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
|
|
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
|
|
assert.match(index, /id="loginPanel"/);
|
|
assert.match(index, /id="workbench"[^>]*hidden/);
|
|
assert.match(app, /apiRequest\('\/api\/tasks'/);
|
|
assert.match(app, /fetch\('\/api\/auth\/login'/);
|
|
assert.match(app, /The backend is authoritative/);
|
|
assert.match(app, /syncRequested/);
|
|
assert.match(app, /api\/events\?since=/);
|
|
assert.ok(app.indexOf('/claim') < app.indexOf("sendToExtension('CREATE_TASK'"), 'server claim must precede extension dispatch');
|
|
assert.doesNotMatch(app, /syncPendingTasks|>重交</);
|
|
assert.match(app, /taskStore\.filter\(isTaskPollable\)/);
|
|
assert.match(app, /execution_id: executionId/);
|
|
assert.doesNotMatch(bridge, /async function upsertBusinessTask/);
|
|
assert.match(bridge, /task: currentBusinessTask/);
|
|
assert.match(background, /businessTaskExecutions:[\s\S]+businessTasks: tasks/);
|
|
assert.match(inpage, /deferred_dialog_callbacks_suppressed/);
|
|
assert.doesNotMatch(inpage, /callback\.call\(window\)/);
|
|
});
|
|
test('system task events persist a null actor instead of an invalid empty UUID', async () => {
|
|
const config = loadConfig({
|
|
NODE_ENV: 'test',
|
|
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString('base64')
|
|
});
|
|
const calls = [];
|
|
const client = {
|
|
async query(sql, params = []) {
|
|
calls.push({ sql, params });
|
|
if (sql.includes('INSERT INTO task_events')) {
|
|
return {
|
|
rows: [{
|
|
id: '1',
|
|
organization_id: '00000000-0000-0000-0000-000000000001',
|
|
task_id: '00000000-0000-0000-0000-000000000002',
|
|
status: 'parse_failed',
|
|
stage: 'parse',
|
|
message: 'blocked',
|
|
payload: {},
|
|
created_at: new Date('2026-07-13T00:00:00.000Z')
|
|
}]
|
|
};
|
|
}
|
|
return { rows: [] };
|
|
}
|
|
};
|
|
const service = new TaskService(config);
|
|
await service.emitEvent(client, {
|
|
id: '00000000-0000-0000-0000-000000000002',
|
|
organization_id: '00000000-0000-0000-0000-000000000001',
|
|
task_id: 'TASK-SYSTEM-EVENT'
|
|
}, {
|
|
status: 'parse_failed',
|
|
stage: 'parse',
|
|
message: 'blocked'
|
|
}, '');
|
|
assert.equal(calls[0].params[6], null);
|
|
});
|
|
//# sourceMappingURL=control-plane.test.js.map
|