@celilo/cli 0.26.1 → 0.27.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,9 +58,9 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^1.4.0",
61
+ "@celilo/capabilities": "^1.5.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.8.0",
63
+ "@celilo/core": "^0.8.1",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -151,7 +151,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
151
151
  sshUser: 'ubuntu',
152
152
  sshKeyEncrypted: JSON.stringify(sshKey),
153
153
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
154
- assignedModuleIds: [],
155
154
  createdAt: new Date(),
156
155
  updatedAt: new Date(),
157
156
  });
@@ -187,7 +186,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
187
186
  sshUser: 'root',
188
187
  sshKeyEncrypted: JSON.stringify(sshKey),
189
188
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
190
- assignedModuleIds: [],
191
189
  createdAt: new Date(),
192
190
  updatedAt: new Date(),
193
191
  },
@@ -199,7 +197,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
199
197
  sshUser: 'ubuntu',
200
198
  sshKeyEncrypted: JSON.stringify(sshKey),
201
199
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
202
- assignedModuleIds: [],
203
200
  createdAt: new Date(),
204
201
  updatedAt: new Date(),
205
202
  },
@@ -232,7 +229,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
232
229
  sshUser: 'root',
233
230
  sshKeyEncrypted: JSON.stringify(sshKey),
234
231
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
235
- assignedModuleIds: [],
236
232
  createdAt: new Date(),
237
233
  updatedAt: new Date(),
238
234
  });
@@ -1,309 +1,253 @@
1
- import { describe, expect, it } from 'bun:test';
1
+ /**
2
+ * celilo#524. `verifyCollectionIntegrity` is a supply-chain check that could
3
+ * not report a failure: three independent paths returned "integrity verified"
4
+ * for a tampered collection, and no constructible input reached the failure
5
+ * return. The file had no tests at all.
6
+ *
7
+ * These build real broken collections on disk — the same three the issue's
8
+ * break-and-watch constructed — rather than mocking the filesystem. A mock
9
+ * would assert my belief about what `readFile` does; a real directory asserts
10
+ * what the function does.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { createHash } from 'node:crypto';
15
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
2
18
  import {
3
- type CollectionRequirement,
4
- type VersionConstraint,
5
- compareVersions,
6
- formatConstraints,
7
- intersectConstraints,
8
- mergeAnsibleRequirements,
9
- parseSemanticVersion,
10
- parseVersionConstraint,
11
- satisfiesConstraint,
12
- satisfiesConstraints,
19
+ type InstalledCollection,
20
+ reportIntegrity,
21
+ verifyCollectionIntegrity,
13
22
  } from './dependencies';
14
23
 
15
- describe('parseSemanticVersion', () => {
16
- it('parses valid semantic version', () => {
17
- const version = parseSemanticVersion('8.5.2');
18
- expect(version).toEqual({ major: 8, minor: 5, patch: 2 });
19
- });
24
+ const NAME = 'community.general';
25
+ const [NAMESPACE, COLLECTION] = NAME.split('.');
20
26
 
21
- it('throws on invalid version', () => {
22
- expect(() => parseSemanticVersion('8.5')).toThrow('Invalid semantic version');
23
- expect(() => parseSemanticVersion('8')).toThrow('Invalid semantic version');
24
- expect(() => parseSemanticVersion('v8.5.2')).toThrow('Invalid semantic version');
25
- });
26
- });
27
+ const sha256 = (content: string) => createHash('sha256').update(content).digest('hex');
27
28
 
28
- describe('compareVersions', () => {
29
- it('compares major versions', () => {
30
- const v8 = { major: 8, minor: 0, patch: 0 };
31
- const v9 = { major: 9, minor: 0, patch: 0 };
32
- expect(compareVersions(v8, v9)).toBeLessThan(0);
33
- expect(compareVersions(v9, v8)).toBeGreaterThan(0);
34
- expect(compareVersions(v8, v8)).toBe(0);
35
- });
29
+ describe('verifyCollectionIntegrity', () => {
30
+ let root: string;
31
+ let dir: string;
32
+ let info: InstalledCollection;
36
33
 
37
- it('compares minor versions when major equal', () => {
38
- const v8_0 = { major: 8, minor: 0, patch: 0 };
39
- const v8_5 = { major: 8, minor: 5, patch: 0 };
40
- expect(compareVersions(v8_0, v8_5)).toBeLessThan(0);
41
- expect(compareVersions(v8_5, v8_0)).toBeGreaterThan(0);
34
+ beforeEach(() => {
35
+ root = mkdtempSync(join(tmpdir(), 'celilo-galaxy-'));
36
+ dir = join(root, NAMESPACE, COLLECTION);
37
+ mkdirSync(dir, { recursive: true });
38
+ info = { name: NAME, version: { major: 1, minor: 0, patch: 0 }, path: root };
42
39
  });
43
40
 
44
- it('compares patch versions when major and minor equal', () => {
45
- const v8_5_0 = { major: 8, minor: 5, patch: 0 };
46
- const v8_5_2 = { major: 8, minor: 5, patch: 2 };
47
- expect(compareVersions(v8_5_0, v8_5_2)).toBeLessThan(0);
48
- expect(compareVersions(v8_5_2, v8_5_0)).toBeGreaterThan(0);
41
+ afterEach(() => {
42
+ try {
43
+ rmSync(root, { recursive: true, force: true });
44
+ } catch {
45
+ /* ignore */
46
+ }
49
47
  });
