D4 review fix round. P2: the org inbox took the hire form's HTML before its workspace read, so typing or a submit result that landed during a slow read was painted over with the older draft. inboxPage now takes a function and calls it while building the page after the read. Two browser cases hold the inbox read: typing during it, and a refused start that resolves during it, both survive the repaint (both fail on the previous commit). P3: a hire start passes a bus code through only when the broker raises it before it records the decision (hire-decisions prepare: the model route codes, definition-not-hireable, definition-shared, no-chief-of-staff). Every other code, including revision-conflict-revoked, request-timeout, invalid-envelope, incomplete-request and codes the resolve or apply steps can raise, stays outcome-unknown. Messages are worded for hire, fire and change alike.
356 lines
22 KiB
JavaScript
356 lines
22 KiB
JavaScript
// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
|
|
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
|
|
// in the browser tests.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
|
|
|
|
const ORIGIN = 'https://console.test';
|
|
const DIGEST = 'a'.repeat(64);
|
|
const PREVIEW = {
|
|
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
|
|
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
|
|
grant: { method: 'host-enroll', host: 'h1' },
|
|
};
|
|
|
|
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
|
|
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
|
|
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
|
const data = Buffer.from(raw ?? JSON.stringify(body));
|
|
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
|
|
}
|
|
function response() {
|
|
const r = { status: null, headers: null, text: '' };
|
|
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
|
|
r.end = (t = '') => { r.text += t; };
|
|
r.json = () => JSON.parse(r.text);
|
|
return r;
|
|
}
|
|
function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
|
|
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
|
const calls = [], lines = [];
|
|
const s = {
|
|
session, calls, lines, current: session,
|
|
confirmations: createConfirmations(now ? { now } : {}),
|
|
decide: {
|
|
origin: ORIGIN,
|
|
call: async (verb, args, ctx) => {
|
|
calls.push({ verb, args, ctx });
|
|
return ['decision.preview.console','hire.preview.console'].includes(verb) ? answer(args) : resolve(args);
|
|
},
|
|
},
|
|
};
|
|
s.run = async (path, req) => {
|
|
const res = response();
|
|
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
|
|
return res;
|
|
};
|
|
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
|
|
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
|
|
return s;
|
|
}
|
|
const refused = (res, code) => {
|
|
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
|
|
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
|
|
assert.equal(res.headers['cache-control'], 'no-store');
|
|
};
|
|
|
|
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
|
|
const s = setup();
|
|
const c = await s.confirm('yes');
|
|
assert.equal(c.status, 200);
|
|
const body = c.json();
|
|
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
|
|
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
|
|
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
|
|
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
|
|
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
|
|
// J12: the confirmation is spent, so a repeat never reaches the bus.
|
|
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
|
|
assert.equal(s.calls.length, 2);
|
|
assert.equal(s.lines.length, 3);
|
|
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
|
|
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
|
|
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
|
|
});
|
|
|
|
test('deny resolves without a grant field', async () => {
|
|
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
|
const c = (await s.confirm('no')).json();
|
|
assert.equal(c.action, 'deny');
|
|
assert.equal(c.text, 'No');
|
|
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
|
|
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
|
|
});
|
|
|
|
const ORDINARY = {
|
|
id: 'd1', business: 'demo', question: 'How should we handle this request?', digest: DIGEST, kind: 'decision',
|
|
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }],
|
|
};
|
|
test('ordinary confirmation carries the selected third key/text and decision context, then resolves once in its business', async () => {
|
|
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
|
const c = await s.confirm('existing:project');
|
|
assert.equal(c.status, 200, c.text);
|
|
assert.deepEqual({ ...c.json(), confirmation: 'x' }, { id: 'd1', kind: 'decision', question: ORDINARY.question, action: 'answer', choice: 'existing:project', text: 'Add to existing project', confirmation: 'x' });
|
|
const body = { choice: 'existing:project', confirmation: c.json().confirmation };
|
|
assert.deepEqual((await s.resolve(body)).json(), { id: 'd1', op: 'resolved', choice: 'existing:project' });
|
|
assert.equal(s.calls[1].ctx.business, 'demo');
|
|
assert.equal(s.calls[1].args.digest, DIGEST);
|
|
refused(await s.resolve(body), 'confirmation-required');
|
|
assert.equal(s.calls.length, 2);
|
|
});
|
|
|
|
test('ordinary options are validated authoritatively at confirmation, including two and nine choices', async () => {
|
|
for (const options of [ORDINARY.options.slice(0, 2), Array.from({ length: 9 }, (_, i) => ({ key: `key:${i}`, text: `Answer ${i}` }))]) {
|
|
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
|
|
assert.equal((await s.confirm(options.at(-1).key)).status, 200);
|
|
refused(await s.confirm('not-listed'), 'decision-stale');
|
|
}
|
|
for (const options of [[], ORDINARY.options.slice(0, 1), Array(10).fill(ORDINARY.options[0]), [ORDINARY.options[0], ORDINARY.options[0]], [{ key: 1, text: 'x' }, ORDINARY.options[1]], [{ key: 'x' }, ORDINARY.options[1]]]) {
|
|
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
|
|
refused(await s.confirm('new/autonomous'), 'confirmation-unavailable');
|
|
assert.equal(s.confirmations.size, 0);
|
|
}
|
|
});
|
|
|
|
test('ordinary stale/closed and uncertain outcomes spend their confirmation and never replay', async () => {
|
|
for (const code of ['decision-stale', 'decision-closed', 'outcome-unknown']) {
|
|
const s = setup({ answer: () => ORDINARY, resolve: () => { throw new BusReadError(code); } });
|
|
const c = await s.confirm('new/guided');
|
|
assert.equal(c.status, 200, c.text);
|
|
const body = { choice: 'new/guided', confirmation: c.json().confirmation };
|
|
refused(await s.resolve(body), code);
|
|
refused(await s.resolve(body), 'confirmation-required');
|
|
assert.equal(s.calls.length, 2);
|
|
}
|
|
});
|
|
|
|
test('ordinary confirmations stay bound to the issuing session, choice, decision and business', async () => {
|
|
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
|
let c = (await s.confirm('new/guided')).json();
|
|
refused(await s.resolve({ choice: 'existing:project', confirmation: c.confirmation }), 'confirmation-required');
|
|
c = (await s.confirm('new/guided')).json();
|
|
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
|
|
c = (await s.confirm('new/guided')).json();
|
|
s.current = null;
|
|
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'sign-in-required');
|
|
s.current = s.session;
|
|
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
|
|
const other = setup({ answer: () => ({ ...ORDINARY, business: 'other' }) });
|
|
other.confirmations = s.confirmations;
|
|
c = (await s.confirm('new/guided')).json();
|
|
refused(await other.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
|
|
assert.equal(s.calls.filter(x => x.verb === 'decision.resolve.console').length, 0);
|
|
assert.equal(other.calls.length, 0);
|
|
});
|
|
|
|
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
|
|
const s = setup();
|
|
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
|
|
const res = response();
|
|
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
|
|
refused(res, 'read-only');
|
|
const out = response();
|
|
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
|
|
refused(out, 'sign-in-required');
|
|
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
|
|
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
|
|
}
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test('a bad body, id or content type refuses before the bus', async () => {
|
|
const s = setup();
|
|
const path = '/api/decisions/d1/confirmation';
|
|
refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
|
|
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
|
|
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
|
|
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
|
|
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
|
|
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
|
|
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
|
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
|
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
|
|
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
|
|
refused(await setup().confirm('maybe'), 'decision-stale');
|
|
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
|
|
const s = setup({ answer: () => bad });
|
|
refused(await s.confirm('yes'), 'confirmation-unavailable');
|
|
assert.equal(s.confirmations.size, 0);
|
|
}
|
|
});
|
|
|
|
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
|
|
const cases = [
|
|
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
|
|
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
|
|
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
|
|
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
|
|
];
|
|
for (const [bus, code] of cases) {
|
|
const s = setup({ answer: () => { throw new BusReadError(bus); } });
|
|
refused(await s.confirm('yes'), code);
|
|
}
|
|
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
|
|
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
|
|
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
|
|
const c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
|
|
// Nothing retries, and the confirmation stays spent.
|
|
assert.equal(s.calls.length, 2);
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
|
}
|
|
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
|
|
const s = setup({ resolve: () => reply });
|
|
const c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
|
|
}
|
|
});
|
|
|
|
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
|
|
const s = setup();
|
|
let c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
|
c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
|
|
c = (await s.confirm('yes')).json();
|
|
const other = setup();
|
|
other.confirmations = s.confirmations;
|
|
other.session = { ...s.session };
|
|
other.current = other.session;
|
|
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
|
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
|
|
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
|
|
assert.equal(other.calls.length, 0);
|
|
});
|
|
|
|
test('a session that ends between the steps refuses before the bus', async () => {
|
|
const s = setup();
|
|
s.current = null;
|
|
refused(await s.confirm('yes'), 'sign-in-required');
|
|
assert.equal(s.calls.length, 0);
|
|
s.current = s.session;
|
|
const c = (await s.confirm('yes')).json();
|
|
s.current = { ...s.session };
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
|
|
assert.equal(s.calls.length, 1);
|
|
// A sign-out during the preview also refuses, and no confirmation is issued.
|
|
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
|
|
refused(await t.confirm('yes'), 'sign-in-required');
|
|
assert.equal(t.confirmations.size, 0);
|
|
});
|
|
|
|
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
|
|
let t = 0;
|
|
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
|
|
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
|
|
const a = store.issue(bind), b = store.issue(bind);
|
|
assert.equal(store.issue(bind), null);
|
|
assert.equal(store.size, 2);
|
|
assert.equal(store.consume(a, bind).digest, DIGEST);
|
|
assert.equal(store.consume(a, bind), null);
|
|
t = 1000;
|
|
assert.equal(store.consume(b, bind), null);
|
|
assert.equal(store.size, 0);
|
|
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
|
|
assert.equal(store.consume(undefined, bind), null);
|
|
assert.equal(store.consume('A'.repeat(64), bind), null);
|
|
});
|
|
|
|
test('A4 hire confirmation carries computed lines and resolves through org.hire, never decide',async()=>{
|
|
const flagged=[{field:'tools',value:'bash',reasons:['above-chief']}],credentialLines=[{service:'gitea',scope:'read:repository'}];
|
|
const s=setup({answer:()=>({id:'d1',business:'demo',kind:'hire',question:'hire scout?',digest:DIGEST,options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}],flagged,credentialLines,expiresAt:'2099-01-01T00:00:00.000Z'}),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
|
|
const c=await s.confirm('yes');assert.equal(c.status,200);
|
|
assert.deepEqual(c.json().flagged,flagged);assert.deepEqual(c.json().credentialLines,credentialLines);
|
|
const r=await s.resolve({choice:'yes',confirmation:c.json().confirmation});assert.equal(r.status,200);
|
|
assert.equal(s.calls[1].verb,'hire.resolve.console');
|
|
});
|
|
|
|
test('A4 Console start carries the signed-in subject and org, records once and never retries',async()=>{
|
|
const s=setup({resolve:()=>({id:'new-hire',op:'resolved',choice:'yes'})});
|
|
const hire={op:'hire',level:'org',name:'scout',reason:'Need help',role:{template:'coder',sha256:DIGEST},route:{harness:'pi',model:'claude-opus-5-5'}};
|
|
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire}}));
|
|
assert.equal(r.status,200,r.text);assert.equal(s.calls.length,1);
|
|
assert.equal(s.calls[0].verb,'hire.start.console');assert.deepEqual(s.calls[0].args,hire);assert.equal(s.calls[0].ctx.business,'demo');
|
|
refused(await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire},headers:{origin:['https://elsewhere.test']}})),'cross-origin');
|
|
assert.equal(s.calls.length,1);
|
|
});
|
|
test('A4 confirmation holds the exact human adjustment, so resolve cannot substitute it',async()=>{
|
|
const adjust={role:{tools:['read']},credentials:[]};
|
|
const s=setup({answer:()=>({...ORDINARY,kind:'hire',flagged:[],credentialLines:[],expiresAt:'2099-01-01T00:00:00.000Z'}),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
|
|
const c=await s.run('/api/decisions/d1/confirmation',request({body:{choice:'new/autonomous',adjust}}));
|
|
assert.equal(c.status,200,c.text);assert.deepEqual(s.calls[0].args,{id:'d1',adjust});
|
|
assert.equal(s.calls[0].verb,'hire.preview.console');
|
|
const r=await s.resolve({choice:'new/autonomous',confirmation:c.json().confirmation});
|
|
assert.equal(r.status,200,r.text);assert.deepEqual(s.calls[1].args.adjust,adjust);
|
|
});
|
|
|
|
// D4 (#1873, P1-2): a hire start the bus definitely refused before it recorded the decision keeps its
|
|
// own code and a next step; every other code, a lost or unreadable reply among them, is still
|
|
// outcome-unknown, and the other steps keep their mapping.
|
|
const START_HIRE={op:'hire',level:'org',name:'scout',reason:'Need help',role:{template:'coder',sha256:DIGEST},route:{harness:'pi',model:'gone-model'}};
|
|
const startWith=async err=>{
|
|
const s=setup({resolve:()=>{throw err;}});
|
|
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}}));
|
|
assert.equal(s.calls.length,1,'one bus call, never retried');
|
|
return {r,s};
|
|
};
|
|
test('D4 hire start: model-provider-unavailable keeps its code and names Settings > Model proxy',async()=>{
|
|
const {r,s}=await startWith(new BusReadError('model-provider-unavailable'));
|
|
assert.equal(r.status,409,r.text);
|
|
const body=r.json();
|
|
assert.equal(body.error,'model-provider-unavailable');
|
|
assert.match(body.message,/Settings > Model proxy/);
|
|
assert.match(body.message,/Nothing was applied/);
|
|
assert.equal(r.headers['cache-control'],'no-store');
|
|
assert.match(s.lines.at(-1),/step=start error=model-provider-unavailable$/);
|
|
});
|
|
test('D4 hire start: the other model route codes keep their code with a next step',async()=>{
|
|
for(const [code,status] of [['model-catalogue-unavailable',503],['model-harness-incompatible',409],['thinking-unsupported',409]]) {
|
|
const {r}=await startWith(new BusReadError(code));
|
|
assert.equal(r.status,status,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
|
|
}
|
|
});
|
|
test('D4 hire start: the other proposal checks that run before the decision insert keep their code',async()=>{
|
|
for(const code of ['definition-not-hireable','definition-shared','no-chief-of-staff']) {
|
|
const {r}=await startWith(new BusReadError(code));
|
|
assert.equal(r.status,409,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
|
|
assert.doesNotMatch(r.json().message,/\bhire was\b/,'worded for hire, fire and change alike');
|
|
}
|
|
});
|
|
// Review P3: a code the broker can raise after the decision exists or is consumed, or one the bus
|
|
// server raises about the request itself, is not a definite refusal of the start.
|
|
test('D4 hire start: a code that can follow the decision insert, or a transport code, stays outcome-unknown',async()=>{
|
|
for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','instance-kept','organization-instance-exists','organization-unknown-instance','root-fire-shared','fire-in-progress','some-future-code'])
|
|
refused((await startWith(new BusReadError(code))).r,'outcome-unknown');
|
|
});
|
|
test('D4 hire start: a lost or unreadable reply is still outcome-unknown',async()=>{
|
|
for(const err of [new BusReadError('outcome-unknown'),new BusReadError('invalid-response'),new BusReadError('response-too-large'),new BusReadError('outcome-unknown-revoked'),new BusReadError('Not A Code'),new BusReadError(''),new Error('socket closed'),Object.assign(new Error('x'),{code:'model-provider-unavailable'})])
|
|
refused((await startWith(err)).r,'outcome-unknown');
|
|
// A reply that does not say the hire resolved yes is not a refusal either.
|
|
const s=setup({resolve:()=>({id:'new-hire',op:'resolved',choice:'no'})});
|
|
refused(await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}})),'outcome-unknown');
|
|
});
|
|
test('D4 hire start: the bus codes the endpoint already names keep their refusal',async()=>{
|
|
refused((await startWith(new BusReadError('unauthenticated'))).r,'human-required');
|
|
refused((await startWith(new BusReadError('right-required'))).r,'right-required');
|
|
refused((await startWith(new BusReadError('hire-owner-required'))).r,'hire-owner-required');
|
|
});
|
|
test('D4 templates, confirmation and resolve keep their open mapping for a definite unnamed code',async()=>{
|
|
const t=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
|
|
refused(await t.run('/api/decisions/hire/templates',request({body:{business:'demo'}})),'confirmation-unavailable');
|
|
const c=setup({answer:()=>{throw new BusReadError('model-provider-unavailable');}});
|
|
refused(await c.confirm('yes'),'confirmation-unavailable');
|
|
const s=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
|
|
const ok=await s.confirm('yes');assert.equal(ok.status,200);
|
|
refused(await s.resolve({choice:'yes',confirmation:ok.json().confirmation}),'outcome-unknown');
|
|
});
|