@bongos/core 1.25.23 → 1.25.25

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.
@@ -24,13 +24,9 @@ test('switchAccount adds prompt=select_account; a plain sign-in does not', () =>
24
24
  assert.equal(sw.searchParams.get('scope'), plain.searchParams.get('scope'));
25
25
  });
26
26
 
27
- test('the start route reads ?switch=1 and passes it to the GitHub leg', () => {
28
- const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/auth.js'), 'utf8');
29
- assert.match(src, /const wantsSwitch = req\.query\.switch === '1'/);
30
- assert.match(src, /switchAccount: wantsSwitch/);
31
- // a switch must reach GitHub, never short-circuit through the live-session handoff
32
- assert.match(src, /if \(!wantsRepos && !wantsSwitch\) \{/);
33
- });
27
+ // The start route itself is now RUN, not read (task 1005283): tests/github_signup_handoff.mjs
28
+ // calls GET /auth/web/start with ?switch=1 and asserts the redirect carries
29
+ // prompt=select_account and goes to GitHub, never through the live-session handoff.
34
30
 
35
31
  test('the landing carries the New account door beside Sign in', () => {
36
32
  const html = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/index.html'), 'utf8');
@@ -229,7 +229,7 @@ function world({ withGovernment = true } = {}) {
229
229
  lifecycle, government, now, documents: null,
230
230
  });
231
231
  return {
232
- store, tasks, board, opened, deps,
232
+ store, tasks, board, opened, deps, government,
233
233
  finish: (s) => tasks[s].forEach((t) => { t.status = 'shipped'; }),
234
234
  pass: (s) => { board[s] = { state: 'passed', item_id: '9', waiting_on: [] }; },
235
235
  tick: (days) => { now = new Date(now.getTime() + days * 86400000); },
@@ -251,7 +251,7 @@ test('decide → five stages through the Board Room → alive, and the hall open
251
251
  g = await gh.readGenesis(w.deps());
252
252
  assert.equal(g.stage, 'scope');
253
253
  assert.equal(g.can_close, false, 'a stage with unfinished tasks cannot be put to the board');
254
- assert.equal((await gh.savePlan({}, 1, w.deps())).reason, 'not_deciding', 'the plan is settled once');
254
+ assert.equal((await gh.savePlan({}, 1, w.deps())).revised, false, 'the same plan again changes nothing (task 1005107)');
255
255
 
256
256
  for (const s of STAGES5) {
257
257
  assert.equal((await gh.readGenesis(w.deps())).stage, s);
@@ -446,6 +446,48 @@ test('task 1005115: each stage on the genesis home carries its document', async
446
446
  assert.equal(g.stages[0].document, null);
447
447
  });
448
448
 
449
+ test('task 1005107: the plan can be changed after decide; the same plan again changes nothing', async () => {
450
+ const w = world();
451
+ w.government.proposeCharter = async ({ form }) => ({ ok: true, item: { id: 44, constitution: { pass_rule: 'first_ratifier' } }, reads_as: { name: form } });
452
+ await gh.startFounding(1, w.deps());
453
+ await gh.savePlan({ days: { scope: 7 } }, 1, w.deps());
454
+ let g = await gh.readGenesis(w.deps());
455
+ assert.equal(g.plan_editable, true);
456
+ const before = new Map(w.store);
457
+ const same = await gh.savePlan({ days: { scope: 7 } }, 1, w.deps());
458
+ assert.equal(same.ok, true);
459
+ assert.equal(same.revised, false);
460
+ assert.deepEqual([...w.store], [...before], 'leaving it unchanged writes nothing');
461
+ // A changed length moves the stage's due date at once.
462
+ const r = await gh.savePlan({ days: { scope: 10 } }, 1, w.deps());
463
+ assert.equal(r.revised, true);
464
+ assert.deepEqual(r.changed, ['days']);
465
+ g = await gh.readGenesis(w.deps());
466
+ assert.equal(g.stages[0].days, 10);
467
+ assert.equal(g.stage, 'scope', 'revising the plan never moves the stage');
468
+ // A different government is filed for the board, like any charter change.
469
+ const gov = await gh.savePlan({ days: { scope: 10 }, government: 'republic' }, 1, w.deps());
470
+ assert.equal(gov.charter.state, 'sitting');
471
+ assert.equal(gov.charter.item_id, '44');
472
+ // Turning "existing" off cannot take back a filed step, and it says so.
473
+ await gh.savePlan({ days: { scope: 10 }, government: 'republic', existing: true }, 1, w.deps());
474
+ const off = await gh.savePlan({ days: { scope: 10 }, government: 'republic', existing: false }, 1, w.deps());
475
+ assert.equal(off.could_not.length, 1);
476
+ assert.match(off.could_not[0], /stays on the board/);
477
+ assert.equal((await gh.savePlan({ days: { scope: 999 } }, 1, w.deps())).reason, 'bad_days', 'a revision is held to the same ranges');
478
+ });
479
+
480
+ test('task 1005107: a stage the Board Room passed is shown fixed, with why; one that has not is not', async () => {
481
+ const w = world();
482
+ await gh.startFounding(1, w.deps());
483
+ await gh.savePlan({}, 1, w.deps());
484
+ w.finish('scope');
485
+ w.pass('scope');
486
+ const g = await gh.readGenesis(w.deps());
487
+ assert.match(g.stages[0].locked.reason, /Board Room passed the scope stage/);
488
+ assert.equal(g.stages[1].locked, null);
489
+ });
490
+
449
491
  test('the view: states, wakes, the life plates and a due date from the plan', () => {
450
492
  const mode = { stage: 'strategize', stage_number: 2, stage_count: 5, day: 9 };
451
493
  const g = gh.shapeGenesis({
@@ -704,6 +746,77 @@ test('task 1005115: with no Board Room the card says the stage moved by itself,
704
746
  assert.doesNotMatch(host.innerHTML, /The strategy<\/b>/, 'an empty document is not listed');
705
747
  });
706
748
 
749
+ test('task 1005107: the card offers to change the plan, reopens it with the saved answers, and says which stages are fixed', async () => {
750
+ const genesis = gh.shapeGenesis({
751
+ mode: { stage: 'strategize', stage_number: 2, stage_count: 5, day: 3 },
752
+ plan: { days: { scope: 7, strategize: 9, charter: 4, legalize: 7, legislation: 3 }, passes: 2, advisors: 'cbaas', existing: true, government: null, expected_builders: null },
753
+ tasks: { scope: [{ id: '1', title: 'Scope', status: 'shipped' }], strategize: [{ id: '2', title: 'Strategy', status: 'ready' }] },
754
+ board: { scope: { state: 'passed', waiting_on: [] } },
755
+ stageAt: { strategize: '2026-10-08T00:00:00Z' },
756
+ });
757
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
758
+ const click = await mountClickable(b);
759
+ assert.match(b.host.innerHTML, /data-genesis-plan-edit>Change the plan</);
760
+ assert.match(b.host.innerHTML, /Fixed: The Board Room passed the scope stage/);
761
+ assert.doesNotMatch(b.host.innerHTML, /data-genesis-plan>/, 'the plan form is not shown until asked for');
762
+ await click(fakeButton('data-genesis-plan-edit'));
763
+ assert.match(b.host.innerHTML, /data-genesis-plan>Save the plan</);
764
+ assert.match(b.host.innerHTML, /data-genesis-plan-cancel>Keep it as it was</);
765
+ assert.match(b.host.innerHTML, /value="2" checked/, 'two passes, as saved');
766
+ assert.match(b.host.innerHTML, /value="cbaas" checked/, 'advisors, as saved');
767
+ assert.match(b.host.innerHTML, /data-existing checked/, '"it already exists", as saved');
768
+ assert.match(b.host.innerHTML, /<option value="9" selected>9 days/, 'the stage length, as saved');
769
+ await click(fakeButton('data-genesis-plan-cancel'));
770
+ assert.doesNotMatch(b.host.innerHTML, /data-genesis-plan>/, 'keeping it as it was sends nothing and closes the form');
771
+ assert.ok(!b.calls.some((c) => c.startsWith('POST /founding/plan')));
772
+ });
773
+
774
+ test('task 1005107: a finished stage\'s answers are changed in place; unchanged sends nothing, a change sends only itself', async () => {
775
+ const genesis = gh.shapeGenesis({
776
+ mode: { stage: 'strategize', stage_number: 2, stage_count: 5, day: 3 },
777
+ plan: { days: {} },
778
+ tasks: { scope: [{ id: '1', title: 'Scope', status: 'shipped' }], strategize: [{ id: '2', title: 'Strategy', status: 'ready' }] },
779
+ stageAt: { strategize: '2026-10-08T00:00:00Z' },
780
+ });
781
+ const work = {
782
+ stage: 'scope', page: 'Old page',
783
+ questions: [
784
+ { id: 'what_creating', ask: 'What are you creating?', answer: { state: 'answered', text: 'A ledger' } },
785
+ { id: 'reach_who', ask: 'Who for?', answer: null },
786
+ ],
787
+ };
788
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1', work } });
789
+ const click = await mountClickable(b);
790
+ assert.match(b.host.innerHTML, /data-genesis-stage-edit="scope">Change its answers</);
791
+ await click(fakeButton('data-genesis-stage-edit', 'scope'));
792
+ assert.ok(b.calls.includes('GET /api/bongos/founding/work/scope'));
793
+ assert.match(b.host.innerHTML, /<textarea rows="3" data-answer="what_creating">A ledger<\/textarea>/, 'opens with the answer already given');
794
+ assert.match(b.host.innerHTML, /data-page>Old page</);
795
+ // Leaving everything as it was sends nothing.
796
+ const values = { '[data-answer="what_creating"]': 'A ledger', '[data-answer="reach_who"]': '', '[data-page]': 'Old page' };
797
+ b.host.querySelector = (sel) => (sel in values ? { value: values[sel] } : null);
798
+ const before = b.calls.length;
799
+ await click(fakeButton('data-genesis-stage-save'));
800
+ assert.equal(b.calls.length, before, 'nothing changed: no request');
801
+ assert.deepEqual(b.toasts.slice(-1), ['Nothing changed.']);
802
+ // A change sends only that answer.
803
+ await click(fakeButton('data-genesis-stage-edit', 'scope'));
804
+ values['[data-answer="reach_who"]'] = 'Small bakeries';
805
+ const mark = b.calls.length;
806
+ await click(fakeButton('data-genesis-stage-save'));
807
+ assert.deepEqual(b.calls.slice(mark), ['PUT /api/bongos/founding/work/scope/answers/reach_who']);
808
+ });
809
+
810
+ test('task 1005107: a government the plan cannot file is explained in words, never as a code', async () => {
811
+ const w = world();
812
+ w.government.proposeCharter = async () => ({ ok: false, reason: 'council_needs_a_rank' });
813
+ await gh.startFounding(1, w.deps());
814
+ await gh.savePlan({}, 1, w.deps());
815
+ const r = await gh.savePlan({ government: 'foundation' }, 1, w.deps());
816
+ assert.match(r.could_not[0], /needs a council with a rank/);
817
+ assert.doesNotMatch(r.could_not[0], /needs_council|council_needs/);
818
+ });
819
+
707
820
  test('the hall carries the five life plates byte-identical to the world\'s', () => {
708
821
  const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
709
822
  const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
@@ -399,6 +399,44 @@ if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
399
399
  assert.equal(all.charter.title, 'The charter');
400
400
  });
401
401
 
402
+ await test('task 1005107: a stage the Board Room passed is read-only with a plain reason; others stay editable', async () => {
403
+ let passed = ['scope'];
404
+ const government = { genesisStageStatus: async () => ({ stages: passed.map((key) => ({ key, state: 'passed' })) }) };
405
+ const deps = { store: memStore(), government };
406
+ const w = await gw.setAnswer('scope', 'what_creating', { text: 'A ledger' }, 1, deps);
407
+ assert.equal(w.ok, false);
408
+ assert.equal(w.reason, 'stage_closed');
409
+ assert.match(w.message, /Board Room passed the scope stage/);
410
+ assert.equal((await gw.setPage('scope', 'x', 1, deps)).reason, 'stage_closed');
411
+ assert.equal((await gw.setLink('scope', 'https://x.test', 1, deps)).reason, 'stage_closed');
412
+ assert.equal((await gw.clearAnswer('scope', 'what_creating', 1, deps)).reason, 'stage_closed');
413
+ const read = await gw.readStage('scope', deps);
414
+ assert.match(read.work.locked.reason, /fixed/);
415
+ const doc = (await gw.readDocument('scope', deps)).document;
416
+ assert.equal(doc.editable, false);
417
+ assert.match(doc.locked_reason, /what was voted on/);
418
+ assert.equal((await gw.readDocuments(deps)).scope.editable, false);
419
+ // A stage not passed stays editable, and so does one with no Board Room at all.
420
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Time' }, 1, deps)).ok, true);
421
+ assert.equal((await gw.setAnswer('scope', 'what_creating', { text: 'A ledger' }, 1, { store: deps.store, government: null })).ok, true);
422
+ // When the Board Room cannot be read, nothing is written.
423
+ const broken = { store: deps.store, government: { genesisStageStatus: async () => { throw new Error('down'); } } };
424
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Money' }, 1, broken)).reason, 'stage_lock_unknown');
425
+ passed = [];
426
+ assert.equal((await gw.setPage('scope', 'Now open again', 1, deps)).ok, true);
427
+ });
428
+
429
+ await test('task 1005107: a fixed stage answers stage_closed whatever was sent, and the board is read once per write', async () => {
430
+ let reads = 0;
431
+ const government = { genesisStageStatus: async () => { reads++; return { stages: [{ key: 'charter', state: 'passed' }] }; } };
432
+ const deps = { store: memStore(), government };
433
+ assert.equal((await gw.setPage('charter', 'x'.repeat(gw.LIMITS.page + 1), 1, deps)).reason, 'stage_closed', 'not page_too_long');
434
+ assert.equal((await gw.setAnswer('charter', 'no_such_question', { text: 'x' }, 1, deps)).reason, 'stage_closed', 'not unknown_question');
435
+ reads = 0;
436
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Time' }, 1, deps)).ok, true);
437
+ assert.equal(reads, 1, 'the write and the read that answers it share one Board Room read');
438
+ });
439
+
402
440
  console.log(`\ngenesis_work: ${passed} passed, ${failed} failed`);
403
441
  if (failed) process.exit(1);
404
442
  }
@@ -34,12 +34,15 @@ app.use(express.json());
34
34
  const router = express.Router();
35
35
  // The project's facts (task 1005129) live in project_settings: an in-memory one here.
36
36
  const settings = new Map();
37
+ let passedStages = [];
37
38
  mount(router, {
38
39
  store,
39
40
  getSetting: async (k) => (settings.has(k) ? { key: k, value: settings.get(k) } : null),
40
41
  setSetting: async (k, v) => { settings.set(k, String(v)); },
41
42
  factsMarker: null,
42
43
  payGates: null,
44
+ // The Board Room (task 1005107): the stages listed in `passedStages` read as passed.
45
+ government: { genesisStageStatus: async () => { if (!passedStages) throw new Error('board down'); return { stages: passedStages.map((key) => ({ key, state: 'passed' })) }; } },
43
46
  });
44
47
  app.use('/api/bongos', router);
45
48
  const server = app.listen(0);
@@ -100,6 +103,24 @@ try {
100
103
  assert.match(r.data.markdown, /Where its builders are: global/);
101
104
  });
102
105
 
106
+ await test('task 1005107: a write to a stage the Board Room passed is a 409 that says why', async () => {
107
+ passedStages = ['legislation'];
108
+ const r = await call('PUT', '/founding/work/legislation/page', { body: { page: 'x' } });
109
+ assert.equal(r.status, 409);
110
+ assert.equal(r.data.error.code, 'stage_closed');
111
+ assert.match(r.data.error.message, /Board Room passed the legislation stage/);
112
+ const d = await call('GET', '/founding/work/legislation/document', { as: OTHER });
113
+ assert.equal(d.data.document.editable, false);
114
+ passedStages = [];
115
+ assert.equal((await call('PUT', '/founding/work/legislation/page', { body: { page: 'x' } })).status, 200);
116
+ // When the Board Room cannot be read, the write is refused as a 503, never let through.
117
+ passedStages = null;
118
+ const down = await call('PUT', '/founding/work/legislation/page', { body: { page: 'y' } });
119
+ assert.equal(down.status, 503);
120
+ assert.equal(down.data.error.code, 'stage_lock_unknown');
121
+ passedStages = [];
122
+ });
123
+
103
124
  await test('a builder who is not the owner cannot write', async () => {
104
125
  for (const [m, p, body] of [
105
126
  ['PUT', '/founding/work/strategize/answers/risks', { text: 'x' }],
@@ -0,0 +1,135 @@
1
+ // tests/github_signup_handoff.mjs — no GitHub account yet: GitHub's own sign-up page, with
2
+ // "Continue with Google", and back to project creation (task 1005283).
3
+ //
4
+ // WHY. Bongos cannot make a GitHub account for anyone, and every project lives in a GitHub
5
+ // repository. A person with only a Google account used to meet "Continue with GitHub" and
6
+ // nothing else. GitHub's sign-up page offers "Continue with Google", and during an OAuth
7
+ // sign-in GitHub's own screen links it with `return_to` = the authorize step, so the
8
+ // sign-in resumes once the account exists (checked on GitHub's live pages, signed out,
9
+ // 2026-11-13). `?signup=1` on our sign-in start opens THAT page directly.
10
+ //
11
+ // What this pins, by RUNNING the start route's handler (no network, no database):
12
+ // 1. ?signup=1 redirects to https://github.com/signup with return_to = the same
13
+ // authorize step (same state cookie, same client, same repo scope, same callback);
14
+ // 2. it skips the GitHub-or-Google chooser, which would offer a Bongos Google sign-in
15
+ // that cannot make a project;
16
+ // 3. a plain sign-in still goes to the authorize step, and ?switch=1 still adds the
17
+ // account picker (converted here from tests/auth_switch_account.mjs's source read);
18
+ // 4. githubSignupUrl hands on only github.com's own authorize path;
19
+ // 5. the creation page's gate says why GitHub is needed and carries the door, whose
20
+ // link runs through the route above and returns to /projects?view=new.
21
+ //
22
+ // Run: node --test tests/github_signup_handoff.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { createRequire } from 'node:module';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ process.env.BONGOS_GITHUB_CLIENT_ID = process.env.BONGOS_GITHUB_CLIENT_ID || 'gh-test-client';
32
+ process.env.BONGOS_GITHUB_CLIENT_SECRET = process.env.BONGOS_GITHUB_CLIENT_SECRET || 'gh-test-secret';
33
+ // Google sign-in on, so the chooser would show for a plain sign-in: the door must skip it
34
+ process.env.BONGOS_GOOGLE_CLIENT_ID = 'google-test-client';
35
+ process.env.BONGOS_GOOGLE_CLIENT_SECRET = 'google-test-secret';
36
+
37
+ const require = createRequire(import.meta.url);
38
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
+ // "Continue with Google" is offered only when the hub that keeps Google sign-ins is present
40
+ const seams = require(path.join(ROOT, 'src/module-seams.js'));
41
+ if (!seams.hasProvider('platform-identity.hub')) {
42
+ seams.registerProvider('platform-identity.hub', { signInWithGoogle: async () => null, recordHubSignin: async () => {} });
43
+ }
44
+ const buildAuthRouter = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
45
+ const { githubSignupUrl } = require(path.join(ROOT, 'src/bongos/auth.js'));
46
+ const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
47
+
48
+ function fakeRes() {
49
+ const res = {
50
+ _status: 200, _body: null, _redirect: null, _cookies: {},
51
+ status(c) { this._status = c; return this; },
52
+ type() { return this; },
53
+ send(b) { this._body = b; return this; },
54
+ json(b) { this._body = b; return this; },
55
+ set() { return this; },
56
+ redirect(a, b) { this._redirect = b === undefined ? a : b; return this; },
57
+ cookie(n, v, o) { this._cookies[n] = { v, o }; return this; },
58
+ clearCookie() { return this; },
59
+ };
60
+ attachFail({}, res, () => {});
61
+ return res;
62
+ }
63
+ const start = (() => {
64
+ const layer = buildAuthRouter().stack.find((l) => l.route?.path === '/auth/web/start' && l.route.methods.get);
65
+ assert.ok(layer, 'GET /auth/web/start exists');
66
+ return layer.route.stack.at(-1).handle;
67
+ })();
68
+ const req = (query) => ({ query, headers: { host: 'localhost' }, baseUrl: '/api/bongos', body: {} });
69
+ async function run(query) { const res = fakeRes(); await start(req(query), res); return res; }
70
+
71
+ test('?signup=1 opens GitHub’s sign-up page, which resumes this same sign-in', async () => {
72
+ const res = await run({ via: 'github', signup: '1', return: '/projects?view=new', repos: '1' });
73
+ assert.ok(res._redirect, 'a redirect, not the chooser page');
74
+ const signup = new URL(res._redirect);
75
+ assert.equal(signup.origin + signup.pathname, 'https://github.com/signup');
76
+ assert.equal(signup.searchParams.get('source'), 'oauth', 'the same marker GitHub’s own "Create an account" link carries');
77
+ const back = new URL(signup.searchParams.get('return_to'), 'https://github.com');
78
+ assert.equal(back.pathname, '/login/oauth/authorize', 'the way back is the authorize step');
79
+ assert.equal(back.searchParams.get('client_id'), process.env.BONGOS_GITHUB_CLIENT_ID);
80
+ const stateCookie = Object.values(res._cookies).find((c) => /^[0-9a-f]{32}$/.test(String(c.v)));
81
+ assert.ok(stateCookie, 'a state cookie is set');
82
+ assert.equal(back.searchParams.get('state'), stateCookie.v,
83
+ 'the state GitHub hands back is the one in this browser’s cookie, so the callback accepts it');
84
+ assert.match(back.searchParams.get('scope'), /\brepo\b/, 'the repo access project creation needs is asked once, on the way back');
85
+ assert.match(back.searchParams.get('redirect_uri'), /\/auth\/web\/callback$/);
86
+ assert.ok(Object.values(res._cookies).some((c) => c.v === '/projects?view=new'), 'after the callback, the person lands on project creation');
87
+ });
88
+
89
+ test('the door skips the GitHub-or-Google chooser; a plain sign-in still shows it', async () => {
90
+ const signup = await run({ signup: '1', return: '/projects?view=new', repos: '1' });
91
+ assert.match(signup._redirect || '', /^https:\/\/github\.com\/signup\?/, 'no via=github needed: a GitHub sign-up is GitHub');
92
+ const plain = await run({ return: '/projects?view=new', repos: '1' });
93
+ assert.equal(plain._redirect, null);
94
+ assert.match(plain._body, /Continue with GitHub/);
95
+ });
96
+
97
+ test('without ?signup a sign-in goes to the authorize step, and ?switch=1 still adds the account picker', async () => {
98
+ const plain = await run({ via: 'github', return: '/projects?view=new', repos: '1' });
99
+ const p = new URL(plain._redirect);
100
+ assert.equal(p.origin + p.pathname, 'https://github.com/login/oauth/authorize');
101
+ assert.equal(p.searchParams.get('prompt'), null);
102
+ const sw = await run({ switch: '1', return: '/' });
103
+ const s = new URL(sw._redirect);
104
+ assert.equal(s.pathname, '/login/oauth/authorize');
105
+ assert.equal(s.searchParams.get('prompt'), 'select_account', 'a switch reaches GitHub, never the live-session handoff');
106
+ });
107
+
108
+ test('githubSignupUrl hands on only github.com’s own authorize path', () => {
109
+ const ok = githubSignupUrl('https://github.com/login/oauth/authorize?client_id=c&state=s&scope=repo');
110
+ assert.equal(new URL(ok).searchParams.get('return_to'), '/login/oauth/authorize?client_id=c&state=s&scope=repo');
111
+ for (const bad of [
112
+ 'https://evil.example/login/oauth/authorize?state=s',
113
+ 'http://github.com/login/oauth/authorize?state=s',
114
+ 'https://github.com/settings/tokens',
115
+ 'https://github.com.evil.example/login/oauth/authorize',
116
+ 'not a url',
117
+ null,
118
+ ]) assert.equal(githubSignupUrl(bad), null, String(bad));
119
+ });
120
+
121
+ test('the creation page says why GitHub is needed and offers the door, returning to creation', () => {
122
+ const hub = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/projects.html'), 'utf8').replace(/\r\n/g, '\n');
123
+ const gate = (hub.match(/<section class="panel" id="panel-gate"[\s\S]*?<\/section>/) || [''])[0];
124
+ assert.ok(gate, 'the gate is findable');
125
+ assert.match(gate, /<p class="finePrint" id="wizWhyGh">Your project’s files live in a GitHub repository, so it needs a GitHub account\.<\/p>/);
126
+ const door = gate.match(/<a id="wizSignup" href="([^"]+)">Make one free with Google<\/a>/);
127
+ assert.ok(door, 'the door sits on the gate');
128
+ assert.ok(gate.indexOf('id="wizSignin"') < gate.indexOf('id="wizSignup"'), 'under Continue with GitHub, not instead of it');
129
+ const href = new URL(door[1].replace(/&amp;/g, '&'), 'https://hub.example');
130
+ assert.equal(href.pathname, '/api/bongos/auth/web/start');
131
+ assert.equal(href.searchParams.get('signup'), '1');
132
+ assert.equal(href.searchParams.get('repos'), '1', 'the same repo access the GitHub button asks for');
133
+ assert.equal(href.searchParams.get('return'), '/projects?view=new');
134
+ assert.match(gate, /choose <strong>Continue with Google<\/strong>, and you come straight back here/);
135
+ });