@bongos/core 1.19.349 → 1.19.351
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 +39 -39
- package/docs/copy-registry.json +49 -49
- 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/module.json +2 -1
- 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/public-landing/projects.html +30 -10
- package/scripts/gds/ship-flow.js +11 -1
- 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/projects_hub.mjs +5 -3
- package/tests/projects_hub_pre_uat.mjs +41 -0
- package/tests/public_landing_projects.mjs +1 -1
- package/tests/task_visual_route_gate.mjs +151 -0
- package/tests/task_visuals.mjs +200 -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);
|