@bongos/core 1.19.1078 → 1.19.1080
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 +35 -25
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +246 -3
- package/docs/api-reference.md +5 -2
- package/docs/architecture.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +16 -0
- package/package-lock.json +2 -2
- package/package.json +2 -2
- package/release-notes.json +12 -0
- package/scripts/gds/module-artifact.js +9 -2
- package/scripts/gds/module.js +272 -1
- package/src/bongos/module-store.js +55 -0
- package/src/bongos/routes/modules.js +119 -2
- package/src/module-api.js +1 -1
- package/tests/module_install.mjs +224 -0
- package/tests/module_update.mjs +232 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// tests/module_install.mjs — `bongos module install <key>` (task 1003785, ADR 0338 D1).
|
|
2
|
+
// No network, no database: cmdInstall runs against a fake store built from a REAL
|
|
3
|
+
// packed tarball (scripts/gds/module-artifact.js packModule), so verification is the
|
|
4
|
+
// real verifyModuleArtifact. The store's read side (src/bongos/module-store.js) runs
|
|
5
|
+
// against a fake pg client, and the three routes are pinned in their source.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/module_install.mjs
|
|
8
|
+
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const { packModule, verifyModuleArtifact } = require('../scripts/gds/module-artifact.js');
|
|
20
|
+
const { readTar, buildTar } = require('../scripts/gds/artifact-format.js');
|
|
21
|
+
const { cmdInstall } = require('../scripts/gds/module.js');
|
|
22
|
+
const { isFreePrice, resolveAcquirable, versionArtifactFile, relativeArtifactPath } = require('../src/bongos/module-store.js');
|
|
23
|
+
|
|
24
|
+
const NOW = () => new Date('2026-09-29T00:00:00Z');
|
|
25
|
+
|
|
26
|
+
// A real packed tarball for one module, built from a temp source tree.
|
|
27
|
+
async function packed(mj = {}) {
|
|
28
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-install-src-'));
|
|
29
|
+
const manifest = { key: 'weather', title: 'Weather', description: 'Shows the weather.', version: '1.2.0', coreVersion: '^1.0.0', contributes: {}, ...mj };
|
|
30
|
+
const dir = path.join(src, manifest.key);
|
|
31
|
+
fs.mkdirSync(path.join(dir, 'routes'), { recursive: true });
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(manifest, null, 2));
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'routes', 'weather.js'), 'module.exports = () => null;\n');
|
|
34
|
+
const { tgz } = packModule(manifest.key, { modulesDir: src, now: NOW, modeOf: () => '644' });
|
|
35
|
+
const v = await verifyModuleArtifact(tgz, { key: manifest.key });
|
|
36
|
+
assert.ok(v.ok, 'the fixture packs cleanly');
|
|
37
|
+
return { tgz, v };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A fake store: answers the three install calls and records what it was asked.
|
|
41
|
+
function fakeStore({ tgz, v }, over = {}) {
|
|
42
|
+
const calls = [];
|
|
43
|
+
const describe = {
|
|
44
|
+
ok: true, module_key: v.moduleJson.key, version: v.version, core_version: v.coreVersion,
|
|
45
|
+
tarball_sha256: v.tarballSha256, tree_sha256: v.treeSha256, tarball_bytes: v.bytes, channel: 'general',
|
|
46
|
+
};
|
|
47
|
+
const request = async (method, urlPath, opts = {}) => {
|
|
48
|
+
calls.push({ method, urlPath, json: opts.json });
|
|
49
|
+
if (urlPath.endsWith('/acquire')) return over.acquire || { ok: true, status: 200, data: { ...describe, ...(over.describe || {}) } };
|
|
50
|
+
if (urlPath.endsWith('/tarball')) return over.download || { ok: true, status: 200, buf: over.buf || tgz };
|
|
51
|
+
if (urlPath.endsWith('/acquired')) return over.acquired || { ok: true, status: 200, data: { ok: true } };
|
|
52
|
+
throw new Error(`unexpected ${method} ${urlPath}`);
|
|
53
|
+
};
|
|
54
|
+
return { calls, request };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dest = () => fs.mkdtempSync(path.join(os.tmpdir(), 'mod-install-dest-'));
|
|
58
|
+
const quiet = () => { const out = []; return { out, log: (m) => out.push(m), errlog: (m) => out.push(m) }; };
|
|
59
|
+
|
|
60
|
+
// ---- cmdInstall ----
|
|
61
|
+
|
|
62
|
+
test('installs a free module: verified, placed, recorded, and NOT enabled', async () => {
|
|
63
|
+
const p = await packed();
|
|
64
|
+
const store = fakeStore(p);
|
|
65
|
+
const modulesDir = dest();
|
|
66
|
+
const q = quiet();
|
|
67
|
+
const code = await cmdInstall(['weather'], { ...q, modulesDir, request: store.request, core: '1.19.0' });
|
|
68
|
+
assert.equal(code, 0, q.out.join('\n'));
|
|
69
|
+
const placed = path.join(modulesDir, 'weather');
|
|
70
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(placed, 'module.json'), 'utf8')).version, '1.2.0');
|
|
71
|
+
assert.equal(fs.readFileSync(path.join(placed, 'routes', 'weather.js'), 'utf8'), 'module.exports = () => null;\n');
|
|
72
|
+
assert.ok(!fs.existsSync(path.join(placed, '.bongos-module.json')), 'the store manifest is not placed as a module file');
|
|
73
|
+
assert.deepEqual(fs.readdirSync(modulesDir), ['weather'], 'no staging dir left behind, nothing else written');
|
|
74
|
+
assert.deepEqual(store.calls.map((c) => c.method + ' ' + c.urlPath), [
|
|
75
|
+
'POST /store/modules/weather/acquire',
|
|
76
|
+
'GET /store/modules/weather/versions/1.2.0/tarball',
|
|
77
|
+
'POST /store/modules/weather/acquired',
|
|
78
|
+
]);
|
|
79
|
+
assert.deepEqual(store.calls[2].json, { version: '1.2.0' }, 'records the exact version placed');
|
|
80
|
+
assert.match(q.out.join('\n'), /not switched on/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('--version asks the store for that exact version; a loose one is refused before any call', async () => {
|
|
84
|
+
const p = await packed();
|
|
85
|
+
const store = fakeStore(p);
|
|
86
|
+
const q = quiet();
|
|
87
|
+
await cmdInstall(['weather', '--version', '1.2.0'], { ...q, modulesDir: dest(), request: store.request, core: '1.19.0' });
|
|
88
|
+
assert.deepEqual(store.calls[0].json, { version: '1.2.0' });
|
|
89
|
+
const store2 = fakeStore(p);
|
|
90
|
+
assert.equal(await cmdInstall(['weather', '--version', '^1.2.0'], { ...quiet(), modulesDir: dest(), request: store2.request }), 2);
|
|
91
|
+
assert.equal(store2.calls.length, 0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a module already on disk is refused, and the store is never asked', async () => {
|
|
95
|
+
const p = await packed();
|
|
96
|
+
const store = fakeStore(p);
|
|
97
|
+
const modulesDir = dest();
|
|
98
|
+
fs.mkdirSync(path.join(modulesDir, 'weather'));
|
|
99
|
+
assert.equal(await cmdInstall(['weather'], { ...quiet(), modulesDir, request: store.request }), 1);
|
|
100
|
+
assert.equal(store.calls.length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a tampered download fails verification: nothing placed, nothing recorded', async () => {
|
|
104
|
+
const p = await packed();
|
|
105
|
+
const entries = readTar(p.tgz);
|
|
106
|
+
const route = entries.find((e) => e.name.endsWith('routes/weather.js'));
|
|
107
|
+
route.buf = Buffer.from('require("child_process").execSync("evil");\n');
|
|
108
|
+
const store = fakeStore(p, { buf: buildTar(entries) });
|
|
109
|
+
const modulesDir = dest();
|
|
110
|
+
const q = quiet();
|
|
111
|
+
assert.equal(await cmdInstall(['weather'], { ...q, modulesDir, request: store.request, core: '1.19.0' }), 1);
|
|
112
|
+
assert.match(q.out.join('\n'), /failed verification \(hash_mismatch\)/);
|
|
113
|
+
assert.deepEqual(fs.readdirSync(modulesDir), []);
|
|
114
|
+
assert.ok(!store.calls.some((c) => c.urlPath.endsWith('/acquired')));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a download that verifies but is not the version the store described is refused', async () => {
|
|
118
|
+
const p = await packed();
|
|
119
|
+
const other = await packed({ version: '1.3.0' });
|
|
120
|
+
const store = fakeStore(p, { buf: other.tgz });
|
|
121
|
+
const modulesDir = dest();
|
|
122
|
+
assert.equal(await cmdInstall(['weather'], { ...quiet(), modulesDir, request: store.request, core: '1.19.0' }), 1);
|
|
123
|
+
assert.deepEqual(fs.readdirSync(modulesDir), []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a priced module the caller does not hold is refused with the store\'s reason', async () => {
|
|
127
|
+
const p = await packed();
|
|
128
|
+
const store = fakeStore(p, { acquire: { ok: false, status: 402, data: { error: { code: 'payment_required', message: 'buying is not available yet' } } } });
|
|
129
|
+
const modulesDir = dest();
|
|
130
|
+
const q = quiet();
|
|
131
|
+
assert.equal(await cmdInstall(['weather'], { ...q, modulesDir, request: store.request }), 1);
|
|
132
|
+
assert.match(q.out.join('\n'), /HTTP 402\): buying is not available yet/);
|
|
133
|
+
assert.equal(store.calls.length, 1, 'no download is attempted');
|
|
134
|
+
assert.deepEqual(fs.readdirSync(modulesDir), []);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a module this core cannot mount is refused before download', async () => {
|
|
138
|
+
const p = await packed({ coreVersion: '^2.0.0' });
|
|
139
|
+
const store = fakeStore(p);
|
|
140
|
+
assert.equal(await cmdInstall(['weather'], { ...quiet(), modulesDir: dest(), request: store.request, core: '1.19.0' }), 1);
|
|
141
|
+
assert.equal(store.calls.length, 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a failed record after placing keeps the files and says so', async () => {
|
|
145
|
+
const p = await packed();
|
|
146
|
+
const store = fakeStore(p, { acquired: { ok: false, status: 500, data: { error: { message: 'db down' } } } });
|
|
147
|
+
const modulesDir = dest();
|
|
148
|
+
const q = quiet();
|
|
149
|
+
assert.equal(await cmdInstall(['weather'], { ...q, modulesDir, request: store.request, core: '1.19.0' }), 1);
|
|
150
|
+
assert.ok(fs.existsSync(path.join(modulesDir, 'weather', 'module.json')));
|
|
151
|
+
assert.match(q.out.join('\n'), /installed, but the store did not record the version \(db down\)/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---- verifyModuleArtifact includeFiles ----
|
|
155
|
+
|
|
156
|
+
test('includeFiles returns the module files from the checked parse, manifest excluded', async () => {
|
|
157
|
+
const { tgz } = await packed();
|
|
158
|
+
const v = await verifyModuleArtifact(tgz, { key: 'weather', includeFiles: true });
|
|
159
|
+
assert.deepEqual(v.files.map((f) => f.path).sort(), ['module.json', 'routes/weather.js']);
|
|
160
|
+
const plain = await verifyModuleArtifact(tgz, { key: 'weather' });
|
|
161
|
+
assert.equal(plain.files, undefined, 'publish keeps its old shape');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---- the store's read side ----
|
|
165
|
+
|
|
166
|
+
test('an unpriced module is free (owner decision 2026-09-29); a zero in either currency is free', () => {
|
|
167
|
+
assert.equal(isFreePrice(null), true);
|
|
168
|
+
assert.equal(isFreePrice({ price_cents: 0, price_credits: null }), true);
|
|
169
|
+
assert.equal(isFreePrice({ price_cents: 500, price_credits: 0 }), true);
|
|
170
|
+
assert.equal(isFreePrice({ price_cents: 500, price_credits: null }), false);
|
|
171
|
+
assert.equal(isFreePrice({ price_cents: null, price_credits: 20 }), false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
function fakeDb({ mod, versions = [], price = null }) {
|
|
175
|
+
return {
|
|
176
|
+
async query(sql) {
|
|
177
|
+
if (sql.includes('FROM store_modules')) return { rows: mod ? [mod] : [] };
|
|
178
|
+
if (sql.includes('FROM store_module_versions')) return { rows: versions };
|
|
179
|
+
if (sql.includes('FROM store_module_prices')) return { rows: price ? [price] : [] };
|
|
180
|
+
throw new Error(`unhandled: ${sql}`);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const row = (version) => ({ id: 1, module_key: 'weather', version, core_version: '^1.0.0', tarball_sha256: 'a'.repeat(64), tree_sha256: 'b'.repeat(64), tarball_bytes: 10, artifact_path: `weather/weather-${version}.tgz` });
|
|
185
|
+
|
|
186
|
+
test('resolveAcquirable picks the newest version in semver order, not text order', async () => {
|
|
187
|
+
const r = await resolveAcquirable('weather', { db: fakeDb({ mod: { module_key: 'weather', status: 'listed' }, versions: [row('1.9.0'), row('1.10.0'), row('1.2.0')] }) });
|
|
188
|
+
assert.equal(r.ok, true);
|
|
189
|
+
assert.equal(r.version.version, '1.10.0');
|
|
190
|
+
assert.equal(r.free, true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('resolveAcquirable refuses an unknown module, a delisted one, a missing version, and a module with none', async () => {
|
|
194
|
+
assert.equal((await resolveAcquirable('weather', { db: fakeDb({}) })).code, 'module_not_found');
|
|
195
|
+
assert.equal((await resolveAcquirable('weather', { db: fakeDb({ mod: { status: 'delisted' }, versions: [row('1.0.0')] }) })).code, 'module_delisted');
|
|
196
|
+
assert.equal((await resolveAcquirable('weather', { version: '2.0.0', db: fakeDb({ mod: { status: 'listed' }, versions: [row('1.0.0')] }) })).code, 'version_not_found');
|
|
197
|
+
assert.equal((await resolveAcquirable('weather', { db: fakeDb({ mod: { status: 'listed' } }) })).code, 'no_versions');
|
|
198
|
+
const priced = await resolveAcquirable('weather', { db: fakeDb({ mod: { status: 'listed' }, versions: [row('1.0.0')], price: { price_cents: 900, price_credits: null } }) });
|
|
199
|
+
assert.equal(priced.free, false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('versionArtifactFile resolves inside the store dir and refuses a row naming another path', () => {
|
|
203
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-'));
|
|
204
|
+
const f = versionArtifactFile(row('1.0.0'), { dir });
|
|
205
|
+
assert.equal(relativeArtifactPath(f, { dir }), 'weather/weather-1.0.0.tgz');
|
|
206
|
+
assert.throws(() => versionArtifactFile({ ...row('1.0.0'), artifact_path: '../../etc/passwd' }, { dir }), /unexpected artifact path/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---- the routes ----
|
|
210
|
+
|
|
211
|
+
test('the install routes are own-scoped and the download checks the entitlement before reading the file', () => {
|
|
212
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
213
|
+
for (const decl of [
|
|
214
|
+
"router.post('/store/modules/:key/acquire', auth.requireBuilder,",
|
|
215
|
+
"router.get('/store/modules/:key/versions/:version/tarball', auth.requireBuilder,",
|
|
216
|
+
"router.post('/store/modules/:key/acquired', auth.requireBuilder,",
|
|
217
|
+
]) assert.ok(src.includes(decl), decl);
|
|
218
|
+
const start = src.indexOf("router.post('/store/modules/:key/acquire'");
|
|
219
|
+
const block = src.slice(start, src.indexOf('return router;', start));
|
|
220
|
+
assert.doesNotMatch(block, /holderRef: (?!String\(req\.builder\.id\))/, 'the holder is always the caller');
|
|
221
|
+
const dl = block.slice(block.indexOf('/tarball'));
|
|
222
|
+
assert.ok(dl.indexOf('checkEntitlement') < dl.indexOf('readFile'), 'no bytes are read for a caller who does not hold it');
|
|
223
|
+
assert.match(block, /if \(!r\.free\)[\s\S]*?payment_required[\s\S]*?status: 402/, 'a priced module is not granted for free');
|
|
224
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// tests/module_update.mjs — `bongos module update <key>` (task 1003786, ADR 0338 D1,
|
|
2
|
+
// criterion wa5-module-economy). No network, no database: cmdUpdate runs against a
|
|
3
|
+
// fake store serving REAL packed tarballs, so verification is the real
|
|
4
|
+
// verifyModuleArtifact.
|
|
5
|
+
//
|
|
6
|
+
// The criterion's teeth are the parity tests: an update to a paid module the caller
|
|
7
|
+
// holds and to a free one make the identical calls with the identical outcome, and
|
|
8
|
+
// the shared acquisition path carries no price-conditional code at all.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/module_update.mjs
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const { packModule, verifyModuleArtifact } = require('../scripts/gds/module-artifact.js');
|
|
23
|
+
const { readTar, buildTar } = require('../scripts/gds/artifact-format.js');
|
|
24
|
+
const { cmdUpdate, cmdInstall } = require('../scripts/gds/module.js');
|
|
25
|
+
|
|
26
|
+
const NOW = () => new Date('2026-09-29T00:00:00Z');
|
|
27
|
+
|
|
28
|
+
// A real packed tarball for weather@version, with `extra` files beside module.json.
|
|
29
|
+
async function packed(version, extra = {}, mj = {}) {
|
|
30
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-src-'));
|
|
31
|
+
const manifest = { key: 'weather', title: 'Weather', description: 'Shows the weather.', version, coreVersion: '^1.0.0', contributes: {}, ...mj };
|
|
32
|
+
const dir = path.join(src, 'weather');
|
|
33
|
+
const files = { 'module.json': JSON.stringify(manifest, null, 2), 'routes/weather.js': `module.exports = () => '${version}';\n`, ...extra };
|
|
34
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
35
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
37
|
+
}
|
|
38
|
+
const { tgz } = packModule('weather', { modulesDir: src, now: NOW, modeOf: () => '644' });
|
|
39
|
+
const v = await verifyModuleArtifact(tgz, { key: 'weather' });
|
|
40
|
+
assert.ok(v.ok);
|
|
41
|
+
return { tgz, v };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// An installed weather@version in a fresh modules dir.
|
|
45
|
+
function installedAt(version, extra = {}) {
|
|
46
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-dest-'));
|
|
47
|
+
const dir = path.join(modulesDir, 'weather');
|
|
48
|
+
const files = { 'module.json': JSON.stringify({ key: 'weather', version }), 'routes/weather.js': `module.exports = () => '${version}';\n`, ...extra };
|
|
49
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
50
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
52
|
+
}
|
|
53
|
+
return modulesDir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A fake store. `source` is how the caller came to hold the module; the store's own
|
|
57
|
+
// acquire route never looks at price for a holder, so nothing else differs.
|
|
58
|
+
function fakeStore({ tgz, v }, { source = 'free', over = {} } = {}) {
|
|
59
|
+
const calls = [];
|
|
60
|
+
const request = async (method, urlPath, opts = {}) => {
|
|
61
|
+
calls.push({ method, urlPath, json: opts.json });
|
|
62
|
+
if (urlPath.endsWith('/acquire')) {
|
|
63
|
+
return over.acquire || { ok: true, status: 200, data: {
|
|
64
|
+
ok: true, module_key: 'weather', version: v.version, core_version: v.coreVersion,
|
|
65
|
+
tarball_sha256: v.tarballSha256, tree_sha256: v.treeSha256, tarball_bytes: v.bytes, channel: 'general',
|
|
66
|
+
entitlement: { module_key: 'weather', holder_kind: 'builder', holder_ref: '42', source, version: '1.2.0' },
|
|
67
|
+
} };
|
|
68
|
+
}
|
|
69
|
+
if (urlPath.endsWith('/tarball')) return { ok: true, status: 200, buf: over.buf || tgz };
|
|
70
|
+
if (urlPath.endsWith('/acquired')) return { ok: true, status: 200, data: { ok: true } };
|
|
71
|
+
throw new Error(`unexpected ${method} ${urlPath}`);
|
|
72
|
+
};
|
|
73
|
+
return { calls, request };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const quiet = () => { const out = []; return { out, log: (m) => out.push(m), errlog: (m) => out.push(m) }; };
|
|
77
|
+
const opts = (modulesDir, store, q = quiet()) => ({ ...q, modulesDir, roots: [], request: store.request, core: '1.19.0' });
|
|
78
|
+
const route = (modulesDir) => fs.readFileSync(path.join(modulesDir, 'weather', 'routes', 'weather.js'), 'utf8');
|
|
79
|
+
|
|
80
|
+
// ---- update ----
|
|
81
|
+
|
|
82
|
+
test('updates an installed module to the newest version: files replaced, old-only files gone, version recorded', async () => {
|
|
83
|
+
const modulesDir = installedAt('1.2.0', { 'old-only.js': 'x' });
|
|
84
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
85
|
+
const q = quiet();
|
|
86
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 0, q.out.join('\n'));
|
|
87
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.3.0';\n");
|
|
88
|
+
assert.ok(!fs.existsSync(path.join(modulesDir, 'weather', 'old-only.js')), 'the new version is exactly what was published');
|
|
89
|
+
assert.deepEqual(fs.readdirSync(modulesDir), ['weather'], 'no staging or previous dir left behind');
|
|
90
|
+
assert.deepEqual(store.calls.map((c) => `${c.method} ${c.urlPath}`), [
|
|
91
|
+
'POST /store/modules/weather/acquire',
|
|
92
|
+
'GET /store/modules/weather/versions/1.3.0/tarball',
|
|
93
|
+
'POST /store/modules/weather/acquired',
|
|
94
|
+
]);
|
|
95
|
+
assert.deepEqual(store.calls[2].json, { version: '1.3.0' });
|
|
96
|
+
assert.match(q.out.join('\n'), /1\.2\.0 → 1\.3\.0\. Its on\/off switch is unchanged/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('already on the newest version: says so and downloads nothing', async () => {
|
|
100
|
+
const modulesDir = installedAt('1.3.0');
|
|
101
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
102
|
+
const q = quiet();
|
|
103
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 0);
|
|
104
|
+
assert.deepEqual(store.calls.map((c) => c.urlPath), ['/store/modules/weather/acquire']);
|
|
105
|
+
assert.match(q.out.join('\n'), /up to date at 1\.3\.0/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an older --version is refused before the store is asked: update only moves forward', async () => {
|
|
109
|
+
const modulesDir = installedAt('1.3.0');
|
|
110
|
+
const store = fakeStore(await packed('1.2.0'));
|
|
111
|
+
assert.equal(await cmdUpdate(['weather', '--version', '1.2.0'], opts(modulesDir, store)), 1);
|
|
112
|
+
assert.equal(store.calls.length, 0);
|
|
113
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.3.0';\n");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('--version asks the store for that exact version', async () => {
|
|
117
|
+
const modulesDir = installedAt('1.2.0');
|
|
118
|
+
const store = fakeStore(await packed('1.4.0'));
|
|
119
|
+
assert.equal(await cmdUpdate(['weather', '--version', '1.4.0'], opts(modulesDir, store)), 0);
|
|
120
|
+
assert.deepEqual(store.calls[0].json, { version: '1.4.0' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a module that is not installed is refused, pointing at install', async () => {
|
|
124
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-empty-'));
|
|
125
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
126
|
+
const q = quiet();
|
|
127
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
128
|
+
assert.match(q.out.join('\n'), /not installed here\. Run `bongos module install weather`/);
|
|
129
|
+
assert.equal(store.calls.length, 0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a module bundled with the core is refused: it updates with the core', async () => {
|
|
133
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-empty-'));
|
|
134
|
+
const coreRoot = installedAt('1.0.0');
|
|
135
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
136
|
+
const q = quiet();
|
|
137
|
+
assert.equal(await cmdUpdate(['weather'], { ...opts(modulesDir, store, q), roots: [coreRoot] }), 1);
|
|
138
|
+
assert.match(q.out.join('\n'), /comes with the core, not the store/);
|
|
139
|
+
assert.equal(store.calls.length, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a tampered download leaves the installed version untouched', async () => {
|
|
143
|
+
const modulesDir = installedAt('1.2.0');
|
|
144
|
+
const next = await packed('1.3.0');
|
|
145
|
+
const entries = readTar(next.tgz);
|
|
146
|
+
entries.find((e) => e.name.endsWith('routes/weather.js')).buf = Buffer.from('evil();\n');
|
|
147
|
+
const store = fakeStore(next, { over: { buf: buildTar(entries) } });
|
|
148
|
+
const q = quiet();
|
|
149
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
150
|
+
assert.match(q.out.join('\n'), /failed verification \(hash_mismatch\)/);
|
|
151
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
152
|
+
assert.deepEqual(fs.readdirSync(modulesDir), ['weather']);
|
|
153
|
+
assert.ok(!store.calls.some((c) => c.urlPath.endsWith('/acquired')));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a version this core cannot mount is refused before download, the installed one kept', async () => {
|
|
157
|
+
const modulesDir = installedAt('1.2.0');
|
|
158
|
+
const store = fakeStore(await packed('2.0.0', {}, { coreVersion: '^2.0.0' }));
|
|
159
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store)), 1);
|
|
160
|
+
assert.equal(store.calls.length, 1);
|
|
161
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a store refusal (e.g. a delisted module) keeps the installed version', async () => {
|
|
165
|
+
const modulesDir = installedAt('1.2.0');
|
|
166
|
+
const store = fakeStore(await packed('1.3.0'), { over: { acquire: { ok: false, status: 409, data: { error: { code: 'module_delisted', message: 'delisted' } } } } });
|
|
167
|
+
const q = quiet();
|
|
168
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
169
|
+
assert.match(q.out.join('\n'), /HTTP 409\): delisted/);
|
|
170
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---- parity: paid and free take one path (criterion wa5-module-economy) ----
|
|
174
|
+
|
|
175
|
+
test('a paid module the caller holds and a free one update through identical calls with an identical outcome', async () => {
|
|
176
|
+
const next = await packed('1.3.0');
|
|
177
|
+
const runs = {};
|
|
178
|
+
for (const source of ['free', 'purchase', 'grant', 'core']) {
|
|
179
|
+
const modulesDir = installedAt('1.2.0');
|
|
180
|
+
const store = fakeStore(next, { source });
|
|
181
|
+
const q = quiet();
|
|
182
|
+
const code = await cmdUpdate(['weather'], opts(modulesDir, store, q));
|
|
183
|
+
runs[source] = { code, calls: store.calls, out: q.out.join('\n').replaceAll(modulesDir, '<dir>'), route: route(modulesDir) };
|
|
184
|
+
}
|
|
185
|
+
for (const source of ['purchase', 'grant', 'core']) {
|
|
186
|
+
assert.deepEqual(runs[source], runs.free, `an update of a module held as '${source}' must not differ from a free one`);
|
|
187
|
+
}
|
|
188
|
+
assert.equal(runs.free.code, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Strip comments so only code is scanned; a word in an explanation is not a branch.
|
|
192
|
+
function codeOf(src, fnName) {
|
|
193
|
+
const start = src.indexOf(`function ${fnName}(`);
|
|
194
|
+
assert.ok(start !== -1, `${fnName} exists`);
|
|
195
|
+
const next = src.indexOf('\nfunction ', start + 1);
|
|
196
|
+
const nextAsync = src.indexOf('\nasync function ', start + 1);
|
|
197
|
+
const end = [next, nextAsync].filter((i) => i > start).sort((a, b) => a - b)[0] ?? src.length;
|
|
198
|
+
return src.slice(start, end).replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test('the shared acquisition path, install and update carry no price-conditional code', () => {
|
|
202
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/module.js'), 'utf8');
|
|
203
|
+
for (const fn of ['fetchVerified', 'recordPlaced', 'cmdInstall', 'cmdUpdate', 'replaceModule', 'placeModule']) {
|
|
204
|
+
assert.doesNotMatch(codeOf(src, fn), /\b(price|priced|paid|cents|credits|free|purchase|source)\b/i, `${fn} must not read how the module was obtained`);
|
|
205
|
+
}
|
|
206
|
+
// Both commands go through the one path, not two copies of it.
|
|
207
|
+
assert.match(codeOf(src, 'cmdInstall'), /await fetchVerified\(key,/);
|
|
208
|
+
assert.match(codeOf(src, 'cmdUpdate'), /await fetchVerified\(key,/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('the store only looks at price for a caller who holds no entitlement, so a holder is never charged again', () => {
|
|
212
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
213
|
+
const start = src.indexOf("router.post('/store/modules/:key/acquire'");
|
|
214
|
+
const block = src.slice(start, src.indexOf('router.get(', start));
|
|
215
|
+
const priceAt = block.indexOf('r.free');
|
|
216
|
+
const guardAt = block.indexOf('if (!held.entitled) {');
|
|
217
|
+
assert.ok(guardAt !== -1 && priceAt > guardAt, 'the price check sits inside the not-entitled branch');
|
|
218
|
+
assert.equal(block.split('r.free').length - 1, 1, 'price is read in exactly one place');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---- install: the grader's finding on task 1003785 ----
|
|
222
|
+
|
|
223
|
+
test('install refuses a key already present in another discovery root (a module bundled with the core)', async () => {
|
|
224
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-install-empty-'));
|
|
225
|
+
const coreRoot = installedAt('1.0.0');
|
|
226
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
227
|
+
const q = quiet();
|
|
228
|
+
assert.equal(await cmdInstall(['weather'], { ...opts(modulesDir, store, q), roots: [coreRoot] }), 1);
|
|
229
|
+
assert.match(q.out.join('\n'), /already on disk at .*run `bongos module update weather`/);
|
|
230
|
+
assert.equal(store.calls.length, 0);
|
|
231
|
+
assert.deepEqual(fs.readdirSync(modulesDir), []);
|
|
232
|
+
});
|