#867: organization reads no longer take the registry writer lock; they retry while the lock exists or the registry changed during the read. #868: a broken organization is listed as invalid with a path-masked reason instead of disappearing; the Board, header and picker show it, and /api/orgs 503 carries the reason. Orgs the principal may not see and archived orgs still give an empty list. #869: a Console with no board says "No board", not "Board: null". #866 (save-path validation at bus admission) is left out: it refused documents the bus accepted before and needs a migration story first. Every bus, business, cli and webui file run serially; failures are only the known EISDIR node_modules-link files.
This commit is contained in:
1 parent
606911834e
commit
0e9b2c505d
10 files changed
+166
-38
No files matched your search
@@ -131,7 +131,21 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
|
||||
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
|
||||
const doc = JSON.parse(raw); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
|
||||
}
|
||||
async function readCurrent(ctx, id, opts) { return withRegistry(ctx, () => readCurrentUnlocked(ctx, id, opts), { write: false }); }
|
||||
// Readers do not take the writer lock. Require unchanged registry snapshots and
|
||||
// no in-progress publication on either side of the read; retry a raced write.
|
||||
async function readCurrent(ctx, id, opts) {
|
||||
const locked = async () => { try { await fs.lstat(join(ctx.configDir, 'org-runtime.lock')); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (await locked()) { await new Promise(r => setTimeout(r, 10)); continue; }
|
||||
const before = JSON.stringify(await readRegistry(ctx));
|
||||
let result, error;
|
||||
try { result = await readCurrentUnlocked(ctx, id, opts); } catch (e) { error = e; }
|
||||
const after = JSON.stringify(await readRegistry(ctx));
|
||||
if (!(await locked()) && before === after) { if (error) throw error; return result; }
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
fail('org-locked', 'registry changed or was locked during organization read');
|
||||
}
|
||||
export async function createOrg(ctx, id, input) {
|
||||
authorize(ctx); checkId(id); if (input?.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner', '$.human');
|
||||
const doc = normalizeDoc(ctx, id, input); await checked(doc);
|
||||
|
||||
@@ -51,6 +51,19 @@ test('broker organization writer updates the registry revision and remains reada
|
||||
assert.deepEqual(after.doc.plugins.channel, ['cli']); assert.equal(after.revision, before.revision + 1);
|
||||
});
|
||||
|
||||
test('reading beta does not contend with an alpha document writer', async () => {
|
||||
const c = context();
|
||||
for (const id of ['alpha', 'beta']) await createOrg(c, id, await templateRender(id, { fixture: 'local' }));
|
||||
const writer = organizationWriter({ dir: c.configDir, rolesDir: REPO_ROLES });
|
||||
const { document, revision } = writer.read('alpha'); document.plugins.channel = ['cli'];
|
||||
let attempted = false, error;
|
||||
const attempt = () => { if (attempted) return; attempted = true; try { writer.write('alpha', document, { revision, actor: c.principal.sub }); } catch (e) { error = e; } };
|
||||
const watcher = watch(c.configDir, (_event, name) => { if (name === 'org-runtime.lock') attempt(); });
|
||||
try { await showOrg(c, 'beta'); attempt(); } finally { watcher.close(); }
|
||||
if (error) throw error;
|
||||
assert.deepEqual((await showOrg(c, 'alpha')).doc.plugins.channel, ['cli']);
|
||||
});
|
||||
|
||||
test('setup never overwrites a broker write committed while git initializes', async () => {
|
||||
const c = context(); await createOrg(c, 'alpha', await templateRender('alpha', { fixture: 'local' }));
|
||||
const writer = organizationWriter({ dir: c.configDir, rolesDir: REPO_ROLES });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { plainMessage } from './org-admin.mjs';
|
||||
// The registry and service are injected here so their single, shared implementations remain
|
||||
// the authority. The browser supplies only an id; roots and sockets come from verified metadata.
|
||||
export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths, busStatus, options }) {
|
||||
@@ -10,19 +11,27 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat
|
||||
if (org.state !== 'active') throw new Error('org-unavailable');
|
||||
return { org, shown, context };
|
||||
}
|
||||
return Object.freeze({
|
||||
async list(session) {
|
||||
const entries = await listOrgs(ctx), result = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.state !== 'active') continue;
|
||||
let verified;
|
||||
try { verified = await admitted(entry.id, session); } catch { continue; }
|
||||
let busHealthy = false;
|
||||
try { busHealthy = (await busStatus(ctx, entry.id)).healthy === true; } catch { /* offline metadata remains visible */ }
|
||||
result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy });
|
||||
async function list(session) {
|
||||
const entries = await listOrgs(ctx), result = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.state !== 'active') continue;
|
||||
let verified;
|
||||
try { verified = await admitted(entry.id, session); }
|
||||
catch (error) {
|
||||
// Do not reveal entries to an unauthorized principal. An admitted
|
||||
// owner's broken registry or document must not look like no orgs.
|
||||
if (error?.code === 'org-forbidden' || error?.message === 'org-unavailable') continue;
|
||||
result.push({ id: entry.id, label: entry.id, invalid: true, reason: plainMessage(ctx, error?.message || 'organization admission refused') });
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
let busHealthy = false;
|
||||
try { busHealthy = (await busStatus(ctx, entry.id)).healthy === true; } catch { /* offline metadata remains visible */ }
|
||||
result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return Object.freeze({
|
||||
list,
|
||||
async resolve(id, session) {
|
||||
const { org, shown, context } = await admitted(id, session);
|
||||
let busHealthy = false;
|
||||
|
||||
@@ -600,5 +600,5 @@
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); $('local-sign-out').hidden = true; $('avatar-name').textContent = 'Operator'; $('avatar-button').querySelector('.avatar-initial').textContent = 'O'; } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
api('/api/config').then(config => { board = config.board || 'unknown'; $('board-url').textContent = config.board ? `Board: ${board}` : 'No board'; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); $('local-sign-out').hidden = true; $('avatar-name').textContent = 'Operator'; $('avatar-button').querySelector('.avatar-initial').textContent = 'O'; } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
@@ -565,16 +565,21 @@
|
||||
// role, so it shows only where a send can pass (`send` in /api/config). A refusal keeps nothing;
|
||||
// any other failure keeps the last read, labelled with its time, as the bus pages do.
|
||||
let heldErr = null, heldHtml = null, focusSend = false;
|
||||
let heldOrganizations = { empty: false, invalid: [], error: null };
|
||||
function heldTable(claims) {
|
||||
const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
function paintHeld(err, noOrganizations = false) {
|
||||
function paintHeld(err, noOrganizations, invalidOrganizations, organizationError) {
|
||||
heldErr = err;
|
||||
if (noOrganizations !== undefined) heldOrganizations = { empty: noOrganizations, invalid: invalidOrganizations, error: organizationError };
|
||||
({ empty: noOrganizations, invalid: invalidOrganizations, error: organizationError } = heldOrganizations);
|
||||
const box = $('held'), r = last.get('agents');
|
||||
let html;
|
||||
if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings > Organizations</a>.');
|
||||
if (organizationError) html = `<div class="banner err" role="alert"><p>Organization registry could not be read: ${txt(organizationError)}</p>${retry}</div>`;
|
||||
else if (invalidOrganizations.length) html = `<div class="banner err" role="alert">${invalidOrganizations.map(row => `<p>${txt(row.id)}: invalid — ${txt(row.reason)}</p>`).join('')}${retry}</div>`;
|
||||
else if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings > Organizations</a>.');
|
||||
else if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
|
||||
else if (!r) return; // not read yet: the skeleton stays
|
||||
else {
|
||||
@@ -583,7 +588,7 @@
|
||||
${claims.length ? heldTable(claims) : empty('No role is held on the bus right now.', 'A role shows here while a run holds its claim on the bus. Machine sessions are under Show machine sessions.')}
|
||||
<p class="small muted">Read from the bus ${when(r.at)}.</p>`;
|
||||
}
|
||||
$('held-count').textContent = err && (refusal(err) || !r) ? '–' : String(r.rows.length);
|
||||
$('held-count').textContent = organizationError || invalidOrganizations.length || (err && (refusal(err) || !r)) || !r ? '–' : String(r.rows.length);
|
||||
if (html === heldHtml) return; // unchanged: no repeated alert, focus stays put
|
||||
// Keep focus on the same control across the 10 s repaint.
|
||||
const a = document.activeElement, k = box.contains(a) ? { role: a.dataset?.messageRole, retry: a.hasAttribute?.('data-retry'), wrap: a.classList?.contains('table-wrap') } : null;
|
||||
@@ -703,7 +708,7 @@
|
||||
async function businessView(get) {
|
||||
const b = (await get.api('business')).business || {};
|
||||
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
|
||||
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}>${txt(org.label || org.id)}${org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
|
||||
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
|
||||
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
|
||||
const launch = b.launch || {};
|
||||
const authKnown = instances.some(i => i.authority);
|
||||
@@ -1459,7 +1464,7 @@
|
||||
});
|
||||
const initialOrganizations = await window.MosaicInitialOrganizations;
|
||||
if (g !== gen) return;
|
||||
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null), !window.MosaicOrg && Array.isArray(initialOrganizations) && initialOrganizations.length === 0);
|
||||
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null), !window.MosaicOrg && Array.isArray(initialOrganizations) && initialOrganizations.length === 0, !window.MosaicOrg && Array.isArray(initialOrganizations) ? initialOrganizations.filter(row => row.invalid) : [], !window.MosaicOrg ? window.MosaicOrganizationError : null);
|
||||
sections(path); schedule();
|
||||
return;
|
||||
}
|
||||
@@ -1531,7 +1536,10 @@
|
||||
if (e.target.closest('#cmdk')) { pkOpen(); return; }
|
||||
const message = e.target.closest('#held [data-message-role]');
|
||||
if (message) { draft.to = message.dataset.messageRole; focusSend = true; location.hash = '#/inbox'; return; }
|
||||
if (e.target.closest('#bus-view [data-retry], #held [data-retry]')) render(false);
|
||||
if (e.target.closest('#bus-view [data-retry], #held [data-retry]')) {
|
||||
if (!window.MosaicOrg && window.MosaicRefreshOrganizations) window.MosaicRefreshOrganizations();
|
||||
render(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- toast
|
||||
|
||||
@@ -7,17 +7,29 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
const id = match?.[1] ?? null;
|
||||
if (!id) {
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const initialOrganizations = nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
window.MosaicInitialOrganizations = initialOrganizations;
|
||||
initialOrganizations.then(async rows => {
|
||||
if (!Array.isArray(rows) || !rows.length || location.hash.startsWith('#/orgs/')) return;
|
||||
let preferred;
|
||||
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
|
||||
const selected = rows.find(row => row.id === preferred) ?? rows[0];
|
||||
const tail = location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
|
||||
history.replaceState(null, '', `#/orgs/${encodeURIComponent(selected.id)}${tail}`);
|
||||
location.reload();
|
||||
}).catch(() => {});
|
||||
const readInitialOrganizations = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
|
||||
if (r.ok) { window.MosaicOrganizationError = null; return r.json(); }
|
||||
const body = await r.json().catch(() => ({}));
|
||||
window.MosaicOrganizationError = body.message || 'Organization registry could not be read';
|
||||
return null;
|
||||
}).catch(() => { window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
|
||||
const refreshInitial = () => {
|
||||
const initialOrganizations = window.MosaicInitialOrganizations = readInitialOrganizations();
|
||||
initialOrganizations.then(async rows => {
|
||||
if (!Array.isArray(rows) || !rows.length || location.hash.startsWith('#/orgs/')) return;
|
||||
rows = rows.filter(row => !row.invalid);
|
||||
if (!rows.length) return;
|
||||
let preferred;
|
||||
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
|
||||
const selected = rows.find(row => row.id === preferred) ?? rows[0];
|
||||
const tail = location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
|
||||
history.replaceState(null, '', `#/orgs/${encodeURIComponent(selected.id)}${tail}`);
|
||||
location.reload();
|
||||
}).catch(() => {});
|
||||
return initialOrganizations;
|
||||
};
|
||||
window.MosaicRefreshOrganizations = refreshInitial;
|
||||
refreshInitial();
|
||||
return;
|
||||
}
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
@@ -30,6 +42,12 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
let list = readList();
|
||||
const refreshList = () => (list = readList());
|
||||
const paintHeader = rows => {
|
||||
const invalid = rows.filter(row => row.invalid);
|
||||
if (invalid.length) {
|
||||
const banner = document.createElement('div'); banner.className = 'banner err'; banner.setAttribute('role', 'alert');
|
||||
banner.textContent = invalid.map(row => `${row.id}: invalid — ${row.reason}`).join('; ');
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
const org = rows.find(row => row.id === decodeURIComponent(id));
|
||||
const mark = document.querySelector('.wordmark');
|
||||
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
|
||||
|
||||
@@ -17,7 +17,7 @@ import { themesHTTP } from './themes-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||||
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
|
||||
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
|
||||
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
|
||||
import { orgAdminHTTP, defaultOrgAdmin, plainMessage } from './org-admin.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -831,7 +831,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (orgList && !hook) {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
try { return json(res, 200, await organizations.list(currentSession)); }
|
||||
catch { return json(res, 503, { error: 'org-unavailable' }); }
|
||||
catch (error) { return json(res, 503, { error: 'org-unavailable', message: plainMessage(null, error?.message || 'Organization registry could not be read') }); }
|
||||
}
|
||||
if (orgList && hook) orgList = false;
|
||||
let selected;
|
||||
|
||||
@@ -6,7 +6,7 @@ test('organization adapter rechecks authority and verifies roots before selectin
|
||||
const checks = [], ctx = { configDir: '/cfg', dataRoot: '/data', instance: 'test' };
|
||||
const adapter = bus.organizationConsole({ ctx,
|
||||
listOrgs: async () => [{ id: 'alpha', state: 'active' }, { id: 'beta', state: 'active' }, { id: 'reserved', state: 'reserved' }],
|
||||
showOrg: async (context, id) => { checks.push([id, context.principal.sub]); if (id === 'beta') throw Error('denied'); return { doc: { appearance: { theme: 'harbor' } } }; },
|
||||
showOrg: async (context, id) => { checks.push([id, context.principal.sub]); if (id === 'beta') throw Object.assign(Error('denied'), { code: 'org-forbidden' }); return { doc: { appearance: { theme: 'harbor' } } }; },
|
||||
resolveOrg: async (_, id) => ({ id, root: `/verified/${id}`, state: 'active' }),
|
||||
orgPaths: root => ({ socket: `${root}/bus/broker.sock` }),
|
||||
busStatus: async (_, id) => ({ healthy: id === 'alpha' }),
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { consoleReads } from '../src/reads.mjs';
|
||||
import { recordBootRefused, recordBootOk } from '../../cli/src/boot-status.mjs';
|
||||
import { createOrg, templateRender } from '../../business/src/org-service.mjs';
|
||||
import { createOrg, templateRender, showOrg, archiveOrg } from '../../business/src/org-service.mjs';
|
||||
import { busStopForDelete } from '../../cli/src/org-bus.mjs';
|
||||
import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs';
|
||||
import { organizationConsole } from '../src/organizations.mjs';
|
||||
|
||||
test('admission errors are visible while a healthy organization remains usable', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'org-admission-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const ctx = { configDir, dataRoot, instance: 'readtest', principal };
|
||||
for (const id of ['alpha', 'beta']) await createOrg(ctx, id, await templateRender(id));
|
||||
const file = join(configDir, 'org-runtime.json'), registry = JSON.parse(readFileSync(file, 'utf8'));
|
||||
registry.orgs.alpha.digest = '0'.repeat(64); writeFileSync(file, JSON.stringify(registry));
|
||||
const console = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}), busStatus: async () => ({ healthy: true }), options: ({ id }) => ({ id }) });
|
||||
const rows = await console.list(principal);
|
||||
assert.equal(rows.find(row => row.id === 'alpha')?.invalid, true);
|
||||
assert.match(rows.find(row => row.id === 'alpha')?.reason, /document and registry disagree/);
|
||||
assert.equal((await console.resolve('beta', principal)).id, 'beta');
|
||||
});
|
||||
|
||||
test('an owner whose only organization is archived gets an empty list, not an error', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'org-archived-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const ctx = { configDir, dataRoot, instance: 'readtest', principal };
|
||||
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
const { revision } = await showOrg(ctx, 'alpha');
|
||||
await archiveOrg(ctx, 'alpha', { ifRevision: revision, busStop: busStopForDelete });
|
||||
const console = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}), busStatus: async () => ({ healthy: true }), options: ({ id }) => ({ id }) });
|
||||
assert.deepEqual(await console.list(principal), []);
|
||||
});
|
||||
|
||||
test('offline boot status comes from the selected organization root', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'org-reads-'));
|
||||
|
||||
@@ -6,8 +6,36 @@ import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
test('organization header and footer never display null board values', async t => {
|
||||
const organizations = { list: async () => [{ id: 'alpha', label: 'alpha', busHealthy: true }], resolve: async () => ({ reads: {}, bus: {} }) };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
for (const [width, height] of [[1400, 900], [390, 844]]) {
|
||||
await b.viewport(width, height);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(/Board:|No board/.test(document.querySelector('#board-url').textContent))return;await new Promise(r=>setTimeout(r,30))}throw Error('footer not loaded')})()`);
|
||||
const text = await b.evaluate(`document.querySelector('header').textContent + ' ' + document.querySelector('footer').textContent`);
|
||||
assert.doesNotMatch(text, /\b(?:null|undefined)\b/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('an invalid organization is explained instead of appearing as an empty registry', async t => {
|
||||
const organizations = { list: async () => [{ id: 'alpha', label: 'alpha', invalid: true, reason: 'organization document and registry disagree' }], resolve: async () => { throw Error('unavailable'); } };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
for (const [width, height] of [[1400, 900], [390, 844]]) {
|
||||
await b.viewport(width, height);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held [role=alert]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('admission error missing')})()`);
|
||||
const body = await b.evaluate('document.body.textContent');
|
||||
assert.match(body, /alpha.*invalid.*document and registry disagree/is);
|
||||
assert.doesNotMatch(body, /No organizations yet/);
|
||||
}
|
||||
});
|
||||
|
||||
test('fresh dashboard points to organization setup while real list errors remain errors', async t => {
|
||||
for (const broken of [false, true]) {
|
||||
for (let broken of [false, true]) {
|
||||
const organizations = { list: async () => { if (broken) throw Error('registry down'); return []; }, resolve: async () => null };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, board: 'http://127.0.0.1:9', log: () => {} });
|
||||
const b = await browser({ machine: false });
|
||||
@@ -15,7 +43,11 @@ test('fresh dashboard points to organization setup while real list errors remain
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held .banner, #held a[href="#/settings/organizations"]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('held roles did not settle')})()`);
|
||||
const held = await b.evaluate(`({ text: document.querySelector('#held').textContent, error: !!document.querySelector('#held .banner.err, #held .banner.ref'), link: !!document.querySelector('#held a[href="#/settings/organizations"]') })`);
|
||||
if (broken) assert.equal(held.error, true, 'a real registry error remains visible');
|
||||
if (broken) {
|
||||
assert.equal(held.error, true, 'a real registry error remains visible'); assert.match(held.text, /registry down/);
|
||||
broken = false; await b.evaluate(`document.querySelector('#held [data-retry]').click()`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#held a[href="#/settings/organizations"]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('registry retry did not recover')})()`);
|
||||
}
|
||||
else { assert.match(held.text, /No organizations yet/); assert.equal(held.link, true); assert.equal(held.error, false); }
|
||||
} finally { await b.close(); await close(server); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user