@bongos/core 1.24.91 → 1.24.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ // tests/cli_reauth_browser.mjs — signing the command line back in is one Approve
2
+ // click in the browser, on every instance (task 1005118).
3
+ //
4
+ // What this pins:
5
+ // - a re-sign-in's device start (approve_in_browser) gets this site's own approval
6
+ // page even where Google sign-in is OFF; a plain start keeps GitHub's flow, so
7
+ // `bongos login`'s not_approved → access-request path is untouched;
8
+ // - on a GitHub-only site the approval page offers GitHub alone (no dead Google door);
9
+ // - the CLI asks for the browser page only when told to, and prints the whole link;
10
+ // - the saved session keeps the builder's other fields for the same instance and
11
+ // never carries another instance's fields across.
12
+ //
13
+ // DB-free and network-free. Run: node tests/cli_reauth_browser.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+
20
+ for (const p of ['BONGOS', 'CLOUDBONGOS', 'GDS', 'OTB']) {
21
+ delete process.env[`${p}_GOOGLE_CLIENT_ID`];
22
+ delete process.env[`${p}_GOOGLE_CLIENT_SECRET`];
23
+ }
24
+ process.env.BONGOS_GITHUB_CLIENT_ID = process.env.BONGOS_GITHUB_CLIENT_ID || 'gh-test-client';
25
+ process.env.BONGOS_GITHUB_CLIENT_SECRET = process.env.BONGOS_GITHUB_CLIENT_SECRET || 'gh-test-secret';
26
+
27
+ const auth = require('../src/bongos/auth.js');
28
+ const { googleConfigured } = require('../src/bongos/auth-google.js');
29
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
30
+ const buildAuthRouter = require('../src/bongos/routes/auth.js');
31
+ const buildAuthGoogleRouter = require('../src/bongos/routes/auth-google.js');
32
+ const { pollBrowserDeviceFlow } = require('../src/bongos/device-approval.js');
33
+ const { runDeviceFlow } = require('../scripts/bongos/login.js');
34
+ const { nextSession } = require('../scripts/bongos/reauth.js');
35
+
36
+ let pass = 0, fail = 0;
37
+ const t = async (name, fn) => {
38
+ try { await fn(); pass++; console.log(`PASS ${name}`); }
39
+ catch (e) { fail++; console.log(`FAIL ${name}\n ${e.stack || e.message}`); }
40
+ };
41
+
42
+ function fakeRes() {
43
+ const res = {
44
+ _status: 200, _body: null, _headers: {},
45
+ status(c) { this._status = c; return this; },
46
+ type() { return this; },
47
+ send(b) { this._body = b; return this; },
48
+ json(b) { this._body = b; return this; },
49
+ set(k, v) { this._headers[k.toLowerCase()] = v; return this; },
50
+ };
51
+ attachFail({}, res, () => {});
52
+ return res;
53
+ }
54
+ function handler(router, method, path) {
55
+ const layer = router.stack.find((l) => l.route?.path === path && l.route.methods[method]);
56
+ assert.ok(layer, `route ${method.toUpperCase()} ${path} exists`);
57
+ return layer.route.stack.at(-1).handle;
58
+ }
59
+ const req = (over = {}) => ({ query: {}, headers: { host: 'localhost' }, baseUrl: '/api/bongos', body: {}, ...over });
60
+
61
+ const orig = { configured: auth.authConfigured, start: auth.startDeviceFlow, resolve: auth.resolveSession };
62
+ auth.authConfigured = () => true;
63
+ let githubStarts = 0;
64
+ auth.startDeviceFlow = async () => {
65
+ githubStarts++;
66
+ return { device_code: 'gh_dc', user_code: 'WXYZ-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 };
67
+ };
68
+ auth.resolveSession = async () => null;
69
+
70
+ await t('this test runs as a GitHub-only site', () => {
71
+ assert.equal(googleConfigured(), false);
72
+ });
73
+
74
+ await t('a re-sign-in asks for the browser page and gets this site’s own approval page, Google or not', async () => {
75
+ const res = fakeRes();
76
+ await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { approve_in_browser: true } }), res);
77
+ assert.match(res._body.verification_uri, /\/api\/bongos\/auth\/device\?code=[B-Z]{4}-[B-Z]{4}$/);
78
+ assert.equal(githubStarts, 0, 'GitHub’s code entry is not started');
79
+ assert.equal(pollBrowserDeviceFlow(res._body.device_code).state, 'pending', 'the terminal waits on this site');
80
+ });
81
+
82
+ await t('a plain start on a GitHub-only site keeps GitHub’s flow (first sign-in, access requests)', async () => {
83
+ const res = fakeRes();
84
+ await handler(buildAuthRouter(), 'post', '/auth/device/start')(req(), res);
85
+ assert.equal(res._body.verification_uri, 'https://github.com/login/device');
86
+ assert.equal(githubStarts, 1);
87
+ const truthy = fakeRes();
88
+ await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { approve_in_browser: 'yes' } }), truthy);
89
+ assert.equal(truthy._status, 400, 'only a boolean asks; anything else is refused, never guessed');
90
+ const stray = fakeRes();
91
+ await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { other: 1 } }), stray);
92
+ assert.equal(stray._status, 400, 'unknown fields are refused (ADR 0118)');
93
+ assert.equal(githubStarts, 1, 'a refused ask starts nothing');
94
+ });
95
+
96
+ await t('on a GitHub-only site the approval page offers GitHub alone', async () => {
97
+ const start = fakeRes();
98
+ await handler(buildAuthRouter(), 'post', '/auth/device/start')(req({ body: { approve_in_browser: true } }), start);
99
+ const res = fakeRes();
100
+ await handler(buildAuthGoogleRouter(), 'get', '/auth/device')(req({ query: { code: start._body.user_code } }), res);
101
+ assert.match(res._body, /Continue with GitHub/);
102
+ assert.doesNotMatch(res._body, /Continue with Google/, 'no door to a sign-in this site does not offer');
103
+ });
104
+
105
+ auth.authConfigured = orig.configured;
106
+ auth.startDeviceFlow = orig.start;
107
+ auth.resolveSession = orig.resolve;
108
+
109
+ await t('the CLI asks for the browser page only when told to, and prints the whole link', async () => {
110
+ const realFetch = globalThis.fetch;
111
+ const bodies = [];
112
+ const link = 'https://cloudbongos.com/api/bongos/auth/device?code=BCDF-GHJK';
113
+ globalThis.fetch = async (url, opts) => {
114
+ if (/device\/start$/.test(url)) {
115
+ bodies.push(opts.body);
116
+ return { ok: true, status: 200, text: async () => JSON.stringify({ device_code: 'd', user_code: 'BCDF-GHJK', verification_uri: link, expires_in: 0, interval: 0 }) };
117
+ }
118
+ if (/device\/poll$/.test(url)) {
119
+ return { ok: true, status: 200, text: async () => JSON.stringify({ state: 'ok', token: 'tok', builder: { id: 3 } }) };
120
+ }
121
+ throw new Error(`unexpected fetch ${url}`);
122
+ };
123
+ const lines = [];
124
+ // The first poll approves (the loop waits one interval first); an unknown platform
125
+ // opens no real browser.
126
+ const savedPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
127
+ Object.defineProperty(process, 'platform', { value: 'test-none' });
128
+ try {
129
+ const out = await runDeviceFlow('https://cloudbongos.com', { log: (l) => lines.push(l), browser: true });
130
+ assert.deepEqual(out, { state: 'ok', token: 'tok', builder: { id: 3 } }, 'the approval hands back the session');
131
+ await runDeviceFlow('https://cloudbongos.com', { log: () => {} });
132
+ } finally {
133
+ globalThis.fetch = realFetch;
134
+ Object.defineProperty(process, 'platform', savedPlatform);
135
+ }
136
+ assert.deepEqual(JSON.parse(bodies[0]), { approve_in_browser: true });
137
+ assert.equal(bodies[1], undefined, 'a plain login sends no ask');
138
+ assert.ok(lines.some((l) => l.includes(link)), 'the link is printed whole, never cut to the box');
139
+ });
140
+
141
+ await t('the saved session keeps the same instance’s fields and never carries another’s', () => {
142
+ const builder = { id: 3, github_login: 'test-archon', rank: 'archon' };
143
+ const same = nextSession(
144
+ { token: 'old', api_base: 'https://cloudbongos.com', gemini_api_key: 'g', builder: { id: 3 }, extra: 1 },
145
+ { token: 'new', builder, apiBase: 'https://cloudbongos.com' },
146
+ );
147
+ assert.deepEqual(same, { token: 'new', api_base: 'https://cloudbongos.com', gemini_api_key: 'g', builder, extra: 1 });
148
+ const other = nextSession(
149
+ { token: 'old', api_base: 'https://toast.cloudbongos.com', gemini_api_key: 'g', extra: 1 },
150
+ { token: 'new', builder, apiBase: 'https://cloudbongos.com' },
151
+ );
152
+ assert.deepEqual(other, { gemini_api_key: 'g', token: 'new', builder, api_base: 'https://cloudbongos.com' });
153
+ assert.deepEqual(nextSession(null, { token: 'n', builder, apiBase: 'https://x.test' }), { token: 'n', builder, api_base: 'https://x.test' });
154
+ });
155
+
156
+ console.log(`\n${pass} passed, ${fail} failed`);
157
+ if (fail) process.exit(1);
@@ -403,11 +403,20 @@ function fakeDom() {
403
403
  getElementById: (id) => byId.get(id) || null,
404
404
  createElement: (t) => mk(t),
405
405
  createElementNS: (_ns, t) => mk(t),
406
+ // the archived line, as city.html writes it (task 1005209 reads its links for the visor)
407
+ querySelector: (sel) => (sel === '.city-archived' ? archivedLine() : null),
406
408
  };
