@dzhechkov/cloudru-hub 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { after, test } from 'node:test';
8
+
9
+ const pkg = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const cli = join(pkg, 'bin/cloudru-hub.js');
11
+ const example = join(pkg, 'docs/cost-compare.example.json');
12
+ const exampleText = readFileSync(example, 'utf8');
13
+ const source = JSON.parse(exampleText);
14
+ const dirs = [];
15
+ after(() => { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); });
16
+ function directory() { const dir = mkdtempSync(join(tmpdir(), 'cloudru-cost-test-')); dirs.push(dir); return dir; }
17
+ function invoke(input, flags = [], env = {}) {
18
+ const dir = directory(), path = join(dir, 'input.json');
19
+ writeFileSync(path, JSON.stringify(input));
20
+ const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path, '--json', ...flags], {
21
+ cwd: dir, env: { ...process.env, CLOUDRU_VM_BIN: join(dir, 'no-engine'), CLOUDRU_HUB_CONFIG: join(dir, 'no-config'), ...env },
22
+ encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024,
23
+ });
24
+ assert.ifError(result.error);
25
+ return result;
26
+ }
27
+ function invokeRaw(raw) {
28
+ const dir = directory(), path = join(dir, 'input.json');
29
+ writeFileSync(path, raw);
30
+ const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path, '--json'], {
31
+ cwd: dir, encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024,
32
+ });
33
+ assert.ifError(result.error);
34
+ return result;
35
+ }
36
+ function editRaw(from, to) {
37
+ assert.ok(exampleText.includes(from), `missing fixture anchor ${from}`);
38
+ return exampleText.replace(from, to);
39
+ }
40
+ function run(input) {
41
+ const result = invoke(input);
42
+ assert.equal(result.status, 0, result.stderr);
43
+ assert.equal(result.stderr, '');
44
+ return JSON.parse(result.stdout);
45
+ }
46
+ function reject(mutator, pattern) {
47
+ const input = structuredClone(source);
48
+ mutator(input);
49
+ const result = invoke(input);
50
+ assert.equal(result.status, 1, result.stdout + result.stderr);
51
+ assert.equal(result.stdout, '', 'invalid input must not emit partial success JSON');
52
+ assert.match(result.stderr, pattern);
53
+ }
54
+ function complete(input) {
55
+ input.equivalence = { status: 'assumed', assumptions: ['Only modeled infrastructure cost is compared; application equivalence is an operator assumption.'] };
56
+ for (const scenario of input.scenarios) {
57
+ scenario.resources.trafficGB = '7.5';
58
+ scenario.categories.traffic = { status: 'priced', items: [{
59
+ id: 'synthetic-traffic', description: 'Test-only invented traffic tariff', quantity: '7.5', unit: 'traffic-GB', rateRub: '0.12',
60
+ tariff: { url: 'https://example.invalid/test-only-rate', version: 'synthetic-test', asOf: '2026-10-21', vatBasis: 'included', pageOrRow: 'test fixture only' },
61
+ }] };
62
+ }
63
+ return input;
64
+ }
65
+
66
+ test('packaged official-rate example has independent 24h subtotals, capacity and no winner', () => {
67
+ const result = run(source);
68
+ assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), ['251.6616', '466.1376']);
69
+ assert.deepEqual(result.scenarios.map(item => item.categories.storage.totalRubExact), ['22.8384', '30.4512']);
70
+ assert.deepEqual(result.scenarios.map(item => item.capacity.normalSlots), [6, 6]);
71
+ assert.deepEqual(result.scenarios.map(item => item.capacity.survivingSlots), [4, 4]);
72
+ assert.deepEqual(result.scenarios.map(item => item.capacity.feasible), [true, true]);
73
+ assert.deepEqual(result.scenarios.map(item => item.unpriced.map(entry => entry.category)), [['traffic'], ['traffic']]);
74
+ assert.equal(result.comparison.eligible, false);
75
+ assert.equal(result.comparison.winner, null);
76
+ assert.equal(result.comparison.savingsRubExact, null);
77
+ assert.match(result.scenarios[0].categories.compute.items[0].formula, /24 hours/);
78
+ assert.equal(result.scenarios[1].categories.compute.items[0].tariff.version, '260918');
79
+ assert.match(result.scenarios[0].categories.compute.items[0].formula, /3 instance × 2\.9707 RUB\/instance-hour × 24 hours/);
80
+ });
81
+
82
+ test('human output includes modeled formulas, tariff provenance, unknowns and assumptions', () => {
83
+ const dir = directory(), path = join(dir, 'input.json'); writeFileSync(path, JSON.stringify(source));
84
+ const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path], { cwd: dir, encoding: 'utf8' });
85
+ assert.equal(result.status, 0, result.stderr);
86
+ assert.match(result.stdout, /3 instance × 2\.9707 RUB\/instance-hour × 24 hours/);
87
+ assert.match(result.stdout, /evolution-compute\.pdf version 261014/);
88
+ assert.match(result.stdout, /unpriced traffic/);
89
+ assert.match(result.stdout, /assumption:/);
90
+ assert.match(result.stdout, /No winner/);
91
+ });
92
+
93
+ test('fractional horizon keeps exact sums and rounds display only at output', () => {
94
+ const input = structuredClone(source); input.horizonHours = '1.5';
95
+ const result = run(input);
96
+ assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), ['15.72885', '29.1336']);
97
+ assert.deepEqual(result.scenarios.map(item => item.categories.storage.totalRubExact), ['1.4274', '1.9032']);
98
+ assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubDisplay), ['15.73', '29.13']);
99
+ });
100
+
101
+ test('test-only traffic is charged once, then conditional lower modeled cost is identified', () => {
102
+ for (const [horizon, expected, savings] of [
103
+ ['24', ['252.5616', '467.0376'], '214.476'],
104
+ ['1.5', ['16.62885', '30.0336'], '13.40475'],
105
+ ]) {
106
+ const input = complete(structuredClone(source)); input.horizonHours = horizon;
107
+ const result = run(input);
108
+ assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), expected);
109
+ assert.deepEqual(result.scenarios.map(item => item.categories.traffic.totalRubExact), ['0.9', '0.9']);
110
+ assert.equal(result.comparison.eligible, true);
111
+ assert.equal(result.comparison.winner, 'vm');
112
+ assert.equal(result.comparison.savingsRubExact, savings);
113
+ assert.match(result.comparison.basis, /conditional/);
114
+ assert.deepEqual(result.equivalence.assumptions, input.equivalence.assumptions);
115
+ }
116
+ });
117
+
118
+ test('aggregate CPU cannot conceal per-node packing failure', () => {
119
+ const input = complete(structuredClone(source));
120
+ input.workload.replicas = 4;
121
+ input.workload.cpuPerReplica = '1.5';
122
+ const result = run(input);
123
+ assert.deepEqual(result.scenarios.map(item => item.capacity.normalSlots), [3, 3]);
124
+ assert.deepEqual(result.scenarios.map(item => item.capacity.feasible), [false, false]);
125
+ assert.equal(result.comparison.winner, null);
126
+ });
127
+
128
+ test('one-node failure capacity is distinct from normal capacity', () => {
129
+ const input = complete(structuredClone(source));
130
+ input.workload.replicas = 5;
131
+ input.workload.survivingReplicas = 5;
132
+ const failed = run(input);
133
+ assert.deepEqual(failed.scenarios.map(item => item.capacity.normalSlots), [6, 6]);
134
+ assert.deepEqual(failed.scenarios.map(item => item.capacity.survivingSlots), [4, 4]);
135
+ assert.equal(failed.comparison.winner, null);
136
+ input.workload.survivingReplicas = 4;
137
+ const fit = run(input);
138
+ assert.deepEqual(fit.scenarios.map(item => item.capacity.feasible), [true, true]);
139
+ assert.equal(fit.comparison.winner, 'vm');
140
+ });
141
+
142
+ test('guaranteed CPU share, reservation and exact decimal floor determine per-node slots', () => {
143
+ const input = complete(structuredClone(source));
144
+ for (const scenario of input.scenarios) {
145
+ scenario.nodes.cpu = '4'; scenario.nodes.cpuShare = '0.5'; scenario.nodes.reservedCpu = '0.5';
146
+ }
147
+ input.workload.cpuPerReplica = '0.5';
148
+ const result = run(input);
149
+ assert.deepEqual(result.scenarios.map(item => item.capacity.perNodeCpuSlots), [3, 3]);
150
+ const tenth = complete(structuredClone(source));
151
+ for (const scenario of tenth.scenarios) { scenario.nodes.cpu = '0.3'; scenario.nodes.cpuShare = '1'; }
152
+ tenth.workload.cpuPerReplica = '0.1';
153
+ assert.deepEqual(run(tenth).scenarios.map(item => item.capacity.perNodeCpuSlots), [3, 3]);
154
+ });
155
+
156
+ test('a tie is explicit and carries no winner or savings', () => {
157
+ const input = complete(structuredClone(source));
158
+ const vm = input.scenarios[0];
159
+ const k8s = input.scenarios[1];
160
+ // Test-only unit rates: 66 VM hourly units versus 87 K8s hourly units,
161
+ // a 21 × 24 = 504 RUB difference, offset by 7.5 × (67.32 − 0.12).
162
+ vm.categories.compute.items[0].rateRub = '1';
163
+ vm.categories.storage.items[0].rateRub = '1';
164
+ vm.categories.publicIp.items[0].rateRub = '1';
165
+ k8s.categories.compute.items[0].rateRub = '1';
166
+ k8s.categories.controlPlane.items[0].rateRub = '1';
167
+ k8s.categories.storage.items[0].rateRub = '1';
168
+ k8s.categories.publicIp.items[0].rateRub = '1';
169
+ k8s.categories.snat.items[0].rateRub = '1';
170
+ // VM has 66 hourly units; K8s has 87. Add the same 7.5GB traffic to both; tie via VM traffic premium.
171
+ vm.categories.traffic.items[0].rateRub = '67.32';
172
+ k8s.categories.traffic.items[0].rateRub = '0.12';
173
+ const result = run(input);
174
+ assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), ['2088.9', '2088.9']);
175
+ assert.equal(result.comparison.eligible, true);
176
+ assert.equal(result.comparison.tie, true);
177
+ assert.equal(result.comparison.winner, null);
178
+ assert.equal(result.comparison.savingsRubExact, null);
179
+ });
180
+
181
+ test('unknown equivalence alone suppresses a winner even with complete prices', () => {
182
+ const input = complete(structuredClone(source));
183
+ input.equivalence = source.equivalence;
184
+ const result = run(input);
185
+ assert.deepEqual(result.scenarios.map(item => item.complete), [true, true]);
186
+ assert.equal(result.comparison.winner, null);
187
+ });
188
+
189
+ test('raw flags reject duplicates, missing values, unknown options and positionals', () => {
190
+ const dir = directory(), path = join(dir, 'input.json'); writeFileSync(path, JSON.stringify(source));
191
+ for (const flags of [[], ['--input'], ['--input', path, '--input', path], ['--input', path, '--json', '--json'], ['--input', path, '--rate', '1'], ['--input', path, 'extra']]) {
192
+ const result = spawnSync(process.execPath, [cli, 'cost-compare', ...flags], { cwd: dir, encoding: 'utf8' });
193
+ assert.equal(result.status, 1, JSON.stringify(flags)); assert.equal(result.stdout, ''); assert.match(result.stderr, /cost-compare/);
194
+ }
195
+ });
196
+
197
+ test('input must be a bounded regular file and cannot be a symlink', () => {
198
+ const dir = directory();
199
+ const large = join(dir, 'large.json'); writeFileSync(large, ' '.repeat(128 * 1024 + 1));
200
+ const link = join(dir, 'link.json'); symlinkSync(example, link);
201
+ for (const path of [large, link, '/dev/null']) {
202
+ const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path, '--json'], { encoding: 'utf8' });
203
+ assert.equal(result.status, 1, path); assert.equal(result.stdout, ''); assert.match(result.stderr, /cost-compare/);
204
+ }
205
+ });
206
+
207
+ for (const [name, raw, pattern] of [
208
+ ['top-level duplicate horizon', editRaw('"horizonHours": "24"', '"horizonHours": "8760", "horizonHours": "24"'), /duplicate decoded object member/],
209
+ ['nested duplicate CPU declaration', editRaw('"cpu": "2", "ramGB": "4"', '"cpu": "4", "cpu": "2", "ramGB": "4"'), /duplicate decoded object member/],
210
+ ['sibling scenario duplicate id', editRaw('"id": "k8s",', '"id": "other", "id": "k8s",'), /duplicate decoded object member/],
211
+ ['escaped-equivalent duplicate member', editRaw('"horizonHours": "24"', '"horizonHours": "8760", "horizon\\u0048ours": "24"'), /duplicate decoded object member/],
212
+ ['rounded fractional replica token', JSON.stringify(source).replace('"replicas":3', '"replicas":3.0000000000000001'), /numeric tokens must be canonical/],
213
+ ['unsafe integer replica token', JSON.stringify(source).replace('"replicas":3', '"replicas":9007199254740993'), /numeric tokens must be canonical/],
214
+ ['infinite exponent token', JSON.stringify(source).replace('"replicas":3', '"replicas":1e309'), /numeric tokens must be canonical/],
215
+ ['malformed exponent token', JSON.stringify(source).replace('"replicas":3', '"replicas":3e+'), /object member requires/],
216
+ ['excessive JSON nesting', `${'['.repeat(65)}0${']'.repeat(65)}`, /nesting exceeds/],
217
+ ['raw numeric value for decimal-string horizon', editRaw('"horizonHours": "24"', '"horizonHours": 24'), /horizonHours/],
218
+ ]) test(`raw JSON rejects ${name} before accepting a result`, () => {
219
+ const result = invokeRaw(raw);
220
+ assert.equal(result.status, 1, result.stdout + result.stderr);
221
+ assert.equal(result.stdout, '');
222
+ assert.match(result.stderr, pattern);
223
+ });
224
+
225
+ test('canonical safe integer tokens and quoted braces/commas remain accepted', () => {
226
+ const input = structuredClone(source);
227
+ input.workload.replicas = 3;
228
+ input.equivalence.reason = 'unknown: text contains "quotes", braces { } and comma, without object members';
229
+ const result = invokeRaw(JSON.stringify(input));
230
+ assert.equal(result.status, 0, result.stderr);
231
+ assert.equal(JSON.parse(result.stdout).workload.replicas, 3);
232
+ });
233
+
234
+ test('CLI path cannot resolve an engine, spawn, access network or require credentials', () => {
235
+ const dir = directory(), preload = join(dir, 'forbid.cjs');
236
+ writeFileSync(preload, `
237
+ const Module = require('module'); const original = Module._load;
238
+ Module._load = function(request, parent, main) {
239
+ if (parent?.filename.endsWith('/src/cli.js') && ['./resolve','./classification','./install','./safe-mcp','./deploy-approval','./k8s-approval','./dialects'].includes(request)) throw Error('legacy engine/auth module load forbidden');
240
+ if (request === 'yaml') throw Error('external dependency load forbidden');
241
+ return original.apply(this, arguments);
242
+ };
243
+ const child = require('child_process'); for (const name of ['spawn','spawnSync','exec','execFile']) child[name] = () => { throw Error('spawn forbidden'); };
244
+ for (const name of ['http','https','net']) { const mod = require(name); for (const key of ['request','get','connect','createConnection']) if (key in mod) mod[key] = () => { throw Error('network forbidden'); }; }
245
+ global.fetch = () => { throw Error('fetch forbidden'); };
246
+ `);
247
+ const input = join(dir, 'input.json'); writeFileSync(input, JSON.stringify(source));
248
+ const result = spawnSync(process.execPath, ['--require', preload, cli, 'cost-compare', '--input', input, '--json'], {
249
+ cwd: dir, env: { PATH: '/nonexistent', CLOUDRU_VM_BIN: '/no-engine', CLOUDRU_HUB_CONFIG: '/no-config' }, encoding: 'utf8', timeout: 5000,
250
+ });
251
+ assert.ifError(result.error); assert.equal(result.status, 0, result.stderr);
252
+ assert.equal(JSON.parse(result.stdout).comparison.winner, null);
253
+ });
254
+
255
+ for (const [label, mutate, pattern] of [
256
+ ['unknown schema field', x => { x.hidden = true; }, /unknown field/],
257
+ ['non-RUB currency', x => { x.currency = 'USD'; }, /currency/],
258
+ ['different VAT basis', x => { x.vatBasis = 'excluded'; }, /vatBasis/],
259
+ ['scientific rate', x => { x.scenarios[0].categories.compute.items[0].rateRub = '1e3'; }, /rateRub/],
260
+ ['monthly unit', x => { x.scenarios[0].categories.compute.items[0].unit = 'instance-month'; }, /unit/],
261
+ ['wrong unit category', x => { x.scenarios[0].categories.storage.items[0].unit = 'instance-hour'; }, /unit/],
262
+ ['missing tariff version', x => { delete x.scenarios[0].categories.compute.items[0].tariff.version; }, /version/],
263
+ ['HTTP tariff URL', x => { x.scenarios[0].categories.compute.items[0].tariff.url = 'http://example.invalid'; }, /url/],
264
+ ['wrong tariff VAT basis', x => { x.scenarios[0].categories.compute.items[0].tariff.vatBasis = 'excluded'; }, /vatBasis/],
265
+ ['unbound worker quantity', x => { x.scenarios[0].categories.compute.items[0].quantity = '2'; }, /quantities must sum/],
266
+ ['unbound storage quantity', x => { x.scenarios[0].categories.storage.items[0].quantity = '59'; }, /quantities must sum/],
267
+ ['unbound IP quantity', x => { x.scenarios[0].categories.publicIp.items[0].quantity = '2'; }, /quantities must sum/],
268
+ ['noninteger service count', x => { x.scenarios[0].categories.compute.items[0].quantity = '3.5'; }, /integer service count/],
269
+ ['missing K8s control plane', x => { x.scenarios[1].controlPlaneCount = 0; }, /controlPlaneCount/],
270
+ ['unpriced compute with zero declaration', x => { x.scenarios[0].nodes.count = 0; }, /nodes.count/],
271
+ ['not-used positive storage', x => { x.scenarios[0].categories.storage = { status: 'not-used', reason: 'omitted' }; }, /not-used requires/],
272
+ ['unknown traffic silently zero', x => { x.scenarios[0].resources.trafficGB = '0'; }, /declared zero quantity/],
273
+ ['priced unknown traffic', x => { x.scenarios[0].categories.traffic = { status: 'priced', items: [] }; }, /priced requires/],
274
+ ['oversubscribed reservations', x => { x.scenarios[0].nodes.reservedCpu = '3'; }, /reservations exceed/],
275
+ ['declared node failure exceeds topology', x => { x.workload.failedNodes = 4; }, /failedNodes/],
276
+ ['bad CPU share', x => { x.scenarios[0].nodes.cpuShare = '1.1'; }, /cpuShare/],
277
+ ['survival above replicas', x => { x.workload.survivingReplicas = 4; }, /survivingReplicas/],
278
+ ['duplicate kinds', x => { x.scenarios[1].kind = 'vm'; x.scenarios[1].controlPlaneCount = 0; x.scenarios[1].categories.controlPlane = { status: 'not-used', reason: 'none' }; }, /one scenario of each kind/],
279
+ ['missing equivalence assumption', x => { x.equivalence = { status: 'assumed', assumptions: [] }; }, /assumptions/],
280
+ ]) test(`rejects ${label} without partial output`, () => reject(mutate, pattern));
@@ -110,6 +110,18 @@ test('partial deploy preserves its VM UUID for recovery and redacts nested secre
110
110
  assert.ok(denied.responses[0].error);
111
111
  });
