@bongos/core 1.21.69 → 1.21.71
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 +119 -64
- package/.claude/skills/bongos-feedback/SKILL.md +10 -46
- package/.claude/skills/goal-create/SKILL.md +21 -3
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -2
- package/clients/bongos-client/index.cjs +8 -2
- package/clients/bongos-client/index.d.ts +15 -6
- package/clients/bongos-client/index.mjs +8 -2
- package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +196 -19
- package/docs/api-reference.md +9 -6
- package/docs/copy-inventory.md +25 -21
- package/docs/copy-registry.json +76 -30
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +8 -8
- package/docs/recipes/core-release-pipeline.md +6 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/hall-ui/public/settings-software-update.js +77 -2
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/lifecycle/db-tasks.js +12 -0
- package/modules/lifecycle/goal-spec-task.js +129 -0
- package/modules/lifecycle/routes/goals.js +7 -9
- package/modules/lifecycle/routes/task-write-routes.js +11 -1
- package/modules/lifecycle/upkeep-home.js +55 -0
- package/modules/platform-identity/instance-feedback.js +108 -7
- package/modules/platform-identity/routes/sso.js +71 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +42 -0
- package/scripts/gds/claude-materialize.js +125 -31
- package/scripts/gds/claude-md-template.js +47 -10
- package/scripts/gds/cli-lib.js +4 -4
- package/scripts/gds/feedback-send.js +146 -73
- package/scripts/gds/paste-token.js +1 -1
- package/scripts/gds/provision-net.js +1 -8
- package/scripts/gds/provision-units.js +28 -2
- package/scripts/gds/reauth.js +1 -1
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/setup.js +1 -1
- package/scripts/gds/start.js +4 -4
- package/scripts/gds/surface.js +15 -3
- package/scripts/gds/task.js +32 -5
- package/scripts/gds/upgrade-operator.js +73 -0
- package/scripts/gds/upgrade.js +7 -2
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/platform-visibility-gate.js +3 -0
- package/src/bongos/routes/core-update.js +98 -4
- package/src/bongos/routes/feedback.js +100 -76
- package/src/bongos/upgrade-request.js +158 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +195 -18
- package/tests/cli_allowlist_permissions.mjs +6 -2
- package/tests/cli_surface.mjs +2 -2
- package/tests/fixtures/page-ledger.mjs +5 -0
- package/tests/goal_admission_override.mjs +1 -1
- package/tests/goal_category.mjs +1 -1
- package/tests/goal_create_spec_entry.mjs +61 -0
- package/tests/goal_spec_task.mjs +197 -0
- package/tests/goal_spec_task_db.mjs +95 -0
- package/tests/instance_feedback.mjs +267 -68
- package/tests/provision_settings_apply.mjs +25 -1
- package/tests/software_update.mjs +18 -1
- package/tests/start_env_guard.mjs +2 -2
- package/tests/upgrade_operator.mjs +105 -0
- package/tests/upgrade_request.mjs +348 -0
- package/tests/upkeep_home.mjs +156 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// tests/goal_spec_task_db.mjs — a goal made in the hall carries its own spec task,
|
|
2
|
+
// and its other work waits behind it (task 1004585, ADR 0366 D3). REAL DB: the
|
|
3
|
+
// claim gate and the auto-promotion are the database's, so this proves the edge
|
|
4
|
+
// gateOnSpecTask writes is one they actually honour.
|
|
5
|
+
//
|
|
6
|
+
// Real-DB integration test (self-skips without a reachable Postgres; in the
|
|
7
|
+
// INTEGRATION set so the default DB-free lane never runs it).
|
|
8
|
+
//
|
|
9
|
+
// Run: DATABASE_URL=... node tests/goal_spec_task_db.mjs
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { Pool } = require('pg');
|
|
15
|
+
|
|
16
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
17
|
+
try {
|
|
18
|
+
await pool.query('SELECT 1');
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.log(`goal_spec_task_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
21
|
+
await pool.end().catch(() => {});
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const seams = require('../src/module-seams.js');
|
|
26
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
27
|
+
const db = require('../modules/lifecycle/db.js');
|
|
28
|
+
const spec = require('../modules/lifecycle/goal-spec-task.js');
|
|
29
|
+
|
|
30
|
+
const VER = 'ZZZ-SPEC', WORKER = 90000401;
|
|
31
|
+
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
|
|
32
|
+
|
|
33
|
+
async function cleanup() {
|
|
34
|
+
const ids = (await q(`SELECT id FROM tasks WHERE version_id=$1`, [VER]).catch(() => [])).map((r) => r.id);
|
|
35
|
+
await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [ids]).catch(() => {});
|
|
36
|
+
await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, ids]).catch(() => {});
|
|
37
|
+
await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
|
|
38
|
+
await q(`DELETE FROM dependencies WHERE (from_kind='task' AND from_id = ANY($1::bigint[])) OR (to_kind='task' AND to_id = ANY($1::bigint[]))`, [ids]).catch(() => {});
|
|
39
|
+
await q(`DELETE FROM tasks WHERE version_id=$1`, [VER]).catch(() => {});
|
|
40
|
+
await q(`DELETE FROM goal_members WHERE goal_id IN (SELECT id FROM goals WHERE version_id=$1)`, [VER]).catch(() => {});
|
|
41
|
+
await q(`DELETE FROM goals WHERE version_id=$1`, [VER]).catch(() => {});
|
|
42
|
+
await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
|
|
43
|
+
await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let pass = 0;
|
|
47
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); console.log(` ok ${msg}`); pass++; };
|
|
48
|
+
const statusOf = async (id) => (await q(`SELECT status FROM tasks WHERE id=$1`, [id]))[0].status;
|
|
49
|
+
const edges = async (id) => (await q(`SELECT to_id FROM dependencies WHERE from_kind='task' AND from_id=$1 AND to_kind='task'`, [id])).map((r) => Number(r.to_id));
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await cleanup();
|
|
53
|
+
await q(`INSERT INTO versions (id,name,status,track) VALUES ($1,'spec','building','internal') ON CONFLICT (id) DO NOTHING`, [VER]);
|
|
54
|
+
await q(`INSERT INTO builders (id,github_id,github_login,display_name,rank) VALUES ($1,'spec-w','spec-worker','Spec Worker','archon')`, [WORKER]);
|
|
55
|
+
|
|
56
|
+
// A hall-made goal and its spec task (what POST /goals { spec_task: true } writes).
|
|
57
|
+
const goal = await db.createGoal({ versionId: VER, title: 'Hall goal', createdBy: WORKER });
|
|
58
|
+
const specTask = await db.createTask(spec.specTaskFields(goal, { createdBy: WORKER }));
|
|
59
|
+
ok(specTask.status === 'ready' && specTask.kind === 'spike', 'the spec task is born ready, a spike');
|
|
60
|
+
ok((await edges(specTask.id)).length === 0, 'the spec task does not wait on itself');
|
|
61
|
+
|
|
62
|
+
// 1) A task filed into the goal waits on the spec task, and the claim gate says so.
|
|
63
|
+
const work = await db.createTask({ versionId: VER, goalId: goal.id, title: 'Build the first page', kind: 'feature', discipline: 'engineer', creditsReward: 10 });
|
|
64
|
+
ok((await edges(work.id)).includes(Number(specTask.id)), 'a filed task carries an edge to the spec task');
|
|
65
|
+
// unsatisfiedDeps is the read the claim gate refuses on (db-claims.js: a non-empty
|
|
66
|
+
// answer throws DEPS_NOT_SHIPPED). The full claim path is not driven here — its own
|
|
67
|
+
// DB test (claim_gate_open_blocker) is parked in this lane for unrelated setup.
|
|
68
|
+
const gating = await db.unsatisfiedDeps(work.id);
|
|
69
|
+
ok(gating.some((d) => d.kind === 'task' && Number(d.id) === Number(specTask.id)), 'the claim gate read names the spec task as unshipped');
|
|
70
|
+
|
|
71
|
+
// 2) Shipping the spec task releases it: the auto-promotion trigger moves it to ready.
|
|
72
|
+
await q(`UPDATE tasks SET status='shipped' WHERE id=$1`, [specTask.id]);
|
|
73
|
+
ok((await db.unsatisfiedDeps(work.id)).length === 0, 'nothing gates the task once the spec ships');
|
|
74
|
+
ok((await statusOf(work.id)) === 'ready', `the waiting task auto-promotes to ready (got ${await statusOf(work.id)})`);
|
|
75
|
+
|
|
76
|
+
// 3) A task filed after the spec shipped waits on nothing.
|
|
77
|
+
const later = await db.createTask({ versionId: VER, goalId: goal.id, title: 'Build the second page', kind: 'feature', discipline: 'engineer' });
|
|
78
|
+
ok((await edges(later.id)).length === 0, 'a shipped spec gates nothing new');
|
|
79
|
+
|
|
80
|
+
// 4) A goal made WITHOUT a spec task behaves exactly as before.
|
|
81
|
+
const plain = await db.createGoal({ versionId: VER, title: 'CLI goal', createdBy: WORKER });
|
|
82
|
+
const plainTask = await db.createTask({ versionId: VER, goalId: plain.id, title: 'Build the third page', kind: 'feature', discipline: 'engineer' });
|
|
83
|
+
ok((await edges(plainTask.id)).length === 0, 'a goal with no spec task adds no edge');
|
|
84
|
+
|
|
85
|
+
// 5) A task MOVED into a goal with an open spec task waits on it too.
|
|
86
|
+
const goal2 = await db.createGoal({ versionId: VER, title: 'Second hall goal', createdBy: WORKER });
|
|
87
|
+
const spec2 = await db.createTask(spec.specTaskFields(goal2, { createdBy: WORKER }));
|
|
88
|
+
await db.updateTaskGoal(plainTask.id, goal2.id);
|
|
89
|
+
ok((await edges(plainTask.id)).includes(Number(spec2.id)), 'a moved-in task carries an edge to the open spec task');
|
|
90
|
+
|
|
91
|
+
console.log(`\n${pass} passed, 0 failed`);
|
|
92
|
+
} finally {
|
|
93
|
+
await cleanup();
|
|
94
|
+
await pool.end();
|
|
95
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// tests/instance_feedback.mjs — `bongos feedback`: a builder
|
|
2
|
-
//
|
|
3
|
-
// the hub files it (
|
|
1
|
+
// tests/instance_feedback.mjs — `bongos feedback`: a builder reports something
|
|
2
|
+
// about Bongos itself straight to the hub, their project vouches for who sent it
|
|
3
|
+
// with a one-time receipt, and the hub files it (tasks 1004462, 1004591). DB-free: the hub half is driven through its
|
|
4
4
|
// pure module with a fake `ideas.capture`, the project half through its real
|
|
5
5
|
// router with a fake hub.
|
|
6
6
|
|
|
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
|
|
|
8
8
|
import { test } from 'node:test';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
11
12
|
import { createRequire } from 'node:module';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
|
|
@@ -118,9 +119,12 @@ test('the hub route authenticates the project and the sender BEFORE spending or
|
|
|
118
119
|
'the route passes neither a rank nor a vector — both are fixed in instance-feedback.js');
|
|
119
120
|
});
|
|
120
121
|
|
|
121
|
-
// ── the project: the
|
|
122
|
+
// ── the project: the receipt (task 1004591) ──────────────────────────────────
|
|
123
|
+
// The project no longer relays anything. It vouches: a signed-in builder asks
|
|
124
|
+
// for a one-time receipt naming them and the hash of the text, and the hub
|
|
125
|
+
// redeems it once at the project's public URL.
|
|
122
126
|
|
|
123
|
-
async function withRoute(
|
|
127
|
+
async function withRoute(opts, fn) {
|
|
124
128
|
const r2 = createRequire(import.meta.url);
|
|
125
129
|
const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
|
|
126
130
|
const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/feedback.js'));
|
|
@@ -129,24 +133,21 @@ async function withRoute({ idp, hub }, fn) {
|
|
|
129
133
|
const saved = realAuth.requireBuilder;
|
|
130
134
|
realAuth.requireBuilder = (req, _res, next) => { req.builder = { id: 5, github_id: '42', github_login: 'someone' }; next(); };
|
|
131
135
|
delete r2.cache[routePath];
|
|
132
|
-
const
|
|
133
|
-
const fetchImpl = async (url, opts) => {
|
|
134
|
-
sent.push({ url, body: JSON.parse(opts.body) });
|
|
135
|
-
const { status, body } = hub;
|
|
136
|
-
return { status, json: async () => body };
|
|
137
|
-
};
|
|
136
|
+
const build = r2(routePath);
|
|
138
137
|
const express = r2('express');
|
|
139
138
|
const app = express();
|
|
140
139
|
app.use(express.json());
|
|
141
140
|
app.use(r2(envPath).attachFail);
|
|
142
|
-
app.use(
|
|
141
|
+
app.use(build({ hub: () => 'https://hub.example.com', ...opts }));
|
|
143
142
|
const server = app.listen(0);
|
|
144
143
|
await new Promise((res) => server.once('listening', res));
|
|
145
|
-
const
|
|
144
|
+
const url = (p) => `http://127.0.0.1:${server.address().port}${p}`;
|
|
145
|
+
const post = (body) => fetch(url('/feedback/receipt'), {
|
|
146
146
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
147
147
|
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
|
148
|
+
const redeem = (id) => fetch(url(`/feedback/receipt/${id}`)).then(async (r) => ({ status: r.status, body: await r.json() }));
|
|
148
149
|
try {
|
|
149
|
-
await fn({ post,
|
|
150
|
+
await fn({ post, redeem, build });
|
|
150
151
|
} finally {
|
|
151
152
|
server.close();
|
|
152
153
|
realAuth.requireBuilder = saved;
|
|
@@ -154,75 +155,232 @@ async function withRoute({ idp, hub }, fn) {
|
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
test('
|
|
160
|
-
await withRoute({
|
|
161
|
-
|
|
162
|
-
assert.equal(
|
|
163
|
-
|
|
164
|
-
assert.
|
|
165
|
-
|
|
166
|
-
assert.equal(
|
|
167
|
-
assert.equal(
|
|
168
|
-
assert.equal(
|
|
169
|
-
assert.equal(
|
|
170
|
-
assert.equal(
|
|
171
|
-
assert.equal(
|
|
172
|
-
assert.
|
|
173
|
-
assert.equal(sent[0].body.client_secret, 's3cret');
|
|
158
|
+
const HASH = IF.sha256Hex('it broke');
|
|
159
|
+
|
|
160
|
+
test('a receipt names the SESSION\'s builder and the text hash, and redeems exactly once', async () => {
|
|
161
|
+
await withRoute({}, async ({ post, redeem }) => {
|
|
162
|
+
const r = await post({ sha256: HASH, kind: 'idea' });
|
|
163
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
164
|
+
assert.equal(r.body.hub, 'https://hub.example.com');
|
|
165
|
+
assert.match(r.body.receipt, /^[A-Za-z0-9_-]{24}$/);
|
|
166
|
+
const first = await redeem(r.body.receipt);
|
|
167
|
+
assert.equal(first.status, 200);
|
|
168
|
+
assert.equal(first.body.github_id, '42', 'from the session, never the body');
|
|
169
|
+
assert.equal(first.body.github_login, 'someone');
|
|
170
|
+
assert.equal(first.body.sha256, HASH);
|
|
171
|
+
assert.equal(first.body.kind, 'idea');
|
|
172
|
+
assert.equal((await redeem(r.body.receipt)).status, 404, 'single use');
|
|
173
|
+
assert.equal((await redeem('not-a-real-receipt-id')).status, 404);
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
test('a
|
|
178
|
-
await withRoute({
|
|
179
|
-
|
|
180
|
-
assert.equal(
|
|
181
|
-
assert.equal(
|
|
182
|
-
assert.equal(sent.length, 0);
|
|
177
|
+
test('a receipt request with a body naming a sender, or no hash, is refused', async () => {
|
|
178
|
+
await withRoute({}, async ({ post }) => {
|
|
179
|
+
assert.equal((await post({ sha256: HASH, github_id: '1' })).status, 400, 'no sender rides the body');
|
|
180
|
+
assert.equal((await post({ sha256: 'abc' })).status, 400);
|
|
181
|
+
assert.equal((await post({ sha256: HASH, kind: 'task' })).status, 400);
|
|
183
182
|
});
|
|
184
183
|
});
|
|
185
184
|
|
|
186
|
-
test('
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
});
|
|
185
|
+
test('receipts expire and the store is capped', () => {
|
|
186
|
+
const { makeReceiptStore } = require_(path.join(ROOT, 'src/bongos/routes/feedback.js'));
|
|
187
|
+
let now = 0;
|
|
188
|
+
const store = makeReceiptStore({ now: () => now, ttlMs: 1000, max: 3 });
|
|
189
|
+
const id = store.issue({ github_id: '1' });
|
|
190
|
+
now = 1001;
|
|
191
|
+
assert.equal(store.redeem(id), null, 'expired');
|
|
192
|
+
const live = [0, 1, 2].map((i) => store.issue({ github_id: String(i) }));
|
|
193
|
+
assert.equal(store.issue({ github_id: 'noisy' }), null, 'a full store refuses the new one');
|
|
194
|
+
assert.equal(store._rows.size, 3);
|
|
195
|
+
assert.equal(store.redeem(live[0]).github_id, '0', 'and never evicts a pending receipt');
|
|
194
196
|
});
|
|
195
197
|
|
|
196
|
-
test('a
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
198
|
+
test('a members-only project still lets the hub redeem a receipt — and nothing else', () => {
|
|
199
|
+
const { isExempt } = require_(path.join(ROOT, 'src/bongos/platform-visibility-gate.js'));
|
|
200
|
+
assert.equal(isExempt('/api/bongos/feedback/receipt/AbC_d-123456789012', 'GET'), true);
|
|
201
|
+
assert.equal(isExempt('/api/bongos/feedback/receipt', 'POST'), false, 'issuing one needs a member');
|
|
202
|
+
assert.equal(isExempt('/api/bongos/feedback/receipt/AbC/x', 'GET'), false);
|
|
203
|
+
assert.equal(isExempt('/api/bongos/feedback/receipt/AbC', 'DELETE'), false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('reports go to the Cloud Bongos hub unless overridden; the hub itself answers null', () => {
|
|
207
|
+
const { feedbackHub, DEFAULT_FEEDBACK_HUB } = require_(path.join(ROOT, 'src/bongos/routes/feedback.js'));
|
|
208
|
+
assert.equal(feedbackHub({ env: {}, isHub: false }), DEFAULT_FEEDBACK_HUB);
|
|
209
|
+
assert.equal(feedbackHub({ env: {}, isHub: true }), null);
|
|
210
|
+
assert.equal(feedbackHub({ env: { CLOUDBONGOS_FEEDBACK_HUB: 'https://test-hub.example.com/' }, isHub: false }), 'https://test-hub.example.com');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── the hub: the direct door (task 1004591) ──────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function receiptFetch(receipt, status = 200) {
|
|
216
|
+
const calls = [];
|
|
217
|
+
const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { status, json: async () => receipt }; };
|
|
218
|
+
return { fetchImpl, calls };
|
|
219
|
+
}
|
|
220
|
+
const DEPS = (extra = {}) => ({
|
|
221
|
+
assertPublicHost: async () => {},
|
|
222
|
+
capture: async (c) => { DEPS.last = c; return { id: 3, routing: { routed: true, task_id: 77, goal_id: 9, status: 'backlog' } }; },
|
|
223
|
+
getAccount: async () => ({ github_login: 'hub-login' }),
|
|
224
|
+
hubBuilderId: async () => 99,
|
|
225
|
+
...extra,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('direct: a hosted project\'s matching receipt files the report as verified', async () => {
|
|
229
|
+
const { fetchImpl, calls } = receiptFetch({ github_id: '42', github_login: 'will', sha256: HASH, kind: 'bug', core_version: '1.21.70' });
|
|
230
|
+
const landing = await IF.fileDirectReport({
|
|
231
|
+
origin: 'https://mercury.cloudbongos.com', receipt: 'r'.repeat(24), message: 'it broke', kind: 'bug',
|
|
232
|
+
context: { cli_version: '1.21.70', os: 'darwin' }, hubHost: 'cloudbongos.com',
|
|
233
|
+
}, DEPS({ fetchImpl }));
|
|
234
|
+
assert.equal(calls[0].url, `https://mercury.cloudbongos.com/api/bongos/feedback/receipt/${'r'.repeat(24)}`);
|
|
235
|
+
assert.equal(calls[0].opts.redirect, 'error', 'a redirect is never followed');
|
|
236
|
+
assert.equal(landing.landed, 'task');
|
|
237
|
+
assert.equal(DEPS.last.filerRank, null, 'still waits for a nod');
|
|
238
|
+
assert.equal(DEPS.last.vector, 'instance-feedback');
|
|
239
|
+
assert.equal(DEPS.last.capturedBy, 99);
|
|
240
|
+
assert.match(DEPS.last.bodyMd, /Sent by: @hub-login/);
|
|
241
|
+
assert.match(DEPS.last.bodyMd, /verified \(a project Cloud Bongos hosts\)/);
|
|
242
|
+
assert.match(DEPS.last.bodyMd, /Core 1\.21\.70/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('direct: a self-hosted project\'s word is labelled and never borrows a hub account', async () => {
|
|
246
|
+
const { fetchImpl } = receiptFetch({ github_id: '42', github_login: 'will', sha256: HASH, kind: 'bug' });
|
|
247
|
+
const lookups = [];
|
|
248
|
+
await IF.fileDirectReport({
|
|
249
|
+
origin: 'https://bongos.acme.example', receipt: 'r'.repeat(24), message: 'it broke', kind: 'bug', hubHost: 'cloudbongos.com',
|
|
250
|
+
}, DEPS({
|
|
251
|
+
fetchImpl,
|
|
252
|
+
getAccount: async (id) => { lookups.push(['account', id]); return { github_login: 'hub-login' }; },
|
|
253
|
+
hubBuilderId: async (id) => { lookups.push(['builder', id]); return 99; },
|
|
254
|
+
}));
|
|
255
|
+
assert.deepEqual(lookups, [], 'neither hub row is even read for an unverified sender');
|
|
256
|
+
assert.equal(DEPS.last.capturedBy, null);
|
|
257
|
+
assert.doesNotMatch(DEPS.last.bodyMd, /hub-login/);
|
|
258
|
+
assert.match(DEPS.last.bodyMd, /Sent by: @will \(GitHub user 42\)/);
|
|
259
|
+
assert.match(DEPS.last.bodyMd, /confirmed by bongos\.acme\.example \(self-hosted, not verified by us\)/);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('direct: a report the project does not vouch for is dropped, never filed', async () => {
|
|
263
|
+
const filed = [];
|
|
264
|
+
const deps = (receipt, status, extra) => DEPS({ ...receiptFetch(receipt, status), capture: async (c) => { filed.push(c); return {}; }, ...extra });
|
|
265
|
+
const base = { origin: 'https://p.example.com', receipt: 'r'.repeat(24), message: 'it broke', kind: 'bug', hubHost: 'cloudbongos.com' };
|
|
266
|
+
const good = { github_id: '42', sha256: HASH, kind: 'bug' };
|
|
267
|
+
assert.deepEqual(await IF.fileDirectReport(base, deps({ ...good, sha256: IF.sha256Hex('something else') })), { dropped: 'text_mismatch' });
|
|
268
|
+
assert.deepEqual(await IF.fileDirectReport(base, deps({ ...good, kind: 'idea' })), { dropped: 'kind_mismatch' });
|
|
269
|
+
assert.deepEqual(await IF.fileDirectReport(base, deps(good, 404)), { dropped: 'receipt_http_404' });
|
|
270
|
+
assert.deepEqual(await IF.fileDirectReport(base, deps({ ...good, github_id: 'x' })), { dropped: 'receipt_malformed' });
|
|
271
|
+
assert.deepEqual(await IF.fileDirectReport(base, deps(good, 200, { assertPublicHost: async () => { throw new Error('private'); } })), { dropped: 'private_host' });
|
|
272
|
+
assert.deepEqual(await IF.fileDirectReport({ ...base, receipt: '../../x' }, deps(good)), { dropped: 'bad_receipt' });
|
|
273
|
+
assert.equal(filed.length, 0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('direct: only a bare https origin on a public DNS name is ever called', () => {
|
|
277
|
+
for (const bad of ['http://p.example.com', 'https://127.0.0.1', 'https://[::1]', 'https://localhost', 'https://p.example.com:8443',
|
|
278
|
+
'https://p.example.com/path', 'https://u:p@p.example.com', 'https://box.internal', 'https://intranet', 'ftp://p.example.com']) {
|
|
279
|
+
assert.equal(IF.projectOrigin(bad), null, bad);
|
|
280
|
+
}
|
|
281
|
+
assert.deepEqual(IF.projectOrigin('https://Mercury.CloudBongos.com/'), { origin: 'https://mercury.cloudbongos.com', host: 'mercury.cloudbongos.com' });
|
|
282
|
+
assert.equal(IF.isHostedBy('mercury.cloudbongos.com', 'cloudbongos.com'), true);
|
|
283
|
+
assert.equal(IF.isHostedBy('evilcloudbongos.com', 'cloudbongos.com'), false);
|
|
284
|
+
assert.equal(IF.isHostedBy('x.example.com', ''), false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('direct route: answers 202 before verifying, and caps BEFORE anything is fetched', () => {
|
|
288
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/sso.js'), 'utf8');
|
|
289
|
+
const route = src.slice(src.indexOf("router.post('/sso/feedback/direct'"));
|
|
290
|
+
const at = (s) => { const i = route.indexOf(s); assert.ok(i > 0, `route has ${s}`); return i; };
|
|
291
|
+
assert.ok(at('projectOrigin') < at('directIpLimiter.take'));
|
|
292
|
+
assert.ok(at("directIpLimiter.take(ip, 'direct')") < at('directFeedbackLimiter.take'), 'a per-address cap no named host can dodge');
|
|
293
|
+
assert.ok(at('directFeedbackLimiter.take') < at('res.status(202)'));
|
|
294
|
+
assert.ok(at('res.status(202)') < at('fileDirectReport'));
|
|
295
|
+
assert.match(route, /assertPublicHost/);
|
|
209
296
|
});
|
|
210
297
|
|
|
211
298
|
// ── the CLI ───────────────────────────────────────────────────────────────────
|
|
212
299
|
|
|
213
|
-
test('`bongos feedback` is a registered verb, and every refusal
|
|
300
|
+
test('`bongos feedback` is a registered verb, and every refusal has a plain line', () => {
|
|
214
301
|
const { VERBS } = require_(path.join(ROOT, 'bin/bongos.js'));
|
|
215
302
|
assert.equal(VERBS.feedback.script, 'feedback-send.js');
|
|
216
303
|
const { REFUSALS, readMessage } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
|
|
217
|
-
for (const code of ['message_required', '
|
|
218
|
-
assert.ok(REFUSALS[code], code);
|
|
219
|
-
}
|
|
220
|
-
for (const code of ['validation_failed']) {
|
|
221
|
-
assert.ok(REFUSALS[code], code);
|
|
222
|
-
}
|
|
304
|
+
for (const code of ['message_required', 'too_long', 'local_only', 'no_scrubber']) assert.ok(REFUSALS[code], code);
|
|
223
305
|
assert.equal(readMessage(['--idea', 'filter', 'by', 'goal']), 'filter by goal');
|
|
224
306
|
});
|
|
225
307
|
|
|
308
|
+
// The skill tells the agent to use --message-file; it used to send the literal
|
|
309
|
+
// "[object Object]" because argText's result object was used as the text.
|
|
310
|
+
test('--message-file and stdin give the TEXT, never an object', () => {
|
|
311
|
+
const { readMessage } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
|
|
312
|
+
assert.equal(readMessage(['--message-file', '-'], { readStdin: () => 'from stdin' }), 'from stdin');
|
|
313
|
+
assert.equal(readMessage(['--message-file', 'x.md'], { readFile: () => 'from file' }), 'from file');
|
|
314
|
+
assert.equal(readMessage([], { stdinIsPiped: true, readStdin: () => 'piped' }), 'piped');
|
|
315
|
+
assert.equal(readMessage([], { stdinIsPiped: false }), '');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
async function runCli(argv, extra = {}) {
|
|
319
|
+
const { main } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
|
|
320
|
+
const out = []; const err = [];
|
|
321
|
+
const log = console.log; const error = console.error;
|
|
322
|
+
console.log = (...a) => out.push(a.join(' ')); console.error = (...a) => err.push(a.join(' '));
|
|
323
|
+
const spawned = [];
|
|
324
|
+
try {
|
|
325
|
+
const code = await main(argv, {
|
|
326
|
+
requireSession: async () => ({ token: 't' }),
|
|
327
|
+
base: 'https://mercury.cloudbongos.com',
|
|
328
|
+
checkRepeat: () => false,
|
|
329
|
+
spawnDelivery: (job) => spawned.push(job),
|
|
330
|
+
...extra,
|
|
331
|
+
});
|
|
332
|
+
return { code, out, err, spawned };
|
|
333
|
+
} finally { console.log = log; console.error = error; }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
test('the CLI prints one line and hands the network to the background', async () => {
|
|
337
|
+
const r = await runCli(['claim', 'hung']);
|
|
338
|
+
assert.equal(r.code, 0);
|
|
339
|
+
assert.deepEqual(r.out, ['Sent to the Bongos team.']);
|
|
340
|
+
assert.deepEqual(r.spawned, [{ message: 'claim hung', kind: 'bug' }]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('the CLI refuses a project only this computer can reach, and sends nothing', async () => {
|
|
344
|
+
for (const base of ['http://localhost:3000', 'http://127.0.0.1:3006', 'https://dev.test']) {
|
|
345
|
+
const r = await runCli(['x'], { base });
|
|
346
|
+
assert.equal(r.code, 1, base);
|
|
347
|
+
assert.equal(r.spawned.length, 0);
|
|
348
|
+
assert.match(r.err.join('\n'), /only on your computer/);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('the same report twice inside the window is sent once', async () => {
|
|
353
|
+
const { checkAndRecordRepeat } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
|
|
354
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'fb-')), 'sent.json');
|
|
355
|
+
assert.equal(checkAndRecordRepeat('Claim hung\nfirst time', { file, now: 0 }), false);
|
|
356
|
+
assert.equal(checkAndRecordRepeat('claim hung \nsecond rant', { file, now: 1000 }), true);
|
|
357
|
+
assert.equal(checkAndRecordRepeat('a different problem', { file, now: 2000 }), false);
|
|
358
|
+
assert.equal(checkAndRecordRepeat('claim hung', { file, now: 7 * 60 * 60 * 1000 }), false, 'the window expires');
|
|
359
|
+
const r = await runCli(['x'], { checkRepeat: () => true });
|
|
360
|
+
assert.equal(r.spawned.length, 0);
|
|
361
|
+
assert.match(r.out[0], /Already sent/);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('deliver: receipt for the exact text from the project, then the report straight to the hub', async () => {
|
|
365
|
+
const { deliver } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
|
|
366
|
+
const reqs = [];
|
|
367
|
+
const api = { request: async (m, p, o) => { reqs.push({ m, p, body: o.body }); return { ok: true, data: { receipt: 'R'.repeat(24), hub: 'https://hub.example.com' } }; } };
|
|
368
|
+
const sent = [];
|
|
369
|
+
const fetchImpl = async (url, opts) => { sent.push({ url, body: JSON.parse(opts.body) }); return { status: 202, json: async () => ({ ok: true }) }; };
|
|
370
|
+
const out = await deliver({ message: 'it broke', kind: 'bug' }, { api, base: 'https://mercury.cloudbongos.com', fetchImpl });
|
|
371
|
+
assert.deepEqual(out, { ok: true, hub: 'https://hub.example.com' });
|
|
372
|
+
assert.deepEqual(reqs[0], { m: 'POST', p: '/feedback/receipt', body: { sha256: IF.sha256Hex('it broke'), kind: 'bug' } });
|
|
373
|
+
assert.equal(sent[0].url, 'https://hub.example.com/api/bongos/sso/feedback/direct');
|
|
374
|
+
assert.equal(sent[0].body.origin, 'https://mercury.cloudbongos.com');
|
|
375
|
+
assert.equal(sent[0].body.receipt, 'R'.repeat(24));
|
|
376
|
+
assert.equal(sent[0].body.message, 'it broke');
|
|
377
|
+
const hubSelf = await deliver({ message: 'x', kind: 'bug' }, {
|
|
378
|
+
api: { request: async () => ({ ok: true, data: { receipt: 'R'.repeat(24), hub: null } }) },
|
|
379
|
+
base: 'https://cloudbongos.com', fetchImpl,
|
|
380
|
+
});
|
|
381
|
+
assert.equal(hubSelf.hub, 'https://cloudbongos.com', 'on the hub itself, the report goes to the project it talks to');
|
|
382
|
+
});
|
|
383
|
+
|
|
226
384
|
// ── the hub route, driven for real ────────────────────────────────────────────
|
|
227
385
|
// The assembled /sso router with its DB edges stubbed: the client lookup, the
|
|
228
386
|
// membership read and the account read on the platform-identity domain, the
|
|
@@ -230,6 +388,11 @@ test('`bongos feedback` is a registered verb, and every refusal code has a plain
|
|
|
230
388
|
|
|
231
389
|
async function withHub(overrides, fn) {
|
|
232
390
|
const r2 = createRequire(import.meta.url);
|
|
391
|
+
// No real DNS from a unit test: the direct door's background redeem resolves
|
|
392
|
+
// the named host through project-stats, so refuse every host here.
|
|
393
|
+
const ps = r2(path.join(ROOT, 'modules/platform-identity/project-stats.js'));
|
|
394
|
+
const savedAssert = ps.assertPublicHost;
|
|
395
|
+
ps.assertPublicHost = async () => { throw new Error('no network in tests'); };
|
|
233
396
|
const api = r2(path.join(ROOT, 'src/module-api.js'));
|
|
234
397
|
const seams = r2(path.join(ROOT, 'src/module-seams.js'));
|
|
235
398
|
const pi = r2(path.join(ROOT, 'modules/platform-identity/platform-identity.js'));
|
|
@@ -250,6 +413,7 @@ async function withHub(overrides, fn) {
|
|
|
250
413
|
delete r2.cache[routerPath];
|
|
251
414
|
const express = r2('express');
|
|
252
415
|
const app = express();
|
|
416
|
+
app.set('trust proxy', 1); // as src/platform-server.js does
|
|
253
417
|
app.use(express.json());
|
|
254
418
|
app.use(r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js')).attachFail);
|
|
255
419
|
app.use(r2(routerPath)());
|
|
@@ -258,10 +422,14 @@ async function withHub(overrides, fn) {
|
|
|
258
422
|
const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/sso/feedback`, {
|
|
259
423
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
260
424
|
}).then(async (res) => ({ status: res.status, body: await res.json() }));
|
|
261
|
-
|
|
425
|
+
const postTo = (p, body, headers = {}) => fetch(`http://127.0.0.1:${server.address().port}${p}`, {
|
|
426
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
|
|
427
|
+
}).then(async (res) => ({ status: res.status, body: await res.json() }));
|
|
428
|
+
try { await fn({ post, postTo, captured }); } finally {
|
|
262
429
|
server.close();
|
|
263
430
|
Object.assign(pi, saved);
|
|
264
431
|
api.pool.query = savedQuery;
|
|
432
|
+
ps.assertPublicHost = savedAssert;
|
|
265
433
|
delete r2.cache[routerPath];
|
|
266
434
|
}
|
|
267
435
|
}
|
|
@@ -295,6 +463,36 @@ test('hub: a wrong secret, an unwitnessed sender and a forged field are all refu
|
|
|
295
463
|
});
|
|
296
464
|
});
|
|
297
465
|
|
|
466
|
+
// The router's limiters live for the life of the module, so each case below
|
|
467
|
+
// uses its own made-up caller addresses (sent as X-Forwarded-For; the harness
|
|
468
|
+
// trusts the proxy as the platform server does) and its own project hosts.
|
|
469
|
+
const direct = (postTo, ip, host) => postTo('/sso/feedback/direct',
|
|
470
|
+
{ origin: `https://${host}`, receipt: 'r'.repeat(24), message: 'x' }, { 'X-Forwarded-For': ip });
|
|
471
|
+
|
|
472
|
+
test('direct route: one address naming a fresh host each time is still capped', async () => {
|
|
473
|
+
await withHub({}, async ({ postTo }) => {
|
|
474
|
+
const codes = [];
|
|
475
|
+
for (let i = 0; i < IF.PER_SENDER_LIMIT + 2; i++) codes.push((await direct(postTo, '203.0.113.1', `fresh${i}.example.com`)).status);
|
|
476
|
+
assert.deepEqual(codes.slice(0, IF.PER_SENDER_LIMIT), Array(IF.PER_SENDER_LIMIT).fill(202));
|
|
477
|
+
assert.equal(codes.at(-1), 429);
|
|
478
|
+
assert.equal((await postTo('/sso/feedback/direct', { origin: 'https://127.0.0.1', receipt: 'r'.repeat(24), message: 'x' })).status, 400);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('direct route: callers never share a bucket, and a capped caller costs a project nothing', async () => {
|
|
483
|
+
await withHub({}, async ({ postTo }) => {
|
|
484
|
+
// Many different addresses, one report each: nobody is refused (no global bucket).
|
|
485
|
+
for (let i = 0; i < IF.PER_PROJECT_LIMIT + 5; i++) {
|
|
486
|
+
assert.equal((await direct(postTo, `198.51.100.${i + 1}`, `spread${i}.example.com`)).status, 202, `address ${i}`);
|
|
487
|
+
}
|
|
488
|
+
// An address past its own cap keeps naming a victim project: always 429...
|
|
489
|
+
for (let i = 0; i < IF.PER_SENDER_LIMIT; i++) await direct(postTo, '203.0.113.9', `burn${i}.example.com`);
|
|
490
|
+
for (let i = 0; i < IF.PER_PROJECT_LIMIT + 5; i++) assert.equal((await direct(postTo, '203.0.113.9', 'victim.example.com')).status, 429);
|
|
491
|
+
// ...and the victim's own builders still get through.
|
|
492
|
+
assert.equal((await direct(postTo, '192.0.2.50', 'victim.example.com')).status, 202);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
298
496
|
// ── proactive filing (task 1004467) ───────────────────────────────────────────
|
|
299
497
|
// The owner's rule: a builder venting that Bongos is broken gets it filed without
|
|
300
498
|
// asking. That lives in two places, and both must keep saying it.
|
|
@@ -334,6 +532,7 @@ test('every report is secret-scrubbed before it leaves the machine, and never se
|
|
|
334
532
|
assert.ok(r.removed >= 2);
|
|
335
533
|
assert.equal(scrubReport('bongos claim hung for two minutes', {}).text, 'bongos claim hung for two minutes', 'plain prose is untouched');
|
|
336
534
|
const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/feedback-send.js'), 'utf8');
|
|
337
|
-
|
|
338
|
-
assert.
|
|
535
|
+
const main = src.slice(src.indexOf('async function main('));
|
|
536
|
+
assert.ok(main.indexOf('scrubReport(message)') < main.indexOf('spawnDelivery'), 'scrubbed before anything is handed off');
|
|
537
|
+
assert.match(main, /message: scrubbed\.text/, 'only the scrubbed text is sent');
|
|
339
538
|
});
|
|
@@ -78,6 +78,7 @@ ${NEUTRAL_PREFIX}_LOGO_URL=
|
|
|
78
78
|
${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
|
|
79
79
|
${NEUTRAL_PREFIX}_DEMO_PEOPLE=
|
|
80
80
|
${NEUTRAL_PREFIX}_DEMO_HOURS=
|
|
81
|
+
${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
|
|
81
82
|
`;
|
|
82
83
|
|
|
83
84
|
t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
|
|
@@ -147,6 +148,8 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
|
|
|
147
148
|
[`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
|
|
148
149
|
// the birth marker and a demo's box (task 1004506), empty on an un-stamped row
|
|
149
150
|
[`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
|
|
151
|
+
// where the hall's Update button goes (task 1004608), empty when no hub origin resolves
|
|
152
|
+
[`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
|
|
150
153
|
});
|
|
151
154
|
const body = P.webEnvBody(inst, {});
|
|
152
155
|
for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
|
|
@@ -247,7 +250,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
|
|
|
247
250
|
assert.equal(r.ok, true);
|
|
248
251
|
assert.equal(r.healthy, null, 'nothing ran, nothing observed');
|
|
249
252
|
assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
|
|
250
|
-
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
253
|
+
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
251
254
|
assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
|
|
252
255
|
assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
|
|
253
256
|
});
|
|
@@ -630,5 +633,26 @@ await ta('a failed write reports the error without the value', async () => {
|
|
|
630
633
|
assert.ok(!r.error.includes(SECRET), r.error);
|
|
631
634
|
});
|
|
632
635
|
|
|
636
|
+
console.log('\nhubUpgradeEnvVarsFor — where a hosted project\'s Update button goes (task 1004608):');
|
|
637
|
+
|
|
638
|
+
t('both tenant shapes get the hub door for THEIR slug; no other shape gets the key; it rides standup AND the push', () => {
|
|
639
|
+
const saved = process.env.PROVISION_HUB_ORIGIN;
|
|
640
|
+
process.env.PROVISION_HUB_ORIGIN = 'https://hub.example.com/';
|
|
641
|
+
try {
|
|
642
|
+
const key = `${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`;
|
|
643
|
+
for (const shape of ['cloud-host', 'co-tenant']) {
|
|
644
|
+
const inst = { slug: 'mercury', port: 3009, hosting_shape: shape, target_ref: 'will/mercury' };
|
|
645
|
+
assert.deepEqual(require('../scripts/gds/provision-units.js').hubUpgradeEnvVarsFor(inst), { [key]: 'https://hub.example.com/deploy?project=mercury' }, shape);
|
|
646
|
+
assert.equal(P.settingsEnvVarsFor(inst)[key], 'https://hub.example.com/deploy?project=mercury', `${shape} push`);
|
|
647
|
+
assert.match(P.webEnvBody(inst, {}), new RegExp(`^${key}=https://hub\\.example\\.com/deploy\\?project=mercury$`, 'm'), `${shape} standup`);
|
|
648
|
+
}
|
|
649
|
+
for (const shape of ['byo-host', 'control-plane']) {
|
|
650
|
+
assert.deepEqual(require('../scripts/gds/provision-units.js').hubUpgradeEnvVarsFor({ slug: 'x', hosting_shape: shape }), {}, shape);
|
|
651
|
+
}
|
|
652
|
+
} finally {
|
|
653
|
+
if (saved === undefined) delete process.env.PROVISION_HUB_ORIGIN; else process.env.PROVISION_HUB_ORIGIN = saved;
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
633
657
|
console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
|
|
634
658
|
process.exit(failed ? 1 : 0);
|
|
@@ -180,7 +180,8 @@ async function drive(deps) {
|
|
|
180
180
|
const build = require('../src/bongos/routes/core-update.js');
|
|
181
181
|
const express = require('express');
|
|
182
182
|
const app = express();
|
|
183
|
-
|
|
183
|
+
// The ask state's stored row is the DB's; these tests never reach one (task 1004595).
|
|
184
|
+
app.use(build({ askDeps: () => ({ hub: 'https://cloudbongos.com', getSetting: async () => null }), ...deps }));
|
|
184
185
|
auth.requireBuilder = saved;
|
|
185
186
|
const server = app.listen(0);
|
|
186
187
|
await new Promise((r) => server.once('listening', r));
|
|
@@ -205,6 +206,22 @@ test('a hosted project: the Update button goes to the hub\'s door for THIS proje
|
|
|
205
206
|
assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=hoodify-finance' });
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
// Task 1004608: Will's mercury (a cloud-host that keeps its own GitHub app) has no sign-in
|
|
210
|
+
// federation, so the panel told its owner to run `bongos upgrade` on the platform's server.
|
|
211
|
+
test('a hosted project that does not federate sign-in still gets the hub\'s door, from HUB_UPGRADE_URL', async () => {
|
|
212
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
|
|
213
|
+
doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: 'https://cloudbongos.com/deploy?project=mercury' }) });
|
|
214
|
+
assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('HUB_UPGRADE_URL is taken only as a hub /deploy address; anything else stays the cli answer', async () => {
|
|
218
|
+
for (const bad of ['', 'javascript:alert(1)', 'https://evil.example/phish', 'https://cloudbongos.com/deploy?project=a b', '/deploy']) {
|
|
219
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
|
|
220
|
+
doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: bad }) });
|
|
221
|
+
assert.deepEqual(r.body.door, { kind: 'cli', url: null }, bad);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
208
225
|
test('a self-hosted instance has no door to link, and says how it is upgraded instead', async () => {
|
|
209
226
|
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
|
|
210
227
|
assert.deepEqual(r.body.door, { kind: 'cli', url: null });
|
|
@@ -94,8 +94,8 @@ t('offers the wrong-instance escape hatch (bongos login)', () => {
|
|
|
94
94
|
t('a hosted origin never routes to bongos dev', () => {
|
|
95
95
|
for (const deps of [true, false]) {
|
|
96
96
|
const card = instanceNotRunningCard({ origin: 'https://example-instance.cloudbongos.com', depsMissing: deps });
|
|
97
|
-
assert.ok(!/^\s*\d+\.\s*`bongos dev`/m.test(card), 'must not list `bongos dev` as a step');
|
|
98
|
-
assert.ok(/Don't run `bongos dev`/.test(card), 'must say plainly not to, and why');
|
|
97
|
+
assert.ok(!/^\s*\d+\.\s*`(npx )?bongos dev`/m.test(card), 'must not list `bongos dev` as a step');
|
|
98
|
+
assert.ok(/Don't run `npx bongos dev`/.test(card), 'must say plainly not to, and why');
|
|
99
99
|
assert.ok(/empty database/.test(card), 'must name the consequence, not just forbid it');
|
|
100
100
|
}
|
|
101
101
|
});
|