407
409
  return { document, byId, html };
408
410
  }
409
411
 
410
- async function bootPage({ feeds = {}, stored = null, osDark = true, shell = undefined, stage = null } = {}) {
412
+ // The page's own archived line: its links, from city.html, word for word.
413
+ function archivedLine() {
414
+ const line = /<p class="city-archived">([\s\S]*?)<\/p>/.exec(read('city.html'))[1];
415
+ const links = [...line.matchAll(/<a href="([^"]+)">([^<]+)<\/a>/g)].map(([, href, text]) => ({ textContent: text, getAttribute: (k) => (k === 'href' ? href : null) }));
416
+ return { querySelectorAll: () => links };
417
+ }
418
+
419
+ async function bootPage({ feeds = {}, stored = null, osDark = true, shell = undefined, stage = null, visor = undefined } = {}) {
411
420
  const { document, byId, html } = fakeDom();
412
421
  const store = new Map(stored ? [['hall-theme', stored]] : []);
413
422
  const calls = [];
@@ -425,6 +434,7 @@ async function bootPage({ feeds = {}, stored = null, osDark = true, shell = unde
425
434
  const observers = [];
426
435
  class MutationObserver { constructor(fn) { this.fn = fn; observers.push(this); } observe() {} }
427
436
  window.OTBShell = shell;
437
+ window.OTBVisor = visor;
428
438
  if (stage) { const m = document.createElement('main'); m.setAttribute('id', 'hall-main'); Object.assign(m, stage); }
429
439
  const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, console, MutationObserver });
430
440
  ctx.globalThis = ctx;
@@ -494,3 +504,33 @@ test('docket text lands as text: a title with markup is never parsed', async ()
494
504
  const p = await bootPage({ feeds: FEEDS([matter({ title: '<img src=x onerror=alert(1)>', short: '<b>x</b>' })]) });
495
505
  assert.equal(p.byId.get('cityPins').children.length, 1);
496
506
  });
507
+
508
+
509
+ // task 1005209 (ADR 0403): in game mode the city's door and its archived links are the visor's.
510
+ test("game mode: the door is the visor's lead action on D, and the archived links are in its sheet", async () => {
511
+ const scenes = [];
512
+ const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs' }), matter({ id: 'blockers:2', state: 'needs' })];
513
+ const p = await bootPage({ feeds: FEEDS(ms), visor: { scene: (sc) => scenes.push(sc) } });
514
+ const last = scenes[scenes.length - 1];
515
+ assert.equal(last.place, 'Governor city');
516
+ const [lead, street, views] = last.actions;
517
+ assert.equal(lead.label, 'Start governing', 'what the door says, before the living city reads who it waits on (hall_city_living pins the count)');
518
+ assert.deepEqual([lead.key, lead.bind, lead.lead], ['d', true, true], 'it is on the glass, and D (which nothing else binds) presses it');
519
+ assert.deepEqual([street.label, views.label], ['Archived: Street', 'Archived: Building Level Views'], "the line's own words, read off the page");
520
+ assert.ok(!street.lead && !views.lead && !street.bind && !views.bind, 'the archived links are rows in the sheet, reached by A then the keyboard');
521
+ const went = [];
522
+ p.window.location = { assign: (u) => went.push(u) };
523
+ street.run();
524
+ views.run();
525
+ assert.deepEqual(went, ['/builders/street', '/builders/street#building=charter'], 'each goes where its link went');
526
+ const n = scenes.length;
527
+ p.window.GovCityPage.draw(true);
528
+ assert.equal(scenes.length, n, 'a redraw with the same door registers nothing new');
529
+ assert.deepEqual(JSON.parse(JSON.stringify(last.keys.map((k) => k.key))), ['1–9', '← →'], "the card's own keys are still listed");
530
+ });
531
+
532
+ test('game mode: the scene carries no button of its own; classic mode keeps the door and the links', () => {
533
+ const css = read('city.css');
534
+ assert.ok(css.includes('\nhtml.visor-on .city-start, html.visor-on .city-archived { display: none; }'), 'off the scene only while the visor is up');
535
+ assert.doesNotMatch(css, /^\.city-start[^{]*\{[^}]*display: none/m, 'never hidden outside game mode');
536
+ });
@@ -534,6 +534,8 @@ test('the page reads the living feeds and draws the sky, the plates, the plaque
534
534
  const m = city();
535
535
  const start = p.byId.get('cityStart');
536
536
  assert.equal(start.textContent, m.cta.label);
537
+ const lead = p.window.__visorScene.actions[0];
538
+ assert.equal(lead.label.toLowerCase(), m.cta.label, 'in game mode the lead action on the visor says what the door says (task 1005209)');
537
539
  assert.equal(start.attrs.href, '#decide');
538
540
  assert.equal(p.byId.get('cityLine').textContent, m.line);
539
541
  for (const id of ['cityBlds', 'citySky', 'cityPins', 'cityPlaque']) assert.equal(findAll(p.byId.get(id), (n) => 'data-proto' in n.attrs).length, 0, id + ': nothing marked prototype');
@@ -66,6 +66,17 @@ test('a key means an action only when nothing else spent it', () => {
66
66
  assert.equal(V.keyFor('artisan', ev('Escape')), null, 'the ambient glass never takes Escape (ADR 0399 §2.4)');
67
67
  });
68
68
 
69
+ // task 1005209: a scene action the visor binds (bind: true) answers its key after the character's.
70
+ test("a place's own key: the visor binds it only when the scene asks, and the character's keys win", () => {
71
+ const V = loadPure();
72
+ const ev = (key) => ({ key, defaultPrevented: false, ctrlKey: false, metaKey: false, altKey: false });
73
+ const run = () => {};
74
+ V.scene({ name: 'x', place: 'X', actions: [{ label: 'Decide', key: 'D', bind: true, lead: true, run }, { label: 'Back to centre', key: '0', run }, { label: 'Claim here', key: 'c', bind: true, run }] });
75
+ assert.equal(V.keyFor('artisan', ev('d')).label, 'Decide', 'bound, and case does not matter');
76
+ assert.equal(V.keyFor('artisan', ev('0')), null, "a key the scene binds itself stays the scene's");
77
+ assert.equal(V.keyFor('artisan', ev('c')).id, 'claim', "the character's key wins a clash");
78
+ });
79
+
69
80
  // task 1005203: the four segments are the version the project is building, in quarters.
70
81
  test('progress: the starred version while open, else the one building, else none', () => {
71
82
  const V = loadPure();
@@ -146,7 +157,7 @@ test('source: the minimal visor spends the Helm tokens and types in no colour, s
146
157
  assert.ok(phone, 'the phone rules sit in the shared phone block');
147
158
  assert.match(phone[0], /\.visor__hint:not\(\[data-visor-sheet\]\) \{ display: none; \}/, 'a phone keeps only Actions of the hints');
148
159
  assert.doesNotMatch(read('home-city.css'), /visor-on/, 'Home has no top-right pill to clear any more');
149
- assert.match(read('city.css'), /\nhtml\.visor-on \.city-door \{ bottom: 150px; \}/, 'the city\'s door clears the centred button at every width');
160
+ assert.match(read('city.css'), /\nhtml\.visor-on \.city-door \{ bottom: 224px; \}/, 'the city\'s door clears the centred button at every width');
150
161
  });
151
162
 
152
163
  // ---------- in a browser: the glass over a stand-in scene ----------
@@ -260,6 +271,21 @@ test('Escape closes one layer, top first: the sheet, then the scene\'s pick, the
260
271
  await ctx.close();
261
272
  });
262
273
 
274
+ test('a lead scene action is the first key hint, and its click and its key run it (task 1005209)', { skip }, async () => {
275
+ const { ctx, page, errors } = await open();
276
+ await page.evaluate(() => OTBVisor.scene({ name: 'test', place: 'Test city', actions: [{ label: 'Decide 2 in a row', key: 'd', bind: true, lead: true, run() { window.decided = (window.decided || 0) + 1; } }] }));
277
+ const hints = await page.$$eval('.visor__hints .visor__hint', (h) => h.map((x) => x.textContent));
278
+ assert.deepEqual(hints, ['DDecide 2 in a row', 'TYour thinking', 'AActions', 'EscOptions']);
279
+ await page.click('.visor__hint--place');
280
+ await page.keyboard.press('d');
281
+ assert.equal(await page.evaluate(() => window.decided), 2, 'the hint and D both press it');
282
+ await page.evaluate(() => document.documentElement.removeAttribute('data-game'));
283
+ await page.keyboard.press('d');
284
+ assert.equal(await page.evaluate(() => window.decided), 2, 'outside game mode the glass binds no key');
285
+ assert.deepEqual(errors, []);
286
+ await ctx.close();
287
+ });
288
+
263
289
  test('the sheet lists the character\'s actions, then this place, and runs a scene action', { skip }, async () => {
264
290
  const { ctx, page } = await open();
265
291
  await page.click('[data-visor-sheet]');