fix(bus,webui,cli): CB5 review fixes, binding summary on the inbox, raise and trail reads, copy-service-unbindable status, Console allowlist (#1873)
- broker: the hire.raise answer, the inbox console preview and the trail read carry a
binding as {instance, change} only; the raise event keeps the full proposal the resolve reads.
- copy-launch status(): a copy whose role needs a service a copy cannot bind reads
copy-service-unbindable (new fixed status text) instead of a gitea binding step.
- cli instances: an unbound copy's line names its missing services.
- webui serve: the instances verb rebuilds each copy binding from the fixed status table.
- bus.js reads the binding change field only.
- browser tests: system Chromium starts with --disable-extensions; the A3 booted-bus test passes it.
This commit is contained in:
1 parent
fdbc34413b
commit
56aeefb370
12 files changed
+106
-20
No files matched your search
@@ -280,6 +280,11 @@ const fail = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// #1873 CB5: a copy binding proposal leaves the bus only as its copy and its change. The copy id,
|
||||
// incarnation, definition pin, services, grant ids and slots stay in the raise event, which the
|
||||
// resolve reads; the inbox preview and the trail read carry this summary (design lines 162-163).
|
||||
const bindingSummary = (v) => (object(v?.binding)
|
||||
? { ...v, binding: { instance: v.binding.instance, change: v.binding.after === null ? 'unbind' : 'bind' } } : v);
|
||||
// Lifecycle Task 5a: keep these pure helpers here because broker.mjs is also loaded by the
|
||||
// sandbox client. A new host-only import would widen its mounted source closure.
|
||||
const createJson = value => {
|
||||
@@ -2539,7 +2544,7 @@ export class Broker {
|
||||
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
|
||||
new Set(options.map(o => o.key)).size !== options.length) return null;
|
||||
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
|
||||
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...raise.hire} : null;
|
||||
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...bindingSummary(raise.hire)} : null;
|
||||
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
|
||||
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
|
||||
const approve = options.find((o) => o.key === raise.approvalChoice);
|
||||
@@ -6784,7 +6789,7 @@ export class Broker {
|
||||
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
|
||||
});
|
||||
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
|
||||
return {...this.#decisionView(this.#decision(s,did)),hire:p};
|
||||
return {...this.#decisionView(this.#decision(s,did)),hire:bindingSummary(p)};
|
||||
}
|
||||
// The harness fact comes from the host's loaded modules; no host, no account coverage.
|
||||
#budgetCovered(b) { return authorityBudgetCovered(b,{countsAccount:this.#autonomyHost?.countsAccount}); }
|
||||
@@ -9483,6 +9488,7 @@ export class Broker {
|
||||
try {
|
||||
r[k] = JSON.parse(r[k]);
|
||||
} catch {}
|
||||
if (table === 'events' && object(r.body?.hire)) r.body = { ...r.body, hire: bindingSummary(r.body.hire) };
|
||||
rows.set(table + ':' + row.seq, r);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -381,10 +381,13 @@ export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snaps
|
||||
// { status, next }. The same checks a launch runs, without the loss report (the sweep makes it)
|
||||
// and without returning anything the check read. An approved binding not yet issued, or one a
|
||||
// refused issue left pending, needs the owner's issue before anything else. `entry` is the copy's
|
||||
// boot entry: only one bound at boot, or unbound for want of a bindable service, has a status.
|
||||
// boot entry: only one bound at boot, or unbound for want of a service, has a status. One that
|
||||
// lacks a service a copy cannot bind is refused at boot whatever it binds (above), so it reads
|
||||
// copy-service-unbindable and never asks for a gitea binding.
|
||||
status(business, instance, entry) {
|
||||
const needs = Array.isArray(entry?.bound) || (entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.some((s) => COPY_BINDABLE.includes(s)));
|
||||
if (!needs) return null;
|
||||
const unbound = entry?.state === 'unbound' && Array.isArray(entry.missing) && entry.missing.length > 0;
|
||||
if (!Array.isArray(entry?.bound) && !unbound) return null;
|
||||
if (unbound && entry.missing.some((s) => !COPY_BINDABLE.includes(s))) return copyBindingStatus('copy-service-unbindable');
|
||||
let code = 'ready';
|
||||
try {
|
||||
check(business, instance);
|
||||
|
||||
@@ -133,3 +133,40 @@ test('CB1 bus: every new refusal has a table entry with a next step', () => {
|
||||
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code);
|
||||
}
|
||||
});
|
||||
|
||||
// #1873 CB5 (security review P2-1, P3-1): the binding record the raise holds (copy id, incarnation,
|
||||
// definition pin, services, grant ids) leaves the bus only through the owner's preview verb. The
|
||||
// raise answer, the inbox preview and the trail give every other session the copy and the change.
|
||||
test('CB5 bus: the raise answer, the inbox preview and the trail carry a binding as its copy and change only', async t => {
|
||||
const f = await fixture(t);
|
||||
const d = await f.raise();
|
||||
const raised = f.store.all('SELECT body FROM events').map((e) => JSON.parse(e.body)).find((b) => b.operation === 'decision.raise' && b.decision === d.id);
|
||||
const held = raised.hire.binding;
|
||||
assert.equal(typeof held.copyId, 'string');
|
||||
const secrets = [held.copyId, held.orgIncarnation, held.definition?.sha256].filter((v) => typeof v === 'string' && v.length >= 8);
|
||||
assert.equal(secrets.length, 3);
|
||||
const summary = { instance: 'rev', change: 'bind' };
|
||||
assert.deepEqual(d.hire.binding, summary);
|
||||
const sessions = { owner: f.b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), tester: f.b.bindTester({ business: 'demo' }), reader: f.b.bindReader({ business: 'demo' }), chief: f.caps.chief, coder: f.caps.coder };
|
||||
const seen = [JSON.stringify(d)];
|
||||
for (const name of ['owner', 'tester', 'reader']) {
|
||||
const rows = await f.call(sessions[name], 'inbox');
|
||||
const row = rows.find((r) => r.id === d.id);
|
||||
assert.deepEqual(row?.console?.binding, summary, `${name} inbox`);
|
||||
seen.push(JSON.stringify(rows));
|
||||
}
|
||||
for (const name of Object.keys(sessions)) {
|
||||
const trail = await f.call(sessions[name], 'trail', { subject: d.id });
|
||||
const ev = trail.find((r) => r.table === 'events' && r.body?.operation === 'decision.raise');
|
||||
assert.deepEqual(ev?.body?.hire?.binding, summary, `${name} trail`);
|
||||
seen.push(JSON.stringify(trail));
|
||||
}
|
||||
for (const text of seen) for (const v of secrets) assert.equal(text.includes(v), false);
|
||||
// The owner's preview still has the record, and the resolve (which reads the raise) still writes it.
|
||||
const preview = await f.call(f.owner, 'hire.preview.console', { id: d.id });
|
||||
assert.equal(preview.binding.copyId, held.copyId);
|
||||
await f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: d.hire.digest });
|
||||
assert.equal(f.bindings().rev.state, 'pending');
|
||||
const after = JSON.stringify(await f.call(sessions.tester, 'trail', { subject: d.id }));
|
||||
for (const v of secrets) assert.equal(after.includes(v), false);
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { copyBindingStatus } from "../../organization/src/copy-binding-status.mjs";
|
||||
import { SERVICES } from "../../organization/src/vocabulary.mjs";
|
||||
|
||||
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
|
||||
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] [--bot BOT --bot-id N] --revision REV [--tester] | " +
|
||||
@@ -226,7 +227,9 @@ export function formatInstances(view) {
|
||||
for (const [name, copy] of Object.entries(view.copies ?? {})) {
|
||||
if (!copy?.binding) continue;
|
||||
const { status, next } = copyBindingStatus(copy.binding.status);
|
||||
lines.push(` ${name} (copy): ${status}. ${next}`);
|
||||
// The services the copy lacks, as registered service labels only; nothing else the bus sent.
|
||||
const missing = copy.state === "unbound" && Array.isArray(copy.missing) ? SERVICES.filter((s) => copy.missing.includes(s)) : [];
|
||||
lines.push(` ${name} (copy): ${status}${missing.length ? ` (missing ${missing.join(", ")})` : ""}. ${next}`);
|
||||
}
|
||||
const definitions = Object.entries(view.definitions);
|
||||
const accepted = definitions.filter(([, d]) => d.accepted).map(([name]) => name);
|
||||
|
||||
@@ -1089,3 +1089,23 @@ test("CB5 operator: the owner's instances read and mosaic org instances show eac
|
||||
assert.deepEqual(leaks(said.join("")), []);
|
||||
assert.deepEqual(leaks([...first.logs, ...s.logs].map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n")), []);
|
||||
});
|
||||
|
||||
// #1873 CB5 review: a shipped role that needs gitea and vikunja. Boot denies such a copy whatever it
|
||||
// binds, so its status must not send the owner to approve a gitea binding.
|
||||
test("CB5 operator: a copy whose role needs a service a copy cannot bind reads copy-service-unbindable and names its missing services", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const file = join(p.root, "config", "agents.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
doc.agents.rev = { definition: "reviewer", vars: VARS };
|
||||
writeJson(file, doc, 0o600);
|
||||
const s = await start(t, p);
|
||||
const v = s.call("instances");
|
||||
assert.equal(v.copies.rev.state, "unbound");
|
||||
assert.deepEqual(v.copies.rev.binding, { status: "copy-service-unbindable", next: COPY_BINDING_STATUS["copy-service-unbindable"] });
|
||||
// The gitea-only copy beside it still reads credential-unbound.
|
||||
assert.deepEqual(v.copies.researcher.binding, { status: "credential-unbound", next: COPY_BINDING_STATUS["credential-unbound"] });
|
||||
const text = formatInstances(v);
|
||||
assert.ok(text.includes(` rev (copy): copy-service-unbindable (missing gitea, vikunja). ${COPY_BINDING_STATUS["copy-service-unbindable"]}\n`), text);
|
||||
assert.ok(text.includes(` researcher (copy): credential-unbound (missing gitea). ${COPY_BINDING_STATUS["credential-unbound"]}\n`), text);
|
||||
assert.equal(COPY_BINDING_STATUS["copy-service-unbindable"].includes("credentials.gitea"), false);
|
||||
});
|
||||
@@ -9,6 +9,8 @@ export const COPY_BINDING_STATUS = Object.freeze({
|
||||
"issue-required": "The binding is approved; the owner must issue the copy's token with mosaic credentials issue, then restart the organization bus.",
|
||||
"restart-required": "Restart the organization bus so it verifies the binding before the copy launches.",
|
||||
"credential-unbound": "credentials.gitea needs an owner-approved binding.",
|
||||
// Boot denies a copy whose role needs a service a copy cannot bind yet, whatever it binds.
|
||||
"copy-service-unbindable": "this copy's role needs a credential service a copy cannot bind yet, so it cannot launch in this organization; binding gitea alone does not change that.",
|
||||
"credential-revoking": "credentials.gitea revocation is in progress; read the copy's status again when it finishes.",
|
||||
"credential-revoke-failed": "credentials.gitea revocation is incomplete; the owner must retry cleanup.",
|
||||
"credential-revoked": "credentials.gitea was revoked; the owner must approve a new binding.",
|
||||
|
||||
@@ -64,7 +64,8 @@ export const BINDING_NEXT = Object.freeze({
|
||||
unbind: "Unbind approved. The bus stops the copy's runs and revokes its credential. The copy's status on the Agents grid shows when the revoke finishes.",
|
||||
});
|
||||
// A binding decision's preview names its copy and whether it binds or unbinds. Only those two leave
|
||||
// this server: the copy id, incarnation, definition pin and services stay with the bus.
|
||||
// this server: the copy id, incarnation, definition pin and services stay with the bus. The owner's
|
||||
// preview verb is the one bus answer that carries them; the inbox and the trail send the same two.
|
||||
const bindingOf = (v) => (v?.binding && typeof v.binding === 'object' && subjectOk(v.binding.instance) &&
|
||||
(v.binding.after === null || (typeof v.binding.after === 'object' && !Array.isArray(v.binding.after)))
|
||||
? { instance: v.binding.instance, change: v.binding.after === null ? 'unbind' : 'bind' } : null);
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
// #1873 CB5: a copy credential binding decision names its copy and whether it binds or unbinds;
|
||||
// the scope lines above are the bus's computed ones. The confirmation says `change`; the inbox
|
||||
// row carries the bus's preview, where an unbind is `after: null`.
|
||||
const bindingChange = b => b.change === 'unbind' || (b.change === undefined && b.after === null) ? 'unbind' : 'bind';
|
||||
const bindingChange = b => b.change === 'unbind' ? 'unbind' : 'bind';
|
||||
const bindingLine = c => c?.binding && typeof c.binding === 'object' ? `<h3>Credential binding</h3><p data-binding-change="${bindingChange(c.binding)}">${bindingChange(c.binding) === 'unbind' ? 'Unbind and revoke the credentials of' : 'Bind credentials for'} copy <code>${txt(c.binding.instance)}</code>.</p>` : '';
|
||||
const hireAdjustment = c => {
|
||||
if(c?.kind!=='hire' || !c.role)return '';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { THREAD_DEFINITION, THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
|
||||
import { copyBindingStatus } from '../../organization/src/copy-binding-status.mjs';
|
||||
import { OnboardingError, approveNorthStarDraft, interviewMessage, loadInterviewTemplate, northStarTemplateText } from '../../project/src/onboarding.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { projectArchiveHTTP } from './project-archive-http.mjs';
|
||||
@@ -179,7 +180,12 @@ async function busRead(res, bus, verb, search) {
|
||||
try {
|
||||
const value = await bus.instances();
|
||||
if (!/^[a-f0-9]{64}$/.test(value?.revision ?? '') || !value.instances || typeof value.instances !== 'object' || !value.definitions || typeof value.definitions !== 'object') throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { rows: [value], at: new Date().toISOString() });
|
||||
// #1873 CB5: a copy's binding status reaches the page as the fixed table's { status, next }
|
||||
// for its code, never as text the bus sent; an unknown code reads credential-unavailable.
|
||||
const copies = value.copies && typeof value.copies === 'object' && !Array.isArray(value.copies)
|
||||
? Object.fromEntries(Object.entries(value.copies).map(([n, c]) => [n, c && typeof c === 'object' && 'binding' in c ? { ...c, binding: copyBindingStatus(c.binding?.status) } : c]))
|
||||
: value.copies;
|
||||
return json(res, 200, { rows: [copies === undefined ? value : { ...value, copies }], at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: code });
|
||||
|
||||
@@ -19,10 +19,13 @@ process.once('exit', () => { for (const child of live) killGroup(child); });
|
||||
// so by default every page this browser opens starts with Show machine sessions on, as if the
|
||||
// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
|
||||
// `args` adds Chromium flags, for example --host-resolver-rules for a test host name.
|
||||
// #1873 CB5: the system Chromium starts with --disable-extensions, so no host extension (and no
|
||||
// native host it would start) runs in a test. A CHROMIUM launcher is the zoom tests' wrapper, which
|
||||
// loads their one extension with --disable-extensions-except, so it gets no blanket flag.
|
||||
export async function browser({ machine = true, args = [] } = {}) {
|
||||
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
|
||||
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
|
||||
'--headless', '--no-first-run', '--no-default-browser-check',
|
||||
'--headless', '--no-first-run', '--no-default-browser-check', ...(process.env.CHROMIUM ? [] : ['--disable-extensions']),
|
||||
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ...args,
|
||||
], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
|
||||
live.add(child);
|
||||
|
||||
@@ -50,14 +50,15 @@ for(const width of [1440,390])test(`A4 Console hire start and narrowed confirmat
|
||||
posts=await b.evaluate('window.__posts');assert.deepEqual(Object.keys(posts.find(x=>x.p.endsWith('/resolve')).body).sort(),['choice','confirmation']);
|
||||
});
|
||||
// #1873 CB5: a copy credential binding decision on its decision page. The inbox row carries the bus's
|
||||
// preview (an unbind is `after: null`); the page names the copy and the change and lists the computed
|
||||
// scope lines, the confirmation says the change, and the receipt adds decision-http's fixed next step.
|
||||
// No value besides the copy's instance reaches the page: a sentinel in every other binding field never shows.
|
||||
// summary, the copy and its change; the page names both and lists the computed scope lines, the
|
||||
// confirmation says the change, and the receipt adds decision-http's fixed next step. Nothing besides
|
||||
// the copy's instance reaches the page: a sentinel in any other field never shows, and an `after`
|
||||
// that contradicts `change` (a bus older than the summary) never decides the line.
|
||||
for(const change of ['bind','unbind'])test(`CB5 Console binding decision: ${change} confirmation and receipt`,{timeout:120000},async t=>{
|
||||
const SENTINEL='cb5-sentinel-7f3a9c41d2e8b6a0';
|
||||
const f=await fixture(),b=await browser({machine:false,args:['--disable-extensions']});t.after(async()=>{await b.close();await f.close();});
|
||||
const after=change==='unbind'?null:{gitea:{scopes:['read:repository'],projects:['web'],slot:SENTINEL}};
|
||||
const binding={instance:'acme/researcher',source:'root',copyId:SENTINEL,fresh:false,orgIncarnation:SENTINEL,definition:SENTINEL,before:{gitea:{grantId:SENTINEL}},after,generation:2};
|
||||
const after=change==='unbind'?{gitea:{scopes:['read:repository'],projects:['web'],slot:SENTINEL}}:null;
|
||||
const binding={instance:'acme/researcher',change,copyId:SENTINEL,orgIncarnation:SENTINEL,definition:SENTINEL,before:{gitea:{grantId:SENTINEL}},after};
|
||||
const hire={kind:'hire',flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',binding,options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]};
|
||||
const d={id:'d2',at:'2026-11-12T00:00:00.000Z',raised_by_role:'jason',raised_by_run:null,class:'gated',action:'role.hire',route_to:'human',question:`${change} credentials for copy acme/researcher?`,options:hire.options,blocking:false,console:hire};
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{
|
||||
|
||||
@@ -546,7 +546,7 @@ test('agents grid takes a running copy, an unbound copy and a refused project fr
|
||||
const root = { state: 'ok', ...checkRootAgents(rootDoc, { rolesDir }) };
|
||||
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
|
||||
await waitFor(b)('document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]").length === 4');
|
||||
@@ -630,7 +630,9 @@ test('agents grid shows a running copy, an unbound copy and a refused project wi
|
||||
// #1873 CB5: a copy that needs a credential binding carries `binding` from its bus: the status code and
|
||||
// the owner's next step, copy-binding-status.mjs's fixed text. A booted copy whose binding is revoked
|
||||
// can't launch and says the next step; an unbound copy missing gitea adds the step after the services;
|
||||
// a ready binding launches as before with no reason line.
|
||||
// a copy whose role also needs a service a copy cannot bind says so, without asking for a gitea
|
||||
// binding; a ready binding launches as before with no reason line. The Console rebuilds each binding
|
||||
// from its status code, so text the bus put in `next` (a sentinel here) never reaches the page.
|
||||
test('agents grid shows a copy\'s binding status and next step, and a ready binding launches', async t => {
|
||||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||||
const { COPY_BINDING_STATUS } = await import('../../organization/src/copy-binding-status.mjs');
|
||||
@@ -641,12 +643,13 @@ test('agents grid shows a copy\'s binding status and next step, and a ready bind
|
||||
} }, { rolesDir }) };
|
||||
const DOCS = { ddigital: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} }, forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} } };
|
||||
const copy = (name, state, extra = {}) => ({ name, level: 'root', project: null, definition: name === 'reviewer' ? 'reviewer' : 'research', state, running: [], ...extra });
|
||||
const binding = (status) => ({ status, next: COPY_BINDING_STATUS[status] });
|
||||
const SENTINEL = 'cb5-bus-next-sentinel-4c19e2';
|
||||
const binding = (status) => ({ status, next: `${SENTINEL} ${status}`, extra: SENTINEL });
|
||||
const copies = {
|
||||
ddigital: { researcher: copy('researcher', 'instance', { bound: ['gitea'], binding: binding('credential-revoked') }),
|
||||
reviewer: copy('reviewer', 'unbound', { missing: ['gitea'], binding: binding('issue-required') }) },
|
||||
forge: { researcher: copy('researcher', 'instance', { bound: ['gitea'], running: ['r4'], binding: binding('ready') }),
|
||||
reviewer: copy('reviewer', 'unbound', { missing: ['vikunja'] }) },
|
||||
reviewer: copy('reviewer', 'unbound', { missing: ['gitea', 'vikunja'], binding: binding('copy-service-unbindable') }) },
|
||||
};
|
||||
const orgs = workspaceOrgs({ copies });
|
||||
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
|
||||
@@ -665,8 +668,9 @@ test('agents grid shows a copy\'s binding status and next step, and a ready bind
|
||||
assert.deepEqual(why, {
|
||||
'ddigital:researcher': `researcher: ${COPY_BINDING_STATUS['credential-revoked']}`,
|
||||
'ddigital:reviewer': `reviewer: no binding in this organization for gitea. ${COPY_BINDING_STATUS['issue-required']}`,
|
||||
'forge:reviewer': 'reviewer: no binding in this organization for vikunja',
|
||||
'forge:reviewer': `reviewer: no binding in this organization for gitea, vikunja. ${COPY_BINDING_STATUS['copy-service-unbindable']}`,
|
||||
});
|
||||
assert.equal((await b.evaluate('document.body.innerText')).includes(SENTINEL), false);
|
||||
const rows = await b.evaluate('Object.fromEntries([...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [tr.cells[0].innerText.trim().split(/\\s/)[0], tr.cells[4].innerText.replace(/\\s+/g, " ").trim()]))');
|
||||
assert.equal(rows.researcher, `▲ can't launch ${COPY_BINDING_STATUS['credential-revoked']}`);
|
||||
assert.equal(rows.reviewer, `▲ can't launch no binding in this organization for gitea. ${COPY_BINDING_STATUS['issue-required']}`);
|
||||
|
||||
Reference in new issue
Block a user