@bongos/core 1.19.348 → 1.19.350
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 +82 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0184-a-task-visual-inherits-its-summary-audience.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +208 -3
- package/docs/api-reference.md +10 -2
- package/docs/architecture.md +6 -1
- package/docs/copy-inventory.md +38 -38
- package/docs/copy-registry.json +40 -40
- package/docs/file-map.md +2 -1
- package/migrations/core_223_task_visual.sql +33 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/hall-ui/public/hall-kit.js +33 -0
- package/modules/hall-ui/public/hall-render.js +1 -0
- package/modules/hall-ui/public/profile.html +4 -1
- package/modules/hall-ui/public/profile.js +7 -1
- package/modules/hall-ui/public/style.css +29 -0
- package/modules/hall-ui/public/task.js +30 -0
- package/modules/lifecycle/db-analytics.js +3 -1
- package/modules/lifecycle/github-push.js +51 -4
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/tasks.js +22 -2
- package/modules/lifecycle/routes/visuals.js +216 -0
- package/modules/lifecycle/task-visual-db.js +46 -0
- package/modules/lifecycle/task-visuals.js +270 -0
- package/modules/status-ui/public/status.js +33 -0
- package/modules/status-ui/public/style.css +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-flow.js +11 -1
- package/scripts/gds/ship-land.js +24 -2
- package/scripts/gds/ship-visual.js +68 -0
- package/scripts/gds/task.js +62 -0
- package/src/bongos/routes/public.js +8 -0
- package/src/bongos/routes.js +5 -1
- package/src/module-api.js +5 -1
- package/tests/ship_publish_error.mjs +86 -2
- package/tests/task_visual_route_gate.mjs +151 -0
- package/tests/task_visuals.mjs +200 -0
package/src/module-api.js
CHANGED
|
@@ -1126,8 +1126,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1126
1126
|
// landed since 1.19.346 with no explicit bump. run 32802265337. (task 1002620)
|
|
1127
1127
|
// 1.19.348 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1128
1128
|
// landed since 1.19.347 with no explicit bump. run 32802746363. (task 1002620)
|
|
1129
|
+
// 1.19.349 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1130
|
+
// landed since 1.19.348 with no explicit bump. run 32803276871. (task 1002620)
|
|
1131
|
+
// 1.19.350 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1132
|
+
// landed since 1.19.349 with no explicit bump. run 32810944115. (task 1002620)
|
|
1129
1133
|
// ---------------------------------------------------------------------------
|
|
1130
|
-
const CORE_VERSION = '1.19.
|
|
1134
|
+
const CORE_VERSION = '1.19.350'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1131
1135
|
|
|
1132
1136
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1133
1137
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -99,7 +99,19 @@ test('a "fetch first" rejection names the branch and the reset-onto-origin remed
|
|
|
99
99
|
assert.match(out, /reset/i, 'names the reset');
|
|
100
100
|
assert.match(out, /generator/i, 'and the regenerate step before re-shipping');
|
|
101
101
|
assert.match(out, /re-ship|fast-forward/i, 'and why: so the push fast-forwards');
|
|
102
|
-
|
|
102
|
+
// CONTRACT CHANGED, task 1003222. This used to assert the OPPOSITE — that the
|
|
103
|
+
// whole thing collapsed to one line — which was right while the server clipped
|
|
104
|
+
// stderr to 200 chars, and wrong once it stopped. The summary line is still
|
|
105
|
+
// collapsed and scannable; the verbatim text is quoted BELOW it, because git puts
|
|
106
|
+
// the line that names the cause last and a one-line squash buries it.
|
|
107
|
+
const [summary] = out.split('\n');
|
|
108
|
+
assert.ok(!/\n/.test(summary), 'the SUMMARY is still one scannable line');
|
|
109
|
+
assert.match(summary, /reason:/, 'and still carries a reason inline');
|
|
110
|
+
assert.match(out, /what the remote actually said/, 'the untruncated stderr is quoted below');
|
|
111
|
+
assert.ok(
|
|
112
|
+
out.includes("error: failed to push some refs to 'github.com/org/repo.git'"),
|
|
113
|
+
'the remote message survives VERBATIM, on its own line — the whole point of the task',
|
|
114
|
+
);
|
|
103
115
|
});
|
|
104
116
|
|
|
105
117
|
test('the "non-fast-forward" wording triggers the same hint through the envelope', () => {
|
|
@@ -158,5 +170,77 @@ test('1003221 a non-NFF failure gains no hint from either flag', () => {
|
|
|
158
170
|
}
|
|
159
171
|
});
|
|
160
172
|
|
|
173
|
+
|
|
174
|
+
// --- task 1003222: the remote's own words reach the builder ------------------
|
|
175
|
+
//
|
|
176
|
+
// The failure this closes: a builder saw `reason: error: failed` — clipped
|
|
177
|
+
// mid-word by a 200-char slice on top of an earlier 500-char one — while the line
|
|
178
|
+
// that named the cause (`remote: fatal error in commit_refs`) and the HTTP status
|
|
179
|
+
// existed server-side only. That cost hours AND produced a wrong first diagnosis,
|
|
180
|
+
// because the real message was never in the room.
|
|
181
|
+
|
|
182
|
+
const LONG_STDERR = [
|
|
183
|
+
'To https://github.com/org/repo.git',
|
|
184
|
+
' ! [remote rejected] b -> b (pre-receive hook declined)',
|
|
185
|
+
'error: failed to push some refs',
|
|
186
|
+
'remote: fatal error in commit_refs', // git's useful line is LAST
|
|
187
|
+
].join('\n');
|
|
188
|
+
|
|
189
|
+
test('1003222 the LAST line survives — a head-truncation would have eaten it', () => {
|
|
190
|
+
const out = fmt({ error: 'publish_failed', step: 'push', reason: LONG_STDERR });
|
|
191
|
+
assert.ok(
|
|
192
|
+
out.includes('remote: fatal error in commit_refs'),
|
|
193
|
+
'the cause git printed last must reach the builder',
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('1003222 the git exit code is reported — 128 and 1 are different remedies', () => {
|
|
198
|
+
const out = fmt({ error: 'publish_failed', step: 'push', reason: 'boom', git_exit_code: 128 });
|
|
199
|
+
assert.match(out, /git exit 128/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('1003222 a GitHub HTTP status is reported when the API refused', () => {
|
|
203
|
+
const out = fmt({ error: 'pr_create_failed', github_status: 422, reason: 'No commits between main and b' });
|
|
204
|
+
assert.match(out, /GitHub HTTP 422/, '422 vs 403 vs 404 are three different fixes');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('1003222 a timeout says so rather than looking like a rejection', () => {
|
|
208
|
+
const out = fmt({ error: 'publish_failed', step: 'push', reason: 'killed', timed_out: true });
|
|
209
|
+
assert.match(out, /TIMED OUT/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('1003222 a single-line stderr is NOT repeated in a quote block', () => {
|
|
213
|
+
// It is already fully present in the summary; quoting it again is noise.
|
|
214
|
+
const out = fmt({ error: 'publish_failed', step: 'push', reason: 'fatal: repository not found' });
|
|
215
|
+
assert.ok(!out.includes('what the remote actually said'), 'no block for a one-liner');
|
|
216
|
+
assert.match(out, /fatal: repository not found/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('1003222 an omitted middle is DECLARED, never silently clipped', () => {
|
|
220
|
+
const out = fmt({
|
|
221
|
+
error: 'publish_failed', step: 'push', reason: LONG_STDERR, reason_bytes: 99999,
|
|
222
|
+
});
|
|
223
|
+
assert.match(out, /99999 bytes at the server/, 'the reader is told text is missing');
|
|
224
|
+
assert.match(out, /omitted/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('1003222 the remedy precedes the quote block, not stranded under it', () => {
|
|
228
|
+
// The remedy is the actionable sentence. Below twenty lines of git chatter it
|
|
229
|
+
// reads as a footnote to the quotation and gets missed.
|
|
230
|
+
const out = fmt({ error: 'publish_failed', step: 'push', reason: NON_FF_STDERR });
|
|
231
|
+
const remedy = out.indexOf('reset onto');
|
|
232
|
+
const quote = out.indexOf('what the remote actually said');
|
|
233
|
+
assert.ok(remedy > -1 && quote > -1, 'both present');
|
|
234
|
+
assert.ok(remedy < quote, 'remedy comes first');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('1003222 nothing regressed for a failure carrying no stderr at all', () => {
|
|
238
|
+
const out = fmt({ error: 'publish_failed' });
|
|
239
|
+
assert.match(out, /publish_failed/);
|
|
240
|
+
assert.ok(!out.includes('what the remote actually said'));
|
|
241
|
+
assert.ok(!/undefined|null|\[object/.test(out), 'no placeholder leakage');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
|
|
161
245
|
console.log(`ship_publish_error: ${passed} passed, ${failed} failed`);
|
|
162
|
-
process.exit(failed === 0 ? 0 : 1);
|
|
246
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// tests/task_visual_route_gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// Route-level proof of the ONE rule behind ship-time task visuals (task 1003109,
|
|
4
|
+
// ADR 0184): a visual has no audience of its own — it inherits the audience of the
|
|
5
|
+
// value_summary it restates. Already-public work (shipped|confirmed, not
|
|
6
|
+
// security_sensitive) serves unauthenticated; EVERYTHING else falls through to
|
|
7
|
+
// auth.requireBuilder and never past it.
|
|
8
|
+
//
|
|
9
|
+
// The unit tests in tests/task_visuals.mjs pin the predicate. This pins the WIRING
|
|
10
|
+
// — that the handler actually consults it, in the right order, and fails closed on
|
|
11
|
+
// every miss. It runs the real Express router with the data layer and the auth gate
|
|
12
|
+
// stubbed at the module boundary; no DB, no network, no session.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/task_visual_route_gate.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import fs from 'node:fs/promises';
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
const TMP = path.join(os.tmpdir(), `otb-visualroute-${process.pid}`);
|
|
25
|
+
process.env.TASK_VISUALS_DIR = TMP;
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
const visualDb = require('../modules/lifecycle/task-visual-db.js');
|
|
30
|
+
const visuals = require('../modules/lifecycle/task-visuals.js');
|
|
31
|
+
|
|
32
|
+
// ---- stubs at the module boundary -----------------------------------------
|
|
33
|
+
// The route reads `db.getTaskVisualAccess` and `auth.requireBuilder` off these
|
|
34
|
+
// module objects at CALL time, so replacing the properties is enough — no loader
|
|
35
|
+
// tricks, and the router under test is the real one.
|
|
36
|
+
let taskRow = null; // what the "DB" says about the task
|
|
37
|
+
let lookupThrows = false;
|
|
38
|
+
visualDb.getTaskVisualAccess = async () => {
|
|
39
|
+
if (lookupThrows) throw new Error('simulated DB outage');
|
|
40
|
+
return taskRow;
|
|
41
|
+
};
|
|
42
|
+
let authCalls = 0;
|
|
43
|
+
api.requireBuilder = (req, res) => {
|
|
44
|
+
authCalls++;
|
|
45
|
+
// Stand in for the real gate's deny path: it owns its own response, so reaching
|
|
46
|
+
// here means the request was NOT served publicly.
|
|
47
|
+
res.status(401).json({ error: 'unauthenticated' });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const buildRouter = require('../modules/lifecycle/routes/visuals.js');
|
|
51
|
+
|
|
52
|
+
// res.fail is composed at the kernel level in production; supply the same shape.
|
|
53
|
+
const app = express();
|
|
54
|
+
app.use((req, res, next) => {
|
|
55
|
+
res.fail = (code, status) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code });
|
|
56
|
+
next();
|
|
57
|
+
});
|
|
58
|
+
app.use(buildRouter());
|
|
59
|
+
|
|
60
|
+
const server = http.createServer(app);
|
|
61
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
62
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
63
|
+
|
|
64
|
+
// A real stored file, so a successful gate actually streams bytes.
|
|
65
|
+
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
66
|
+
const stored = await visuals.persistImageBuffer(PNG, { taskId: 4242, contentType: 'image/png', dir: TMP });
|
|
67
|
+
const NAME = stored.file;
|
|
68
|
+
|
|
69
|
+
let passed = 0, failed = 0;
|
|
70
|
+
function t(name, fn) {
|
|
71
|
+
return Promise.resolve().then(fn)
|
|
72
|
+
.then(() => { passed++; console.log(` PASS ${name}`); })
|
|
73
|
+
.catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
|
|
74
|
+
}
|
|
75
|
+
async function get(name) {
|
|
76
|
+
authCalls = 0;
|
|
77
|
+
const res = await fetch(`${base}/task-visuals/${name}`);
|
|
78
|
+
return { status: res.status, authCalls, buf: Buffer.from(await res.arrayBuffer()), headers: res.headers };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('Task-visual serve gate (task 1003109 / ADR 0184):');
|
|
82
|
+
|
|
83
|
+
await t('a SHIPPED task serves its visual to an unauthenticated caller', async () => {
|
|
84
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
85
|
+
const r = await get(NAME);
|
|
86
|
+
assert.equal(r.status, 200);
|
|
87
|
+
assert.equal(r.authCalls, 0, 'public work must not be sent through the auth gate');
|
|
88
|
+
assert.deepEqual(r.buf, PNG);
|
|
89
|
+
assert.equal(r.headers.get('content-type'), 'image/png');
|
|
90
|
+
assert.equal(r.headers.get('x-content-type-options'), 'nosniff');
|
|
91
|
+
assert.equal(r.headers.get('content-disposition'), 'inline');
|
|
92
|
+
assert.match(r.headers.get('cache-control'), /^public,/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t('a CONFIRMED task is public too — the same set as a public value_summary', async () => {
|
|
96
|
+
taskRow = { id: 4242, status: 'confirmed', security_sensitive: false };
|
|
97
|
+
assert.equal((await get(NAME)).status, 200);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await t('IN-FLIGHT work never serves publicly — it falls through to requireBuilder', async () => {
|
|
101
|
+
for (const status of ['backlog', 'ready', 'active', 'completed', 'abandoned']) {
|
|
102
|
+
taskRow = { id: 4242, status, security_sensitive: false };
|
|
103
|
+
const r = await get(NAME);
|
|
104
|
+
assert.equal(r.status, 401, status);
|
|
105
|
+
assert.equal(r.authCalls, 1, `${status} must reach the auth gate`);
|
|
106
|
+
assert.equal(r.buf.length && r.buf.includes(PNG), false, `${status} must not leak bytes`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await t('a SHIPPED but security-sensitive task stays builders-only (the one narrowing)', async () => {
|
|
111
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: true };
|
|
112
|
+
const r = await get(NAME);
|
|
113
|
+
assert.equal(r.status, 401);
|
|
114
|
+
assert.equal(r.authCalls, 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await t('an ABSENT task fails closed — no public serve, no crash', async () => {
|
|
118
|
+
taskRow = null;
|
|
119
|
+
const r = await get(NAME);
|
|
120
|
+
assert.equal(r.status, 401);
|
|
121
|
+
assert.equal(r.authCalls, 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await t('a DB outage fails closed rather than opening the door', async () => {
|
|
125
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
126
|
+
lookupThrows = true;
|
|
127
|
+
const r = await get(NAME);
|
|
128
|
+
lookupThrows = false;
|
|
129
|
+
assert.equal(r.status, 401, 'a lookup failure must not be read as "public"');
|
|
130
|
+
assert.equal(r.authCalls, 1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await t('a malformed name is refused BEFORE any lookup (the traversal wall)', async () => {
|
|
134
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
135
|
+
for (const bad of ['..%2F..%2Fetc%2Fpasswd', 'screenshot.png', 'task-4242-0011.png', 'task-4242-0011223344556677.svg']) {
|
|
136
|
+
const r = await get(bad);
|
|
137
|
+
assert.equal(r.status, 400, bad);
|
|
138
|
+
assert.equal(r.authCalls, 0, `${bad} must not even reach the task lookup`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('a well-formed name with no file on disk is a 404, not a 200', async () => {
|
|
143
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
144
|
+
const r = await get('task-4242-ffffffffffffffff.png');
|
|
145
|
+
assert.equal(r.status, 404);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
server.close();
|
|
149
|
+
await fs.rm(TMP, { recursive: true, force: true });
|
|
150
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
151
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/task_visuals.mjs
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for ship-time task visuals (task 1003109, goal 1000072 "Human
|
|
4
|
+
// legibility"): filename safety / path-traversal, the task-id a stored name
|
|
5
|
+
// belongs to, the upload screening (type allowlist, size cap, magic-byte sniff),
|
|
6
|
+
// the AUDIENCE predicate that decides whether an image is public, alt-text
|
|
7
|
+
// normalization, and persist/delete against a temp dir. No network, no DB.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/task_visuals.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
// Point storage at a throwaway dir BEFORE requiring the module.
|
|
19
|
+
const TMP = path.join(os.tmpdir(), `otb-taskvisual-${process.pid}`);
|
|
20
|
+
process.env.TASK_VISUALS_DIR = TMP;
|
|
21
|
+
const tv = require('../modules/lifecycle/task-visuals.js');
|
|
22
|
+
const kit = require('../modules/hall-ui/public/hall-kit.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0, failed = 0;
|
|
25
|
+
function t(name, fn) {
|
|
26
|
+
return Promise.resolve().then(fn)
|
|
27
|
+
.then(() => { passed++; console.log(` PASS ${name}`); })
|
|
28
|
+
.catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Minimal real headers, long enough for the sniff's 12-byte floor.
|
|
32
|
+
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
33
|
+
const JPG = Buffer.from('ffd8ffe000104a46494600010101', 'hex');
|
|
34
|
+
const GIF = Buffer.from('4749463839610100010080000000', 'hex');
|
|
35
|
+
const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('WEBPVP8 ')]);
|
|
36
|
+
const NOT_AN_IMAGE = Buffer.from('<?php system($_GET[0]); ?> ');
|
|
37
|
+
|
|
38
|
+
console.log('Ship-time task visuals (task 1003109):');
|
|
39
|
+
|
|
40
|
+
// ---- isSafeName / resolveSafe / taskIdFromName -----------------------------
|
|
41
|
+
await t('isSafeName: accepts a server-generated name, rejects junk + traversal', () => {
|
|
42
|
+
assert.equal(tv.isSafeName('task-1003109-0011223344556677.png'), true);
|
|
43
|
+
assert.equal(tv.isSafeName('task-1-aabbccddeeff0011.webp'), true);
|
|
44
|
+
// Not the minted shape: user-chosen names, wrong hex length, bad extension.
|
|
45
|
+
assert.equal(tv.isSafeName('screenshot.png'), false);
|
|
46
|
+
assert.equal(tv.isSafeName('task-1003109-00112233.png'), false);
|
|
47
|
+
assert.equal(tv.isSafeName('task-1003109-0011223344556677.svg'), false);
|
|
48
|
+
assert.equal(tv.isSafeName('task-1003109-0011223344556677.png.php'), false);
|
|
49
|
+
// Traversal, absolute paths, and a name that merely CONTAINS a valid one.
|
|
50
|
+
assert.equal(tv.isSafeName('../task-1003109-0011223344556677.png'), false);
|
|
51
|
+
assert.equal(tv.isSafeName('/etc/passwd'), false);
|
|
52
|
+
assert.equal(tv.isSafeName('a/task-1003109-0011223344556677.png'), false);
|
|
53
|
+
assert.equal(tv.isSafeName(null), false);
|
|
54
|
+
assert.equal(tv.isSafeName(42), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await t('resolveSafe: stays inside the dir, and returns null for anything unsafe', () => {
|
|
58
|
+
const p = tv.resolveSafe('task-7-0011223344556677.png', { dir: TMP });
|
|
59
|
+
assert.equal(p, path.join(path.resolve(TMP), 'task-7-0011223344556677.png'));
|
|
60
|
+
assert.equal(tv.resolveSafe('../../etc/passwd', { dir: TMP }), null);
|
|
61
|
+
assert.equal(tv.resolveSafe('task-7-0011223344556677.png/../../x', { dir: TMP }), null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await t('taskIdFromName: reads the owning task, and fails closed on a bad name', () => {
|
|
65
|
+
assert.equal(tv.taskIdFromName('task-1003109-0011223344556677.png'), 1003109);
|
|
66
|
+
// Fails CLOSED: the serve route looks the task up through this, and a null id
|
|
67
|
+
// resolves to no task, which resolves to "not public" — never to open access.
|
|
68
|
+
assert.equal(tv.taskIdFromName('../secrets.png'), null);
|
|
69
|
+
assert.equal(tv.taskIdFromName(''), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---- screening -------------------------------------------------------------
|
|
73
|
+
await t('screenImage: accepts each allowlisted format with matching bytes', () => {
|
|
74
|
+
assert.deepEqual(tv.screenImage(PNG, 'image/png'), { ok: true, ext: 'png' });
|
|
75
|
+
assert.deepEqual(tv.screenImage(JPG, 'image/jpeg'), { ok: true, ext: 'jpg' });
|
|
76
|
+
assert.deepEqual(tv.screenImage(GIF, 'image/gif'), { ok: true, ext: 'gif' });
|
|
77
|
+
assert.deepEqual(tv.screenImage(WEBP, 'image/webp'), { ok: true, ext: 'webp' });
|
|
78
|
+
// A content-type with parameters still resolves.
|
|
79
|
+
assert.deepEqual(tv.screenImage(PNG, 'image/png; charset=binary'), { ok: true, ext: 'png' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await t('screenImage: refuses a type outside the allowlist (SVG is executable)', () => {
|
|
83
|
+
assert.equal(tv.screenImage(PNG, 'image/svg+xml').ok, false);
|
|
84
|
+
assert.equal(tv.screenImage(PNG, 'text/html').ok, false);
|
|
85
|
+
assert.equal(tv.screenImage(PNG, 'application/octet-stream').ok, false);
|
|
86
|
+
assert.equal(tv.screenImage(PNG, '').ok, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await t('screenImage: a mislabeled content-type cannot smuggle a non-image in', () => {
|
|
90
|
+
const r = tv.screenImage(NOT_AN_IMAGE, 'image/png');
|
|
91
|
+
assert.equal(r.ok, false);
|
|
92
|
+
assert.equal(r.reason, 'sniff_mismatch');
|
|
93
|
+
// Right family, wrong format: JPEG bytes claiming to be a PNG.
|
|
94
|
+
assert.equal(tv.screenImage(JPG, 'image/png').reason, 'sniff_mismatch');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t('screenImage: refuses empty and over-cap payloads', () => {
|
|
98
|
+
assert.equal(tv.screenImage(Buffer.alloc(0), 'image/png').reason, 'empty');
|
|
99
|
+
const huge = Buffer.concat([PNG, Buffer.alloc(tv.MAX_BYTES)]);
|
|
100
|
+
const r = tv.screenImage(huge, 'image/png');
|
|
101
|
+
assert.equal(r.ok, false);
|
|
102
|
+
assert.equal(r.reason, 'too_big');
|
|
103
|
+
assert.equal(r.bytes, huge.length);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---- the audience predicate ------------------------------------------------
|
|
107
|
+
await t('isPubliclyViewable: already-public work only', () => {
|
|
108
|
+
assert.equal(tv.isPubliclyViewable({ status: 'shipped' }), true);
|
|
109
|
+
assert.equal(tv.isPubliclyViewable({ status: 'confirmed' }), true);
|
|
110
|
+
// In-flight work is builders-only, exactly like its value_summary.
|
|
111
|
+
for (const status of ['backlog', 'ready', 'active', 'completed', 'abandoned']) {
|
|
112
|
+
assert.equal(tv.isPubliclyViewable({ status }), false, status);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('isPubliclyViewable: fails closed, and narrows on security_sensitive', () => {
|
|
117
|
+
assert.equal(tv.isPubliclyViewable(null), false);
|
|
118
|
+
assert.equal(tv.isPubliclyViewable(undefined), false);
|
|
119
|
+
assert.equal(tv.isPubliclyViewable({}), false);
|
|
120
|
+
// The one deliberate narrowing vs value_summary: a shipped security task's
|
|
121
|
+
// screenshot stays builders-only.
|
|
122
|
+
assert.equal(tv.isPubliclyViewable({ status: 'shipped', security_sensitive: true }), false);
|
|
123
|
+
assert.equal(tv.isPubliclyViewable({ status: 'shipped', security_sensitive: false }), true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- alt text --------------------------------------------------------------
|
|
127
|
+
await t('normalizeAlt: flattens, trims, bounds — and empty becomes null', () => {
|
|
128
|
+
assert.equal(tv.normalizeAlt(' before and\n after '), 'before and after');
|
|
129
|
+
assert.equal(tv.normalizeAlt(''), null);
|
|
130
|
+
assert.equal(tv.normalizeAlt(' '), null);
|
|
131
|
+
assert.equal(tv.normalizeAlt(null), null);
|
|
132
|
+
assert.equal(tv.normalizeAlt('x'.repeat(1000)).length, tv.MAX_ALT_LENGTH);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- persist / delete ------------------------------------------------------
|
|
136
|
+
await t('persistImageBuffer: writes a name the serve route accepts, under the right task', async () => {
|
|
137
|
+
const r = await tv.persistImageBuffer(PNG, { taskId: 1003109, contentType: 'image/png', dir: TMP });
|
|
138
|
+
assert.equal(r.ok, true);
|
|
139
|
+
assert.equal(tv.isSafeName(r.file), true);
|
|
140
|
+
assert.equal(tv.taskIdFromName(r.file), 1003109);
|
|
141
|
+
assert.equal(r.url, `/api/bongos/task-visuals/${r.file}`);
|
|
142
|
+
// Both prefixes are permanent aliases, so a URL minted under either resolves.
|
|
143
|
+
assert.equal(kit.isTaskVisualUrl(r.url), true);
|
|
144
|
+
assert.equal(r.bytes, PNG.length);
|
|
145
|
+
const onDisk = await fs.readFile(path.join(TMP, r.file));
|
|
146
|
+
assert.deepEqual(onDisk, PNG);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t('persistImageBuffer: two writes never collide (a replace keeps the old file distinct)', async () => {
|
|
150
|
+
const a = await tv.persistImageBuffer(PNG, { taskId: 5, contentType: 'image/png', dir: TMP });
|
|
151
|
+
const b = await tv.persistImageBuffer(PNG, { taskId: 5, contentType: 'image/png', dir: TMP });
|
|
152
|
+
assert.notEqual(a.file, b.file);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('persistImageBuffer: refuses without writing anything', async () => {
|
|
156
|
+
const before = (await fs.readdir(TMP)).length;
|
|
157
|
+
assert.equal((await tv.persistImageBuffer(NOT_AN_IMAGE, { taskId: 1, contentType: 'image/png', dir: TMP })).reason, 'sniff_mismatch');
|
|
158
|
+
assert.equal((await tv.persistImageBuffer(PNG, { taskId: 0, contentType: 'image/png', dir: TMP })).reason, 'bad_task_id');
|
|
159
|
+
assert.equal((await tv.persistImageBuffer(PNG, { taskId: 'x', contentType: 'image/png', dir: TMP })).reason, 'bad_task_id');
|
|
160
|
+
assert.equal((await fs.readdir(TMP)).length, before);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('deleteStored: removes by name or URL, is idempotent, refuses a traversal', async () => {
|
|
164
|
+
const r = await tv.persistImageBuffer(PNG, { taskId: 9, contentType: 'image/png', dir: TMP });
|
|
165
|
+
assert.equal(await tv.deleteStored(r.url, { dir: TMP }), true);
|
|
166
|
+
// Idempotent — removing an already-absent visual is a success, so a replace
|
|
167
|
+
// whose old file was already swept doesn't error.
|
|
168
|
+
assert.equal(await tv.deleteStored(r.url, { dir: TMP }), true);
|
|
169
|
+
assert.equal(await tv.deleteStored('../../etc/passwd', { dir: TMP }), false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---- the client-side URL guard --------------------------------------------
|
|
173
|
+
await t('hall-kit: only a server-minted visual URL is ever rendered', () => {
|
|
174
|
+
assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/task-1003109-0011223344556677.png'), true);
|
|
175
|
+
assert.equal(kit.isTaskVisualUrl('/api/bongos/task-visuals/task-1-aabbccddeeff0011.webp'), true);
|
|
176
|
+
// Anything that is not that exact shape renders nothing — including an
|
|
177
|
+
// absolute URL to another origin, and a javascript: payload.
|
|
178
|
+
assert.equal(kit.isTaskVisualUrl('https://evil.example/x.png'), false);
|
|
179
|
+
assert.equal(kit.isTaskVisualUrl('javascript:alert(1)'), false);
|
|
180
|
+
assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/../../secrets'), false);
|
|
181
|
+
assert.equal(kit.isTaskVisualUrl(''), false);
|
|
182
|
+
assert.equal(kit.isTaskVisualUrl(null), false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await t('hall-kit: no visual renders nothing at all — never a placeholder', () => {
|
|
186
|
+
assert.equal(kit.shipVisualThumbHtml({ id: 1 }), '');
|
|
187
|
+
assert.equal(kit.shipVisualThumbHtml({ id: 1, visual_url: null }), '');
|
|
188
|
+
assert.equal(kit.shipVisualThumbHtml(null), '');
|
|
189
|
+
const html = kit.shipVisualThumbHtml({ id: 1, visual_url: '/api/gds/task-visuals/task-1-0011223344556677.png', visual_alt: 'a "before" shot' });
|
|
190
|
+
assert.ok(html.includes('ship-visual-thumb'));
|
|
191
|
+
// The caption is attacker-adjacent text (a builder wrote it) — it must be escaped.
|
|
192
|
+
assert.ok(html.includes('"before"'));
|
|
193
|
+
assert.ok(!html.includes('"before"'));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---- cleanup ---------------------------------------------------------------
|
|
197
|
+
await fs.rm(TMP, { recursive: true, force: true });
|
|
198
|
+
|
|
199
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
200
|
+
process.exit(failed ? 1 : 0);
|