The bus instances read adds a copy's binding status only when the copy was bound at boot or is unbound for want of a bindable service, never to a tester. decision-http returns an allowlisted binding (instance and change) on a binding confirmation and fixed next-step text on an approved resolve and on a binding hire start. The decision page names the copy and the change; the receipt shows the next step; the Agents grid shows can't launch with the binding's next step. Every new string is fixed text.
413 lines
28 KiB
JavaScript
413 lines
28 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 { BINDING_NEXT, 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');
|
|
});
|
|
|
|
// #1873 CB5: a copy credential binding decision on the Console. The confirmation carries the bus's
|
|
// computed scope lines, the copy's name and whether it binds or unbinds, and nothing else of the
|
|
// binding; the approved answer carries the owner's fixed next step. The sentinel stands for a token
|
|
// the bus should never send: placed in every binding field the bus could add, it never reaches a
|
|
// response or a log line.
|
|
const SENTINEL='sentinel-0123456789abcdef0123456789abcdef01234567';
|
|
const bindingPreview=(after)=>({id:'d1',business:'demo',kind:'hire',question:'bind credentials for copy researcher?',digest:DIGEST,
|
|
options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}],flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',
|
|
binding:{instance:'researcher',source:{level:'root',name:SENTINEL,project:null},copyId:SENTINEL,fresh:true,orgIncarnation:SENTINEL,definition:{ref:SENTINEL,sha256:SENTINEL},
|
|
before:{gitea:{slot:SENTINEL,grantId:SENTINEL,scopes:['read:repository'],resources:{projects:['stack']}}},after,generation:0}});
|
|
const leaks=(s,...texts)=>texts.filter(t=>t.includes(SENTINEL)||t.includes(SENTINEL.slice(0,16))).length;
|
|
test('CB5 binding confirmation names the copy and the change with the computed scope lines, and the approval names the next step; nothing else of the binding leaves',async()=>{
|
|
const after={gitea:{slot:'self',scopes:['read:repository'],resources:{projects:['stack']},token:SENTINEL}};
|
|
const s=setup({answer:()=>bindingPreview(after),resolve:a=>({id:a.id,op:'resolved',choice:a.choice,transaction:SENTINEL,binding:{token:SENTINEL}})});
|
|
const c=await s.confirm('yes');assert.equal(c.status,200,c.text);
|
|
const body=c.json();
|
|
assert.deepEqual({...body,confirmation:'x'},{id:'d1',question:'bind credentials for copy researcher?',action:'answer',choice:'yes',text:'Approve',kind:'hire',
|
|
flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',binding:{instance:'researcher',change:'bind'},confirmation:'x'});
|
|
const r=await s.resolve({choice:'yes',confirmation:body.confirmation});assert.equal(r.status,200,r.text);
|
|
assert.deepEqual(r.json(),{id:'d1',op:'resolved',choice:'yes',next:BINDING_NEXT.bind});
|
|
assert.equal(s.calls[1].verb,'hire.resolve.console');
|
|
assert.equal(leaks(s,c.text,r.text,...s.lines),0);
|
|
assert.match(s.lines[0],/ step=confirmation action=answer$/);assert.match(s.lines[1],/ step=resolve choice="yes"$/);
|
|
});
|
|
test('CB5 an unbind says the bus revokes; a denial carries no next step; a malformed binding is not echoed',async()=>{
|
|
const u=setup({answer:()=>bindingPreview(null),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
|
|
const uc=await u.confirm('yes');assert.deepEqual(uc.json().binding,{instance:'researcher',change:'unbind'});
|
|
const ur=await u.resolve({choice:'yes',confirmation:uc.json().confirmation});
|
|
assert.deepEqual(ur.json(),{id:'d1',op:'resolved',choice:'yes',next:BINDING_NEXT.unbind});
|
|
const d=setup({answer:()=>bindingPreview(null),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
|
|
const dc=await d.confirm('no');
|
|
assert.deepEqual((await d.resolve({choice:'no',confirmation:dc.json().confirmation})).json(),{id:'d1',op:'resolved',choice:'no'});
|
|
// An instance name the subject check refuses, or an after that is neither null nor an object, sends
|
|
// no binding field (the copy name is the only binding value the page shows).
|
|
for(const bad of [{instance:'../'+SENTINEL,after:null},{instance:'researcher',after:[SENTINEL]},{instance:'researcher',after:SENTINEL}]){
|
|
const b=setup({answer:()=>({...bindingPreview(null),binding:{...bindingPreview(null).binding,...bad}}),resolve:a=>({id:a.id,op:'resolved',choice:a.choice})});
|
|
const bc=await b.confirm('yes');assert.equal(bc.status,200,bc.text);
|
|
assert.equal(Object.hasOwn(bc.json(),'binding'),false,bc.text);
|
|
const br=await b.resolve({choice:'yes',confirmation:bc.json().confirmation});
|
|
assert.equal(Object.hasOwn(br.json(),'next'),false,br.text);
|
|
assert.equal(leaks(b,bc.text,br.text,...b.lines),0);
|
|
}
|
|
assert.equal(leaks(u,uc.text,ur.text,...u.lines,...d.lines),0);
|
|
});
|
|
test('CB5 a binding recorded and approved through the Console start names the next step by its shape, and only a binding start does',async()=>{
|
|
const s=setup({resolve:()=>({id:'b1',op:'resolved',choice:'yes',binding:{token:SENTINEL}})});
|
|
const bind={op:'change',level:'root',name:'researcher',reason:'token '+SENTINEL,binding:{services:{gitea:{scopes:['read:repository'],projects:['stack']}}}};
|
|
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:bind}}));
|
|
assert.equal(r.status,200,r.text);assert.deepEqual(r.json(),{id:'b1',op:'resolved',choice:'yes',next:BINDING_NEXT.bind});
|
|
const u=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{...bind,binding:{services:null}}}}));
|
|
assert.deepEqual(u.json(),{id:'b1',op:'resolved',choice:'yes',next:BINDING_NEXT.unbind});
|
|
const h=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}}));
|
|
assert.deepEqual(h.json(),{id:'b1',op:'resolved',choice:'yes'});
|
|
assert.equal(leaks(s,r.text,u.text,h.text,...s.lines),0);
|
|
for(const t of Object.values(BINDING_NEXT))assert.equal(leaks(s,t),0);
|
|
});
|