// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read // the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and // the line links to /auth/login with the current path and query; any other 401 stays an error. Both // fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows // only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in // window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A // page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../../bus/src/store.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { views } from '../../bus/src/views.mjs'; import { startServer } from '../src/serve.mjs'; import { browser } from './browser.mjs'; import { fixture, close } from './fixture.mjs'; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } }; const stub = `(() => { const real = window.fetch.bind(window); window.__reqs = []; window.__signIn = []; window.__other = []; const timeout = window.setTimeout.bind(window); window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest); const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); window.fetch = async (url, init = {}) => { const path = String(url).split('?')[0]; window.__reqs.push(path); if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' }); if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' }); if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true }); return real(url, init); }; })();`; async function setup() { const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-')); const store = new Store(root), b0 = new Broker({ store, businesses }); b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' }); const reader = b0.bindReader({ business: 'demo' }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const f = await fixture(), b = await browser({ machine: true }); const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub }); await b.viewport(1280); const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`); const reqs = () => b.evaluate('window.__reqs.length'); const settle = () => new Promise(r => setTimeout(r, 600)); const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); }; return { b, base, wait, text, shown, reqs, settle, done }; } const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")'; const LINE = 'Signed out. Sign in again.'; test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => { const { b, base, wait, text, shown, reqs, settle, done } = await setup(); // The line, its link back to this page, the Sign out form gone, and no further fetch. const out = async ret => { await wait('!document.querySelector("#signed-out").hidden'); assert.equal(await text('#signed-out'), LINE); assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`); assert.equal(await shown('#sign-out'), false); assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true); const n = await reqs(); await b.evaluate('document.querySelector("#refresh").click(), true'); await settle(); assert.equal(await reqs(), n, 'the page fetched after signing out'); }; try { // app.js api(): the Board read, with sso on from /api/config. await b.navigate(`${base}?sso&x=1`); await wait(LOADED); await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`); await out('/?sso&x=1'); // app.js getJSON: the conversation catalogue behind History. await b.navigate(`${base}?sso&conv`); await wait(LOADED); await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`); await out('/?sso&conv'); // app.js api(): the board POST behind Seen. await b.navigate(`${base}?sso&seen`); await wait(LOADED); await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`); await wait('document.querySelector("#inspection [data-seen]")'); await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`); await out('/?sso&seen'); // bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from // the 401 itself (F1). await b.navigate(`${base}?read#/inbox`); await wait(LOADED); await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`); await out('/?read'); await b.navigate(`${base}?module`); await wait(LOADED); await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`); await out('/?module'); // Any other 401 is an error: no sign-out, and the polls go on. await b.navigate(`${base}?sso&other`); await wait(LOADED); await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`); await settle(); assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true); assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false); const n = await reqs(); await settle(); assert.ok(await reqs() > n, 'the refresh stopped on another 401'); await b.evaluate('document.querySelector("#avatar-button").click()'); assert.equal(await shown('#sign-out'), true); } finally { await done(); } }); test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => { const { b, base, wait, shown, done } = await setup(); try { await b.navigate(base); await wait(LOADED); await new Promise(r => setTimeout(r, 300)); assert.equal(await shown('#sign-out'), false); await b.navigate(`${base}?sso`); await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`); await b.evaluate('document.querySelector("#avatar-button").click()'); assert.equal(await shown('#sign-out'), true); assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']); } finally { await done(); } });