112
112
 
113
+ test('unreadable apply result names the consumed plan and requires read-only reconciliation', () => {
114
+ const id = 'plan-555555555555';
115
+ fs.writeFileSync(path.join(path.dirname(planFile), `${id}.json`), JSON.stringify({ ...plan, plan_id: id }));
116
+ issueApproval(id, 50, env);
117
+ const out = run([rpc(1, 'deploy_apply', { plan_id: id })], { FAKE_DEPLOY_OUTCOME: JSON.stringify('unreadable') });
118
+ const result = JSON.parse(out.responses[0].result.content[0].text);
119
+ assert.equal(out.responses[0].result.isError, true);
120
+ assert.equal(result.plan_id, id);
121
+ assert.equal(result.verdict, 'unknown');
122
+ assert.match(result.next, /deploy_reconcile/);
123
+ });
124
+
113
125
  test('static site requires the reviewed digest and never grants an SSH command session', () => {
114
126
  const id = 'plan-aabbccddeeff';
115
127
  const digest = 'a'.repeat(64);
@@ -67,6 +67,46 @@ test('unexpected mutators, exposure, privilege, image and namespace are rejected
67
67
  }
68
68
  });
69
69
 
70
+ test('web-image profile accepts a reviewed digest-pinned application and refuses privilege or exposure drift', () => {
71
+ const app = 'lesson-web';
72
+ const image = `registry.example.com/lesson-web@sha256:${'a'.repeat(64)}`;
73
+ const web = [
74
+ { apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: app, namespace: 'lesson-app' }, spec: {
75
+ replicas: 1, selector: { matchLabels: { app } }, template: { metadata: { labels: { app } }, spec: {
76
+ automountServiceAccountToken: false, securityContext: { runAsNonRoot: true },
77
+ containers: [{ name: app, image, ports: [{ containerPort: 8080 }], readinessProbe: { httpGet: { path: '/health', port: 8080 } },
78
+ resources: { requests: { cpu: '50m', memory: '64Mi' }, limits: { cpu: '200m', memory: '256Mi' } },
79
+ securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsUser: 10001,
80
+ runAsGroup: 10001, capabilities: { drop: ['ALL'] } } }],
81
+ } } } },
82
+ { apiVersion: 'v1', kind: 'Service', metadata: { name: app, namespace: 'lesson-app' },
83
+ spec: { type: 'ClusterIP', selector: { app }, ports: [{ port: 80, targetPort: 8080 }] } },
84
+ ];
85
+ const raw = serialize(web);
86
+ assert.equal(validateManifest(raw, 'lesson-app', 'web-image').image, image);
87
+ assert.throws(() => validateManifest(raw, 'lesson-app'), /three valid YAML documents/);
88
+ for (const mutate of [
89
+ (r) => { r[0].spec.template.spec.containers[0].image = 'registry.example.com/lesson-web:latest'; },
90
+ (r) => { r[0].spec.template.spec.containers[0].image = `https://registry.example.com/lesson-web@sha256:${'a'.repeat(64)}`; },
91
+ (r) => { r[0].spec.template.spec.hostNetwork = true; },
92
+ (r) => { r[0].spec.template.spec.containers[0].securityContext.privileged = true; },
93
+ (r) => { r[1].spec.type = 'LoadBalancer'; },
94
+ (r) => { r[0].metadata.namespace = 'default'; },
95
+ (r) => { r[0].metadata.name = r[1].metadata.name = '123'; r[0].spec.selector.matchLabels.app = r[0].spec.template.metadata.labels.app = r[1].spec.selector.app = '123'; r[0].spec.template.spec.containers[0].name = '123'; },
96
+ ]) {
97
+ const changed = structuredClone(web); mutate(changed);
98
+ assert.throws(() => validateManifest(serialize(changed), 'lesson-app', 'web-image'));
99
+ }
100
+ const source = path.join(scratch, 'lesson-app.yaml');
101
+ fs.writeFileSync(source, raw);
102
+ const reviewed = require('crypto').createHash('sha256').update(raw).digest('hex');
103
+ const approval = issueK8sApproval(clusterId, 'lesson-app', source, reviewed, env, Date.now(), 'web-image');
104
+ assert.equal(approval.profile, 'web-image');
105
+ assert.equal(consumeK8sApproval(approval.approvalId, env).manifest, raw);
106
+ fs.writeFileSync(source, serialize([{ ...web[0], metadata: { name: 'changed', namespace: 'lesson-app' } }, web[1]]));
107
+ assert.throws(() => issueK8sApproval(clusterId, 'lesson-app', source, reviewed, env, Date.now(), 'web-image'), /digest differs|must match/);
108
+ });
109
+
70
110
  test('digest, project, expiry and single-use approval bind the exact manifest', () => {
71
111
  assert.throws(() => issueK8sApproval(clusterId, 'snake-pilot', manifestFile, '0'.repeat(64), env), /digest differs/);
72
112
  const a = issueK8sApproval(clusterId, 'snake-pilot', manifestFile, digest, env, 1000);
@@ -125,6 +125,6 @@ test('sha256File matches node crypto', () => {
125
125
  test('the pinned linux-x64 hash is the rebuilt private engine sha256', () => {
126
126
  assert.equal(
127
127
  PKG.cloudruHub.binaryHashes['linux-x64'],
128
- '4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6',
128
+ '94fdba4fe4d1a71e684be998c3d2f94f374ff4e4054db2683bcc04d72bfabc03',
129
129
  );
130
130
  });
@@ -47,6 +47,26 @@ test('safe proxy filters listed tools and blocks ask, deny, unknown and other me
47
47
  assert.deepEqual(out.forwarded.filter((m) => m.method === 'tools/call').map((m) => m.params.name), ['vm_list']);
48
48
  });
49
49
 
50
+ test('k8s_get recovery selectors pass existing read profiles while paid creates stay denied', () => {
51
+ const snapshot = JSON.parse(fs.readFileSync(path.join(pkg, 'data/tools-list-snapshot.json'), 'utf8'));
52
+ assert.equal(snapshot.toolCount, 146);
53
+ assert.equal(snapshot.tools.length, 146);
54
+ for (const profile of ['all', 'kubernetes', 'k8s-deploy']) {
55
+ const out = run([
56
+ msg(1, 'tools/call', { name: 'k8s_get', arguments: { receipts: true, limit: 10 } }),
57
+ msg(2, 'tools/call', { name: 'k8s_get', arguments: { receipt_id: 'a'.repeat(64) } }),
58
+ msg(3, 'tools/call', { name: 'k8s_create', arguments: { confirmed: true } }),
59
+ msg(4, 'tools/call', { name: 'k8s_add_nodepool', arguments: { confirmed: true } }),
60
+ ], profile);
61
+ const byId = Object.fromEntries(out.responses.map((r) => [r.id, r]));
62
+ assert.match(byId[1].result.content[0].text, /executed k8s_get/);
63
+ assert.match(byId[2].result.content[0].text, /executed k8s_get/);
64
+ assert.ok(byId[3].error, `${profile}: k8s_create must be denied`);
65
+ assert.ok(byId[4].error, `${profile}: k8s_add_nodepool must be denied`);
66
+ assert.deepEqual(out.forwarded.map((m) => m.params.name), ['k8s_get', 'k8s_get']);
67
+ }
68
+ });
69
+
50
70
  test('Hermes keepalive gets the filtered tool list while the serial engine is busy', async () => {
51
71
  const serial = path.join(scratch, 'serial-engine');
52
72
  const serialLog = path.join(scratch, 'serial-forwarded.jsonl');