@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.
- package/.bongos-core.json +32 -27
- package/docs/copy-inventory.md +257 -241
- package/docs/copy-registry.json +426 -272
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +195 -189
- package/modules/hall-ui/public/genesis-home.js +98 -16
- package/modules/onboarding/genesis-home.js +64 -4
- package/modules/onboarding/genesis-work.js +65 -10
- package/modules/onboarding/routes/genesis-work.js +2 -1
- package/modules/public-landing/public/projects.html +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/auth-github.js +17 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +7 -7
- package/src/module-api.js +1 -1
- package/tests/auth_switch_account.mjs +3 -7
- package/tests/genesis_home.mjs +115 -2
- package/tests/genesis_work.mjs +38 -0
- package/tests/genesis_work_routes.mjs +21 -0
- package/tests/github_signup_handoff.mjs +135 -0
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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');
|
package/tests/genesis_home.mjs
CHANGED
|
@@ -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())).
|
|
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');
|
package/tests/genesis_work.mjs
CHANGED
|
@@ -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(/&/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
|
+
});
|