50
- });
51
48
 
52
- describe('parseVersionConstraint', () => {
53
- it('parses >= constraint', () => {
54
- const constraints = parseVersionConstraint('>=8.0.0');
55
- expect(constraints).toEqual([
56
- {
57
- operator: '>=',
58
- version: { major: 8, minor: 0, patch: 0 },
59
- },
60
- ]);
61
- });
62
-
63
- it('parses == constraint', () => {
64
- const constraints = parseVersionConstraint('==8.5.0');
65
- expect(constraints).toEqual([
66
- {
67
- operator: '==',
68
- version: { major: 8, minor: 5, patch: 0 },
69
- },
70
- ]);
71
- });
49
+ /** Write a coherent, genuinely intact collection. */
50
+ function writeGoodCollection(): void {
51
+ const payload = '- name: do a thing\n';
52
+ writeFileSync(join(dir, 'plugins.yml'), payload);
72
53
 
73
- it('parses range constraint', () => {
74
- const constraints = parseVersionConstraint('>=8.0.0,<10.0.0');
75
- expect(constraints).toHaveLength(2);
76
- expect(constraints[0]).toEqual({
77
- operator: '>=',
78
- version: { major: 8, minor: 0, patch: 0 },
79
- });
80
- expect(constraints[1]).toEqual({
81
- operator: '<',
82
- version: { major: 10, minor: 0, patch: 0 },
54
+ const filesJson = JSON.stringify({
55
+ files: [{ name: 'plugins.yml', ftype: 'file', chksum_sha256: sha256(payload) }],
83
56
  });
84
- });
85
-
86
- it('throws on invalid constraint', () => {
87
- expect(() => parseVersionConstraint('invalid')).toThrow('Invalid version constraint');
88
- expect(() => parseVersionConstraint('>8')).toThrow('Invalid version constraint');
89
- });
90
- });
91
-
92
- describe('satisfiesConstraint', () => {
93
- it('checks >= constraint', () => {
94
- const constraint: VersionConstraint = {
95
- operator: '>=',
96
- version: { major: 8, minor: 0, patch: 0 },
97
- };
98
-
99
- expect(satisfiesConstraint({ major: 9, minor: 0, patch: 0 }, constraint)).toBe(true);
100
- expect(satisfiesConstraint({ major: 8, minor: 0, patch: 0 }, constraint)).toBe(true);
101
- expect(satisfiesConstraint({ major: 7, minor: 9, patch: 9 }, constraint)).toBe(false);
102
- });
103
-
104
- it('checks == constraint', () => {
105
- const constraint: VersionConstraint = {
106
- operator: '==',
107
- version: { major: 8, minor: 5, patch: 0 },
108
- };
109
-
110
- expect(satisfiesConstraint({ major: 8, minor: 5, patch: 0 }, constraint)).toBe(true);
111
- expect(satisfiesConstraint({ major: 8, minor: 5, patch: 1 }, constraint)).toBe(false);
112
- expect(satisfiesConstraint({ major: 9, minor: 0, patch: 0 }, constraint)).toBe(false);
113
- });
114
-
115
- it('checks < constraint', () => {
116
- const constraint: VersionConstraint = {
117
- operator: '<',
118
- version: { major: 10, minor: 0, patch: 0 },
119
- };
120
-
121
- expect(satisfiesConstraint({ major: 9, minor: 9, patch: 9 }, constraint)).toBe(true);
122
- expect(satisfiesConstraint({ major: 10, minor: 0, patch: 0 }, constraint)).toBe(false);
123
- expect(satisfiesConstraint({ major: 11, minor: 0, patch: 0 }, constraint)).toBe(false);
124
- });
125
- });
126
-
127
- describe('satisfiesConstraints', () => {
128
- it('checks multiple constraints', () => {
129
- const constraints = parseVersionConstraint('>=8.0.0,<10.0.0');
130
-
131
- expect(satisfiesConstraints({ major: 8, minor: 0, patch: 0 }, constraints)).toBe(true);
132
- expect(satisfiesConstraints({ major: 9, minor: 5, patch: 2 }, constraints)).toBe(true);
133
- expect(satisfiesConstraints({ major: 7, minor: 9, patch: 9 }, constraints)).toBe(false);
134
- expect(satisfiesConstraints({ major: 10, minor: 0, patch: 0 }, constraints)).toBe(false);
135
- });
136
- });
137
-
138
- describe('intersectConstraints', () => {
139
- it('returns empty array for empty input', () => {
140
- expect(intersectConstraints([])).toEqual([]);
141
- });
142
-
143
- it('returns single constraint set unchanged', () => {
144
- const constraints = parseVersionConstraint('>=8.0.0');
145
- expect(intersectConstraints([constraints])).toEqual(constraints);
146
- });
147
-
148
- it('merges compatible minimums (takes highest)', () => {
149
- const c1 = parseVersionConstraint('>=8.0.0');
150
- const c2 = parseVersionConstraint('>=9.0.0');
151
-
152
- const result = intersectConstraints([c1, c2]);
153
- expect(result).toEqual([{ operator: '>=', version: { major: 9, minor: 0, patch: 0 } }]);
154
- });
155
-
156
- it('finds range overlap', () => {
157
- const c1 = parseVersionConstraint('>=8.0.0,<10.0.0');
158
- const c2 = parseVersionConstraint('>=9.0.0,<11.0.0');
159
-
160
- const result = intersectConstraints([c1, c2]);
161
- expect(result).toHaveLength(2);
162
- expect(result?.[0]).toEqual({ operator: '>=', version: { major: 9, minor: 0, patch: 0 } });
163
- expect(result?.[1]).toEqual({ operator: '<', version: { major: 10, minor: 0, patch: 0 } });
164
- });
165
-
166
- it('detects conflicting ranges', () => {
167
- const c1 = parseVersionConstraint('>=8.0.0,<9.0.0');
168
- const c2 = parseVersionConstraint('>=9.0.0,<10.0.0');
169
-
170
- const result = intersectConstraints([c1, c2]);
171
- expect(result).toBeNull(); // No overlap
172
- });
173
-
174
- it('handles exact pins matching', () => {
175
- const c1 = parseVersionConstraint('==8.5.0');
176
- const c2 = parseVersionConstraint('==8.5.0');
177
-
178
- const result = intersectConstraints([c1, c2]);
179
- expect(result).toEqual([{ operator: '==', version: { major: 8, minor: 5, patch: 0 } }]);
180
- });
181
-
182
- it('detects conflicting exact pins', () => {
183
- const c1 = parseVersionConstraint('==8.5.0');
184
- const c2 = parseVersionConstraint('==9.0.0');
185
-
186
- const result = intersectConstraints([c1, c2]);
187
- expect(result).toBeNull();
188
- });
189
-
190
- it('checks exact pin against bounds', () => {
191
- const c1 = parseVersionConstraint('==8.5.0');
192
- const c2 = parseVersionConstraint('>=8.0.0,<9.0.0');
193
-
194
- const result = intersectConstraints([c1, c2]);
195
- expect(result).toEqual([{ operator: '==', version: { major: 8, minor: 5, patch: 0 } }]);
196
- });
197
-
198
- it('detects exact pin outside bounds', () => {
199
- const c1 = parseVersionConstraint('==10.0.0');
200
- const c2 = parseVersionConstraint('>=8.0.0,<9.0.0');
201
-
202
- const result = intersectConstraints([c1, c2]);
203
- expect(result).toBeNull();
204
- });
205
- });
206
-
207
- describe('mergeAnsibleRequirements', () => {
208
- it('merges compatible requirements', () => {
209
- const requirements: CollectionRequirement[] = [
210
- {
211
- name: 'community.general',
212
- constraints: parseVersionConstraint('>=8.0.0'),
213
- moduleId: 'module-a',
214
- },
215
- {
216
- name: 'community.general',
217
- constraints: parseVersionConstraint('>=9.0.0'),
218
- moduleId: 'module-b',
219
- },
57
+ writeFileSync(join(dir, 'FILES.json'), filesJson);
58
+ writeFileSync(
59
+ join(dir, 'MANIFEST.json'),
60
+ JSON.stringify({
61
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
62
+ file_manifest_file: {
63
+ name: 'FILES.json',
64
+ ftype: 'file',
65
+ chksum_type: 'sha256',
66
+ chksum_sha256: sha256(filesJson),
67
+ },
68
+ }),
69
+ );
70
+ }
71
+
72
+ test('an intact collection verifies, and says how many files it checked', async () => {
73
+ writeGoodCollection();
74
+ const outcome = await verifyCollectionIntegrity(info);
75
+ expect(outcome.status).toBe('verified');
76
+ expect(outcome.status === 'verified' && outcome.filesChecked).toBeGreaterThan(0);
77
+ });
78
+
79
+ // ── The three cases from the issue. Each returned `true` before. ──────────
80
+
81
+ test('A: an unparseable MANIFEST.json is unverifiable, not verified', async () => {
82
+ writeGoodCollection();
83
+ writeFileSync(join(dir, 'MANIFEST.json'), '{ this is not json');
84
+
85
+ const outcome = await verifyCollectionIntegrity(info);
86
+ expect(outcome.status).toBe('unverifiable');
87
+ // Rule 6.2: the exception path used to claim it logged and did not.
88
+ expect(outcome.status === 'unverifiable' && outcome.reason.length).toBeGreaterThan(0);
89
+ });
90
+
91
+ test('B: a MANIFEST.json with no FILES.json checksum is unverifiable', async () => {
92
+ writeGoodCollection();
93
+ writeFileSync(
94
+ join(dir, 'MANIFEST.json'),
95
+ JSON.stringify({
96
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
97
+ // `file_manifest_file` stripped — the one field an attacker shipping a
98
+ // tampered collection fully controls. It used to switch the check off.
99
+ }),
100
+ );
101
+
102
+ const outcome = await verifyCollectionIntegrity(info);
103
+ expect(outcome.status).toBe('unverifiable');
104
+ expect(outcome.status === 'unverifiable' && outcome.reason).toContain('no FILES.json checksum');
105
+ });
106
+
107
+ test('C: every listed file missing from disk is unverifiable, not a pass', async () => {
108
+ writeGoodCollection();
109
+ rmSync(join(dir, 'plugins.yml'));
110
+
111
+ const outcome = await verifyCollectionIntegrity(info);
112
+ expect(outcome.status).toBe('unverifiable');
113
+ // Verifying zero files is not verifying.
114
+ expect(outcome.status === 'unverifiable' && outcome.reason).toContain('present on disk');
115
+ });
116
+
117
+ // ── Refutation is distinct from ignorance ────────────────────────────────
118
+
119
+ test('a modified file is a MISMATCH, not merely unverifiable', async () => {
120
+ writeGoodCollection();
121
+ writeFileSync(join(dir, 'plugins.yml'), '- name: do something ELSE\n');
122
+
123
+ const outcome = await verifyCollectionIntegrity(info);
124
+ expect(outcome.status).toBe('mismatch');
125
+ expect(outcome.status === 'mismatch' && outcome.detail).toContain('plugins.yml');
126
+ });
127
+
128
+ test('a tampered FILES.json is a MISMATCH', async () => {
129
+ writeGoodCollection();
130
+ // Rewrite FILES.json so it no longer matches the checksum in MANIFEST.json —
131
+ // what re-pointing a file at different content looks like.
132
+ writeFileSync(
133
+ join(dir, 'FILES.json'),
134
+ JSON.stringify({
135
+ files: [{ name: 'plugins.yml', ftype: 'file', chksum_sha256: sha256('something else') }],
136
+ }),
137
+ );
138
+
139
+ const outcome = await verifyCollectionIntegrity(info);
140
+ expect(outcome.status).toBe('mismatch');
141
+ expect(outcome.status === 'mismatch' && outcome.detail).toContain('FILES.json');
142
+ });
143
+
144
+ test('a missing FILES.json is unverifiable, and is not confused with a mismatch', async () => {
145
+ writeGoodCollection();
146
+ rmSync(join(dir, 'FILES.json'));
147
+
148
+ const outcome = await verifyCollectionIntegrity(info);
149
+ expect(outcome.status).toBe('unverifiable');
150
+ });
151
+
152
+ test('a collection with no checksummed files is unverifiable', async () => {
153
+ writeGoodCollection();
154
+ const filesJson = JSON.stringify({ files: [{ name: 'roles', ftype: 'dir' }] });
155
+ writeFileSync(join(dir, 'FILES.json'), filesJson);
156
+ writeFileSync(
157
+ join(dir, 'MANIFEST.json'),
158
+ JSON.stringify({
159
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
160
+ file_manifest_file: {
161
+ name: 'FILES.json',
162
+ ftype: 'file',
163
+ chksum_type: 'sha256',
164
+ chksum_sha256: sha256(filesJson),
165
+ },
166
+ }),
167
+ );
168
+
169
+ const outcome = await verifyCollectionIntegrity(info);
170
+ expect(outcome.status).toBe('unverifiable');
171
+ });
172
+
173
+ /**
174
+ * The recurrence gate proper. Every way this function can fail to establish
175
+ * integrity must be distinguishable from success — a single `status` compared
176
+ * against 'verified' is what a caller will actually write, and it must be
177
+ * false for all of them.
178
+ */
179
+ test('no broken collection is EVER reported as verified', async () => {
180
+ const breakages: Array<[string, () => void]> = [
181
+ ['unparseable manifest', () => writeFileSync(join(dir, 'MANIFEST.json'), 'nope')],
182
+ [
183
+ 'no files checksum',
184
+ () =>
185
+ writeFileSync(
186
+ join(dir, 'MANIFEST.json'),
187
+ JSON.stringify({
188
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
189
+ }),
190
+ ),
191
+ ],
192
+ ['files missing', () => rmSync(join(dir, 'plugins.yml'))],
193
+ ['manifest missing', () => rmSync(join(dir, 'MANIFEST.json'))],
194
+ ['files.json missing', () => rmSync(join(dir, 'FILES.json'))],
195
+ ['content modified', () => writeFileSync(join(dir, 'plugins.yml'), 'tampered\n')],
220
196
  ];
221
197
 
222
- const result = mergeAnsibleRequirements(requirements);
198
+ for (const [label, breakIt] of breakages) {
199
+ rmSync(dir, { recursive: true, force: true });
200
+ mkdirSync(dir, { recursive: true });
201
+ writeGoodCollection();
202
+ breakIt();
223
203
 
224
- expect(result.success).toBe(true);
225
- expect(result.resolved).toHaveLength(1);
226
- expect(result.resolved[0].name).toBe('community.general');
227
- expect(result.resolved[0].satisfiesAll).toBe(true);
228
- expect(formatConstraints(result.resolved[0].constraints)).toBe('>=9.0.0');
229
- });
230
-
231
- it('detects conflicts', () => {
232
- const requirements: CollectionRequirement[] = [
233
- {
234
- name: 'community.general',
235
- constraints: parseVersionConstraint('>=8.0.0,<9.0.0'),
236
- moduleId: 'module-a',
237
- reason: 'Uses old API',
238
- },
239
- {
240
- name: 'community.general',
241
- constraints: parseVersionConstraint('>=9.0.0'),
242
- moduleId: 'module-b',
243
- reason: 'Requires new features',
244
- },
245
- ];
246
-
247
- const result = mergeAnsibleRequirements(requirements);
248
-
249
- expect(result.success).toBe(false);
250
- expect(result.conflicts).toHaveLength(1);
251
- expect(result.conflicts?.[0].collection).toBe('community.general');
252
- expect(result.conflicts?.[0].requirements).toHaveLength(2);
253
- expect(result.conflicts?.[0].requirements[0].moduleId).toBe('module-a');
254
- expect(result.conflicts?.[0].requirements[1].moduleId).toBe('module-b');
255
- });
256
-
257
- it('handles multiple collections', () => {
258
- const requirements: CollectionRequirement[] = [
259
- {
260
- name: 'community.general',
261
- constraints: parseVersionConstraint('>=8.0.0'),
262
- moduleId: 'module-a',
263
- },
264
- {
265
- name: 'ansible.posix',
266
- constraints: parseVersionConstraint('>=1.5.0'),
267
- moduleId: 'module-a',
268
- },
269
- {
270
- name: 'community.general',
271
- constraints: parseVersionConstraint('>=9.0.0'),
272
- moduleId: 'module-b',
273
- },
274
- ];
275
-
276
- const result = mergeAnsibleRequirements(requirements);
277
-
278
- expect(result.success).toBe(true);
279
- expect(result.resolved).toHaveLength(2);
280
-
281
- const general = result.resolved.find((r) => r.name === 'community.general');
282
- const posix = result.resolved.find((r) => r.name === 'ansible.posix');
283
-
284
- expect(general).toBeDefined();
285
- // biome-ignore lint/style/noNonNullAssertion: checked with toBeDefined() above
286
- expect(formatConstraints(general!.constraints)).toBe('>=9.0.0');
287
-
288
- expect(posix).toBeDefined();
289
- // biome-ignore lint/style/noNonNullAssertion: checked with toBeDefined() above
290
- expect(formatConstraints(posix!.constraints)).toBe('>=1.5.0');
204
+ const outcome = await verifyCollectionIntegrity(info);
205
+ expect(`${label}: ${outcome.status}`).not.toBe(`${label}: verified`);
206
+ }
291
207
  });
292
208
  });
293
209
 
294
- describe('formatConstraints', () => {
295
- it('formats single constraint', () => {
296
- const constraints = parseVersionConstraint('>=8.0.0');
297
- expect(formatConstraints(constraints)).toBe('>=8.0.0');
298
- });
299
-
300
- it('formats range constraint', () => {
301
- const constraints = parseVersionConstraint('>=8.0.0,<10.0.0');
302
- expect(formatConstraints(constraints)).toBe('>=8.0.0,<10.0.0');
303
- });
304
-
305
- it('formats exact pin', () => {
306
- const constraints = parseVersionConstraint('==8.5.0');
307
- expect(formatConstraints(constraints)).toBe('==8.5.0');
210
+ /**
211
+ * celilo#524, policy half. A refuted collection now REFUSES the import; an
212
+ * uncheckable one warns.
213
+ *
214
+ * Refusing is cheap exactly here: `installCollectionsForModule` is called only
215
+ * from `module/import.ts`, and nothing installs collections in the deploy path,
216
+ * so a false positive means "this module did not import" rather than "the fleet
217
+ * stopped deploying". No module row exists yet either, so there is nothing left
218
+ * half-created — which is why this is an import refusal and not an ERROR state.
219
+ */
220
+ describe('reportIntegrity — what each outcome does to the import', () => {
221
+ test('a MISMATCH refuses, and the message says what and why', () => {
222
+ const refusal = reportIntegrity('community.general', {
223
+ status: 'mismatch',
224
+ detail: 'plugins.yml does not match its checksum',
225
+ });
226
+ expect(refusal).toBeTruthy();
227
+ expect(refusal).toContain('community.general');
228
+ expect(refusal).toContain('plugins.yml does not match its checksum');
229
+ // The operator needs to know this is refutation, not a missing checksum.
230
+ expect(refusal).toContain('corruption or tampering');
231
+ });
232
+
233
+ /**
234
+ * The distinction that keeps this from becoming a false-positive machine. A
235
+ * collection shipping no `file_manifest_file` is telling you about its
236
+ * publisher, not about tampering — blocking on it would refuse ordinary
237
+ * collections forever, which is how a check gets disabled wholesale.
238
+ */
239
+ test('an UNVERIFIABLE outcome does NOT refuse', () => {
240
+ expect(
241
+ reportIntegrity('community.general', {
242
+ status: 'unverifiable',
243
+ reason: 'MANIFEST.json declares no FILES.json checksum',
244
+ }),
245
+ ).toBeNull();
246
+ });
247
+
248
+ test('a verified collection refuses nothing', () => {
249
+ expect(
250
+ reportIntegrity('community.general', { status: 'verified', filesChecked: 5 }),
251
+ ).toBeNull();
308
252
  });
309
253
  });