@bongos/core 1.19.206 → 1.19.208

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,179 @@
1
+ // tests/setup_device_flow_fallback.mjs
2
+ //
3
+ // W1 (task 1003051): on a wizard-born instance the GitHub App has Device Flow
4
+ // OFF (the manifest can't enable it, GitHub offers only a settings checkbox),
5
+ // so `bongos setup` — the documented CLI sign-in — dead-ended on a proxied
6
+ // GitHub refusal. setup.js now detects that exact refusal and falls back to the
7
+ // hall's CLI-token path.
8
+ //
9
+ // Covers the fallback branch's machinery, each leg against the stubbed refusal
10
+ // or a stubbed instance API:
11
+ // • isDeviceFlowDisabled — the detector against the REAL server envelope
12
+ // (device_start_failed 502 carrying GitHub's device_flow_disabled), a legacy
13
+ // flat shape, and the shapes it must NOT match (auth_not_configured, other
14
+ // upstream failures, success)
15
+ // • deviceFlowFallbackLines — the guidance names the hall Settings page,
16
+ // CLI Access, and the owner's one-checkbox repair
17
+ // • redeemCliToken — driven in a CHILD process (hermetic HOME/USERPROFILE +
18
+ // GDS_API_BASE at a local stub): a good token verifies via /me and writes
19
+ // the session; a bad token is refused and writes NOTHING
20
+ //
21
+ // The interactive TTY walk (prompt → paste) is deliberately not spawned here:
22
+ // the spawn-the-whole-CLI harness (setup_smoke.mjs) is already environment-
23
+ // sensitive, and every seam the prompt wires together is covered above.
24
+ //
25
+ // Run: node tests/setup_device_flow_fallback.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { createRequire } from 'node:module';
29
+ import { spawn } from 'node:child_process';
30
+ import http from 'node:http';
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const setup = require('../scripts/gds/setup.js');
38
+
39
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
40
+ const SETUP_JS = path.join(REPO_ROOT, 'scripts', 'gds', 'setup.js');
41
+
42
+ let passed = 0, failed = 0;
43
+ const t = (name, fn) => {
44
+ try { fn(); passed++; console.log(` PASS ${name}`); }
45
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
46
+ };
47
+ const ta = async (name, fn) => {
48
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
49
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
50
+ };
51
+
52
+ console.log('isDeviceFlowDisabled — the refusal detector:');
53
+
54
+ // The REAL shape: routes/auth.js res.fail('device_start_failed', {status:502,
55
+ // message: 'GitHub rejected device-flow start: device_flow_disabled — …'}).
56
+ const REAL_REFUSAL = {
57
+ ok: false, status: 502,
58
+ data: { error: { code: 'device_start_failed', message: 'GitHub rejected device-flow start: device_flow_disabled — terminal sign-in needs Device Flow enabled on the app: open https://github.com/settings/apps, open your app, check "Enable Device Flow", and save. Web sign-in is unaffected.' } },
59
+ };
60
+
61
+ t('matches the real device_start_failed envelope carrying device_flow_disabled', () => {
62
+ assert.equal(setup.isDeviceFlowDisabled(REAL_REFUSAL), true);
63
+ });
64
+
65
+ t('matches a legacy flat error shape too', () => {
66
+ assert.equal(setup.isDeviceFlowDisabled({ ok: false, status: 502, data: { error: 'device_start_failed', message: 'x device_flow_disabled y' } }), true);
67
+ });
68
+
69
+ t('does NOT match auth_not_configured, other upstream failures, or success', () => {
70
+ assert.equal(setup.isDeviceFlowDisabled({ ok: false, status: 503, data: { error: { code: 'auth_not_configured', message: 'GitHub OAuth App credentials missing on the server.' } } }), false);
71
+ assert.equal(setup.isDeviceFlowDisabled({ ok: false, status: 502, data: { error: { code: 'device_start_failed', message: 'GitHub rejected device-flow start: incorrect_client_credentials' } } }), false);
72
+ assert.equal(setup.isDeviceFlowDisabled({ ok: true, status: 200, data: { device_code: 'x' } }), false);
73
+ assert.equal(setup.isDeviceFlowDisabled(null), false);
74
+ });
75
+
76
+ console.log('\ndeviceFlowFallbackLines — the guidance:');
77
+
78
+ t('names the Settings page, CLI Access, and the owner checkbox repair', () => {
79
+ const text = setup.deviceFlowFallbackLines('https://spike.cloudbongos.com').join('\n');
80
+ assert.match(text, /https:\/\/spike\.cloudbongos\.com\/builders\/settings/, 'points at the hall Settings page');
81
+ assert.match(text, /CLI Access/, 'names the section');
82
+ assert.match(text, /Re-issue CLI token/, 'names the button');
83
+ assert.match(text, /Enable Device Flow/, 'names the owner checkbox');
84
+ assert.match(text, /Web sign-in is unaffected/i, 'says what still works');
85
+ });
86
+
87
+ // ── redeemCliToken in a hermetic child against a stubbed instance API ────────
88
+ // A child process because API_BASE + the session path resolve at module load:
89
+ // the child gets GDS_API_BASE → the stub and HOME/USERPROFILE → a throwaway
90
+ // dir, so the real session can never be touched. The driver prints the JSON
91
+ // result and lets node exit naturally (no process.exit — the Windows async.c
92
+ // abort gotcha), so assertions read stdout, not the exit code.
93
+
94
+ const GOOD_TOKEN = 'a'.repeat(64);
95
+ const server = http.createServer((req, res) => {
96
+ const send = (status, body) => {
97
+ res.writeHead(status, { 'Content-Type': 'application/json' });
98
+ res.end(JSON.stringify(body));
99
+ };
100
+ if (req.method === 'GET' && req.url.endsWith('/api/gds/me')) {
101
+ if ((req.headers.authorization || '') === `Bearer ${GOOD_TOKEN}`) {
102
+ return send(200, { builder: { id: 6, github_login: 'newbuilder', display_name: 'New Builder' } });
103
+ }
104
+ return send(401, { error: { code: 'unauthenticated' } });
105
+ }
106
+ return send(404, { error: { code: 'not_found' } });
107
+ });
108
+ server.listen(0, '127.0.0.1');
109
+ await new Promise((r) => server.once('listening', r));
110
+ const BASE = `http://127.0.0.1:${server.address().port}`;
111
+
112
+ const driverPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'w1-driver-')), 'driver.cjs');
113
+ fs.writeFileSync(driverPath, [
114
+ `const s = require(${JSON.stringify(SETUP_JS)});`,
115
+ `s.redeemCliToken(process.argv[2]).then((r) => { console.log('RESULT ' + JSON.stringify(r)); });`,
116
+ ].join('\n'));
117
+
118
+ // ASYNC spawn, not spawnSync: the stub server lives in THIS process, and a sync
119
+ // spawn would block the event loop — the child's fetch would deadlock waiting
120
+ // for a server that can never accept.
121
+ function redeemInChild(token, home) {
122
+ return new Promise((resolve) => {
123
+ const child = spawn(process.execPath, [driverPath, token], {
124
+ env: { ...process.env, HOME: home, USERPROFILE: home, GDS_API_BASE: BASE, OTB_API_BASE: BASE },
125
+ });
126
+ let stdout = '', stderr = '';
127
+ child.stdout.on('data', (c) => { stdout += c; });
128
+ child.stderr.on('data', (c) => { stderr += c; });
129
+ const timer = setTimeout(() => child.kill(), 30_000);
130
+ child.on('close', (code, signal) => {
131
+ clearTimeout(timer);
132
+ const line = stdout.split('\n').find((l) => l.startsWith('RESULT '));
133
+ resolve({ result: line ? JSON.parse(line.slice(7)) : null, raw: { stdout, stderr, code, signal } });
134
+ });
135
+ });
136
+ }
137
+
138
+ function findSessions(home) {
139
+ const hits = [];
140
+ (function walk(dir) {
141
+ let entries = [];
142
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
143
+ for (const f of entries) {
144
+ const p = path.join(dir, f.name);
145
+ if (f.isDirectory()) walk(p);
146
+ else if (f.name === 'gds-session.json') hits.push(p);
147
+ }
148
+ })(home);
149
+ return hits;
150
+ }
151
+
152
+ console.log('\nredeemCliToken — verify-then-save against the stub:');
153
+
154
+ await ta('a good token verifies via /me and writes the session under the hermetic HOME', async () => {
155
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'w1-home-'));
156
+ const { result, raw } = await redeemInChild(GOOD_TOKEN, home);
157
+ assert.ok(result, `driver printed no RESULT\nstdout:\n${raw.stdout}\nstderr:\n${raw.stderr}`);
158
+ assert.equal(result.ok, true);
159
+ assert.equal(result.builder.github_login, 'newbuilder');
160
+ const sessions = findSessions(home);
161
+ assert.equal(sessions.length, 1, `exactly one session file expected under ${home}`);
162
+ const session = JSON.parse(fs.readFileSync(sessions[0], 'utf8'));
163
+ assert.equal(session.token, GOOD_TOKEN);
164
+ assert.equal(session.api_base, BASE, 'the session points at the instance that minted the token');
165
+ });
166
+
167
+ await ta('a bad token is refused (401) and NOTHING is written', async () => {
168
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'w1-home-'));
169
+ const { result, raw } = await redeemInChild('b'.repeat(64), home);
170
+ assert.ok(result, `driver printed no RESULT\nstdout:\n${raw.stdout}\nstderr:\n${raw.stderr}`);
171
+ assert.equal(result.ok, false);
172
+ assert.equal(result.status, 401);
173
+ assert.match(raw.stderr, /Token rejected/, 'says the token was refused');
174
+ assert.equal(findSessions(home).length, 0, 'no session written for an unverified token');
175
+ });
176
+
177
+ server.close();
178
+ console.log(`\nsetup_device_flow_fallback: ${passed} passed, ${failed} failed`);
179
+ process.exit(failed ? 1 : 0);