@celilo/e2e 0.20.0 → 0.20.2
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/bin/e2e-bake-management +46 -5
- package/package.json +4 -3
- package/src/block-timing.ts +24 -3
- package/src/cli/build.test.ts +142 -3
- package/src/cli/build.ts +49 -7
- package/src/container-manager.ts +47 -1
- package/src/docker-compose-generator.ts +33 -3
- package/src/extract-failure.ts +36 -14
- package/src/live-stack.test.ts +171 -4
- package/src/live-stack.ts +285 -17
- package/src/module-host.test.ts +55 -1
- package/src/module-host.ts +31 -0
- package/src/netapp-staleness.test.ts +139 -0
- package/src/netapp-staleness.ts +62 -0
- package/src/network-builder.ts +19 -3
- package/src/registry-bundle.ts +14 -2
- package/src/runner-keep.test.ts +72 -0
- package/src/runner.ts +19 -2
- package/src/shared-infra.ts +7 -2
- package/src/source-fingerprint.ts +11 -0
- package/src/types.ts +21 -7
- package/registry-server/src/auth.test.ts +0 -76
- package/registry-server/src/bootstrap-packaging.test.ts +0 -71
- package/registry-server/src/introspection.test.ts +0 -243
- package/registry-server/src/module-owner-store.test.ts +0 -85
- package/registry-server/src/rate-limit.test.ts +0 -62
- package/registry-server/src/scoped-token-store.test.ts +0 -93
- package/registry-server/src/server.test.ts +0 -991
- package/registry-server/src/storage.test.ts +0 -152
- package/registry-server/src/sweep.test.ts +0 -326
- package/registry-server/src/validation.test.ts +0 -86
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import type { IndexEntry } from './storage';
|
|
6
|
-
import { RegistryStorage } from './storage';
|
|
7
|
-
|
|
8
|
-
let dataDir: string;
|
|
9
|
-
let storage: RegistryStorage;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
dataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-test-'));
|
|
13
|
-
storage = new RegistryStorage(dataDir);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
// ── indexPath ─────────────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
describe('RegistryStorage.indexPath', () => {
|
|
23
|
-
test('1-char name → index/1/{name}', () => {
|
|
24
|
-
expect(storage.indexPath('a')).toBe(join(dataDir, 'index', '1', 'a'));
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('2-char name → index/2/{name}', () => {
|
|
28
|
-
expect(storage.indexPath('ab')).toBe(join(dataDir, 'index', '2', 'ab'));
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('3-char name → index/3/{first2}/{name}', () => {
|
|
32
|
-
expect(storage.indexPath('abc')).toBe(join(dataDir, 'index', '3', 'ab', 'abc'));
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('4-char name → index/{01}/{23}/{name}', () => {
|
|
36
|
-
expect(storage.indexPath('abcd')).toBe(join(dataDir, 'index', 'ab', 'cd', 'abcd'));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('long name uses first 4 chars for directories', () => {
|
|
40
|
-
expect(storage.indexPath('homebridge')).toBe(join(dataDir, 'index', 'ho', 'me', 'homebridge'));
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('real module names produce expected paths', () => {
|
|
44
|
-
expect(storage.indexPath('caddy')).toBe(join(dataDir, 'index', 'ca', 'dd', 'caddy'));
|
|
45
|
-
expect(storage.indexPath('celilo-registry')).toBe(
|
|
46
|
-
join(dataDir, 'index', 'ce', 'li', 'celilo-registry'),
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// ── readIndex / appendIndex ───────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
describe('RegistryStorage index read/write', () => {
|
|
54
|
-
function entry(vers: string, yanked = false): IndexEntry {
|
|
55
|
-
return { name: 'homebridge', vers, deps: [], cksum: 'abc123', yanked };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
test('readIndex returns empty array for unknown module', () => {
|
|
59
|
-
expect(storage.readIndex('nonexistent')).toEqual([]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('appendIndex then readIndex round-trips entries', () => {
|
|
63
|
-
storage.appendIndex(entry('1.0.0+1'));
|
|
64
|
-
storage.appendIndex(entry('1.0.0+2'));
|
|
65
|
-
const result = storage.readIndex('homebridge');
|
|
66
|
-
expect(result).toHaveLength(2);
|
|
67
|
-
expect(result[0].vers).toBe('1.0.0+1');
|
|
68
|
-
expect(result[1].vers).toBe('1.0.0+2');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('updateIndex replaces all entries', () => {
|
|
72
|
-
storage.appendIndex(entry('1.0.0+1'));
|
|
73
|
-
storage.appendIndex(entry('1.0.0+2'));
|
|
74
|
-
storage.updateIndex('homebridge', [entry('1.0.0+2', true), entry('1.0.0+3')]);
|
|
75
|
-
const result = storage.readIndex('homebridge');
|
|
76
|
-
expect(result).toHaveLength(2);
|
|
77
|
-
expect(result[0].yanked).toBe(true);
|
|
78
|
-
expect(result[1].vers).toBe('1.0.0+3');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('appendIndex creates parent directories', () => {
|
|
82
|
-
storage.appendIndex(entry('1.0.0+1'));
|
|
83
|
-
expect(existsSync(storage.indexPath('homebridge'))).toBe(true);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// ── storePackage / readPackage ────────────────────────────────────────────────
|
|
88
|
-
|
|
89
|
-
describe('RegistryStorage package store/read', () => {
|
|
90
|
-
test('storePackage returns sha256 hex', () => {
|
|
91
|
-
const data = Buffer.from('fake netapp bytes');
|
|
92
|
-
const cksum = storage.storePackage('homebridge', '1.0.0+1', data);
|
|
93
|
-
expect(cksum).toMatch(/^[0-9a-f]{64}$/);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('packageExists returns false before store, true after', () => {
|
|
97
|
-
expect(storage.packageExists('homebridge', '1.0.0+1')).toBe(false);
|
|
98
|
-
storage.storePackage('homebridge', '1.0.0+1', Buffer.from('data'));
|
|
99
|
-
expect(storage.packageExists('homebridge', '1.0.0+1')).toBe(true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('readPackage returns stored bytes', () => {
|
|
103
|
-
const data = Buffer.from('fake netapp bytes');
|
|
104
|
-
storage.storePackage('homebridge', '1.0.0+1', data);
|
|
105
|
-
const result = storage.readPackage('homebridge', '1.0.0+1');
|
|
106
|
-
expect(result?.toString()).toBe('fake netapp bytes');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test('readPackage returns null for missing version', () => {
|
|
110
|
-
expect(storage.readPackage('homebridge', '9.9.9+1')).toBeNull();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('sha256 is consistent with stored data', () => {
|
|
114
|
-
const data = Buffer.from('deterministic content');
|
|
115
|
-
const cksum = storage.storePackage('homebridge', '1.0.0+1', data);
|
|
116
|
-
expect(cksum).toBe(storage.sha256(data));
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ── listModules / getModule ───────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
describe('RegistryStorage listModules / getModule', () => {
|
|
123
|
-
test('listModules returns empty when no modules stored', () => {
|
|
124
|
-
expect(storage.listModules()).toEqual([]);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('listModules returns stored modules with their index entries', () => {
|
|
128
|
-
storage.storePackage('homebridge', '1.0.0+1', Buffer.from('a'));
|
|
129
|
-
storage.appendIndex({
|
|
130
|
-
name: 'homebridge',
|
|
131
|
-
vers: '1.0.0+1',
|
|
132
|
-
deps: [],
|
|
133
|
-
cksum: 'x',
|
|
134
|
-
yanked: false,
|
|
135
|
-
});
|
|
136
|
-
const list = storage.listModules();
|
|
137
|
-
expect(list).toHaveLength(1);
|
|
138
|
-
expect(list[0].name).toBe('homebridge');
|
|
139
|
-
expect(list[0].versions).toHaveLength(1);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('getModule returns null for unknown module', () => {
|
|
143
|
-
expect(storage.getModule('nonexistent')).toBeNull();
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test('getModule returns module with versions when index has entries', () => {
|
|
147
|
-
storage.appendIndex({ name: 'caddy', vers: '2.0.0+1', deps: [], cksum: 'y', yanked: false });
|
|
148
|
-
const mod = storage.getModule('caddy');
|
|
149
|
-
expect(mod?.name).toBe('caddy');
|
|
150
|
-
expect(mod?.versions).toHaveLength(1);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { RegistryStorage } from './storage';
|
|
6
|
-
import { DEFAULT_KEEP_BUILD_REVISIONS, parseRevision, planModuleSweep, sweep } from './sweep';
|
|
7
|
-
|
|
8
|
-
let dataDir: string;
|
|
9
|
-
let storage: RegistryStorage;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
dataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-sweep-'));
|
|
13
|
-
storage = new RegistryStorage(dataDir);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
/** Publish a version through the same two calls the real publish path uses. */
|
|
21
|
-
function publish(name: string, vers: string, sizeBytes = 32): void {
|
|
22
|
-
storage.storePackage(name, vers, Buffer.alloc(sizeBytes, 7));
|
|
23
|
-
storage.appendIndex({ name, vers, deps: [], cksum: `sha256:${vers}`, yanked: false });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* THE invariant. A sweep that trims payloads without trimming index lines
|
|
28
|
-
* leaves the index advertising a version whose file is gone — search lists it
|
|
29
|
-
* and the download 404s. The reverse leaves unreachable bytes on the disk the
|
|
30
|
-
* sweep exists to reclaim. Both directions have to hold.
|
|
31
|
-
*/
|
|
32
|
-
function expectIndexAndPayloadsAgree(name: string): void {
|
|
33
|
-
const indexed = storage.readIndex(name).map((e) => e.vers);
|
|
34
|
-
const stored = storage.storedVersions(name).sort();
|
|
35
|
-
for (const vers of indexed) {
|
|
36
|
-
expect(storage.readPackage(name, vers)).not.toBeNull();
|
|
37
|
-
}
|
|
38
|
-
expect(stored).toEqual([...indexed].sort());
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('parseRevision', () => {
|
|
42
|
-
test('splits a release from its build revision', () => {
|
|
43
|
-
expect(parseRevision('1.0.3+7')).toEqual({ release: '1.0.3', revision: 7 });
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('keeps a Debian epoch with the release', () => {
|
|
47
|
-
expect(parseRevision('2:1.0.0+1')).toEqual({ release: '2:1.0.0', revision: 1 });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('returns null for a version with no build revision', () => {
|
|
51
|
-
expect(parseRevision('1.0.0')).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe('planModuleSweep', () => {
|
|
56
|
-
const entry = (vers: string, yanked = false) => ({
|
|
57
|
-
name: 'm',
|
|
58
|
-
vers,
|
|
59
|
-
deps: [],
|
|
60
|
-
cksum: 'sha256:x',
|
|
61
|
-
yanked,
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Plan against a store where every indexed version HAS its payload — the
|
|
66
|
-
* ordinary case. Passing an empty stored set instead would trip the
|
|
67
|
-
* unreadable guard, which is a different scenario with its own tests below.
|
|
68
|
-
*/
|
|
69
|
-
const planWithPayloads = (entries: ReturnType<typeof entry>[], keepBuildRevisions: number) =>
|
|
70
|
-
planModuleSweep(
|
|
71
|
-
'm',
|
|
72
|
-
entries,
|
|
73
|
-
entries.map((e) => e.vers),
|
|
74
|
-
{ keepBuildRevisions },
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
test('keeps the newest build revision of every release', () => {
|
|
78
|
-
const entries = [
|
|
79
|
-
entry('1.0.0+1'),
|
|
80
|
-
entry('1.0.0+2'),
|
|
81
|
-
entry('1.0.2+8'),
|
|
82
|
-
entry('1.0.2+9'),
|
|
83
|
-
entry('1.0.3+5'),
|
|
84
|
-
entry('1.0.3+6'),
|
|
85
|
-
entry('1.0.3+7'),
|
|
86
|
-
];
|
|
87
|
-
const plan = planWithPayloads(entries, 1);
|
|
88
|
-
expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.0+2', '1.0.2+9', '1.0.3+7']);
|
|
89
|
-
expect(plan.remove).toEqual(['1.0.0+1', '1.0.2+8', '1.0.3+5', '1.0.3+6']);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('never removes the only revision of a release', () => {
|
|
93
|
-
const entries = [entry('0.9.0+2'), entry('1.0.0+1')];
|
|
94
|
-
const plan = planWithPayloads(entries, 1);
|
|
95
|
-
expect(plan.remove).toEqual([]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('a module can never be swept out of existence', () => {
|
|
99
|
-
const entries = [entry('1.0.0+1')];
|
|
100
|
-
const plan = planWithPayloads(entries, 1);
|
|
101
|
-
expect(plan.keep).toHaveLength(1);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test('keeps more revisions per release when asked', () => {
|
|
105
|
-
const entries = [entry('1.0.3+5'), entry('1.0.3+6'), entry('1.0.3+7')];
|
|
106
|
-
const plan = planWithPayloads(entries, 2);
|
|
107
|
-
expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.3+6', '1.0.3+7']);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test('keepBuildRevisions below 1 is clamped — no policy deletes a whole release', () => {
|
|
111
|
-
// Needs MORE THAN ONE release to be a real check. With a single release
|
|
112
|
-
// the latest-line retention below masks a missing clamp; with two, an
|
|
113
|
-
// unclamped 0 wipes out every release except the last.
|
|
114
|
-
const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('1.0.3+5'), entry('1.0.3+6')];
|
|
115
|
-
const plan = planWithPayloads(entries, 0);
|
|
116
|
-
expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.0+2', '1.0.3+6']);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test('retains what latestVersion resolves to even when the index is out of order', () => {
|
|
120
|
-
// Last line wins for RegistryClient.latestVersion regardless of ordering,
|
|
121
|
-
// so a disordered index must not lose the version clients actually fetch.
|
|
122
|
-
const entries = [entry('1.0.3+7'), entry('1.0.3+6'), entry('1.0.3+5')];
|
|
123
|
-
const plan = planWithPayloads(entries, 1);
|
|
124
|
-
expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+5');
|
|
125
|
-
expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+7');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('retains the last NON-yanked line, which is what clients resolve to', () => {
|
|
129
|
-
const entries = [entry('1.0.3+5'), entry('1.0.3+6'), entry('1.0.3+7', true)];
|
|
130
|
-
const plan = planWithPayloads(entries, 1);
|
|
131
|
-
expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+6');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('keeps a version it cannot parse rather than deleting it', () => {
|
|
135
|
-
const entries = [entry('nonsense'), entry('1.0.0+1'), entry('1.0.0+2')];
|
|
136
|
-
const plan = planWithPayloads(entries, 1);
|
|
137
|
-
expect(plan.remove).toEqual(['1.0.0+1']);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('reports a payload with no index line as an orphan', () => {
|
|
141
|
-
const plan = planModuleSweep('m', [entry('1.0.0+2')], ['1.0.0+1', '1.0.0+2'], {
|
|
142
|
-
keepBuildRevisions: 1,
|
|
143
|
-
});
|
|
144
|
-
expect(plan.orphans).toEqual(['1.0.0+1']);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
describe('sweep over a fixture store', () => {
|
|
149
|
-
test('index and payloads agree afterwards, and every release survives', () => {
|
|
150
|
-
for (const rev of [1, 2, 3, 4, 5]) publish('celilo-registry', `1.0.3+${rev}`);
|
|
151
|
-
for (const rev of [1, 2]) publish('celilo-registry', `1.0.2+${rev}`);
|
|
152
|
-
publish('forgejo', '2.1.0+1');
|
|
153
|
-
|
|
154
|
-
const report = sweep(storage, { keepBuildRevisions: DEFAULT_KEEP_BUILD_REVISIONS });
|
|
155
|
-
|
|
156
|
-
expect(report.removedCount).toBe(5);
|
|
157
|
-
expectIndexAndPayloadsAgree('celilo-registry');
|
|
158
|
-
expectIndexAndPayloadsAgree('forgejo');
|
|
159
|
-
// Original index order is preserved: the fixture published 1.0.3 before
|
|
160
|
-
// 1.0.2, and the sweep filters lines rather than re-sorting them.
|
|
161
|
-
expect(storage.readIndex('celilo-registry').map((e) => e.vers)).toEqual(['1.0.3+5', '1.0.2+2']);
|
|
162
|
-
expect(storage.readIndex('forgejo').map((e) => e.vers)).toEqual(['2.1.0+1']);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test('reclaims the bytes it says it reclaims', () => {
|
|
166
|
-
publish('m', '1.0.0+1', 4096);
|
|
167
|
-
publish('m', '1.0.0+2', 4096);
|
|
168
|
-
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
169
|
-
expect(report.reclaimedBytes).toBe(4096);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test('a dry run changes nothing', () => {
|
|
173
|
-
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
174
|
-
const report = sweep(storage, { keepBuildRevisions: 1 }, true);
|
|
175
|
-
|
|
176
|
-
expect(report.removedCount).toBe(2);
|
|
177
|
-
expect(storage.readIndex('m')).toHaveLength(3);
|
|
178
|
-
expect(storage.storedVersions('m').sort()).toEqual(['1.0.0+1', '1.0.0+2', '1.0.0+3']);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test('is idempotent — a second sweep finds nothing to do', () => {
|
|
182
|
-
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
183
|
-
sweep(storage, { keepBuildRevisions: 1 });
|
|
184
|
-
const second = sweep(storage, { keepBuildRevisions: 1 });
|
|
185
|
-
|
|
186
|
-
expect(second.removedCount).toBe(0);
|
|
187
|
-
expect(second.orphanCount).toBe(0);
|
|
188
|
-
expect(second.reclaimedBytes).toBe(0);
|
|
189
|
-
expectIndexAndPayloadsAgree('m');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test('a sweep interrupted after the index rewrite self-heals on the next run', () => {
|
|
193
|
-
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
194
|
-
// The crash window: index trimmed, payloads not yet unlinked. The version
|
|
195
|
-
// is unlisted but still on disk — wasted bytes, not a broken registry.
|
|
196
|
-
storage.updateIndex('m', storage.readIndex('m').slice(-1));
|
|
197
|
-
expect(storage.storedVersions('m')).toHaveLength(3);
|
|
198
|
-
|
|
199
|
-
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
200
|
-
|
|
201
|
-
expect(report.orphanCount).toBe(2);
|
|
202
|
-
expectIndexAndPayloadsAgree('m');
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test('a version left unlisted stays downloadable until its payload goes', () => {
|
|
206
|
-
// Why index-first is the safe order: the download route reads the payload
|
|
207
|
-
// directly and never consults the index, so the window an interrupted
|
|
208
|
-
// sweep opens serves stale-but-valid bytes rather than 404ing.
|
|
209
|
-
publish('m', '1.0.0+1');
|
|
210
|
-
storage.updateIndex('m', []);
|
|
211
|
-
expect(storage.readPackage('m', '1.0.0+1')).not.toBeNull();
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('reclaims a half-written publish, which is what makes the version publishable again', () => {
|
|
215
|
-
// Both states below exist on the production registry, created 2026-08-22 by
|
|
216
|
-
// real failures: an ENOSPC that killed a run after storePackage, and an
|
|
217
|
-
// EACCES on appendIndex. handlePublish has no rollback between the two.
|
|
218
|
-
//
|
|
219
|
-
// The consequence is worse than wasted bytes: `packageExists` reads the
|
|
220
|
-
// FILESYSTEM, so the orphan makes its own version permanently
|
|
221
|
-
// unpublishable — "versions are immutable". Removing the orphan is the
|
|
222
|
-
// repair, and nothing else in the product performs it.
|
|
223
|
-
storage.storePackage('celilo-mgmt', '0.6.2+2', Buffer.alloc(2048, 1));
|
|
224
|
-
publish('celilo-mgmt', '0.6.1+1');
|
|
225
|
-
expect(storage.packageExists('celilo-mgmt', '0.6.2+2')).toBe(true);
|
|
226
|
-
|
|
227
|
-
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
228
|
-
|
|
229
|
-
expect(report.orphanCount).toBe(1);
|
|
230
|
-
expect(storage.packageExists('celilo-mgmt', '0.6.2+2')).toBe(false);
|
|
231
|
-
expectIndexAndPayloadsAgree('celilo-mgmt');
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('REPORTS an index line with no payload and does not remove it', () => {
|
|
235
|
-
// The dangerous direction. Removing the line would actually repair the
|
|
236
|
-
// module — a dangling entry already 404s — but a missing payload is
|
|
237
|
-
// indistinguishable from an unreadable store, so it is reported only.
|
|
238
|
-
publish('m', '1.0.0+1');
|
|
239
|
-
publish('m', '1.0.0+2');
|
|
240
|
-
storage.removePackage('m', '1.0.0+1');
|
|
241
|
-
|
|
242
|
-
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
243
|
-
|
|
244
|
-
expect(report.danglingCount).toBe(1);
|
|
245
|
-
expect(report.modules[0]?.dangling).toEqual(['1.0.0+1']);
|
|
246
|
-
// Still listed: the sweep reported it rather than acting on it.
|
|
247
|
-
expect(storage.readIndex('m').map((e) => e.vers)).toContain('1.0.0+1');
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
test('touches nothing when a module has index lines and no payloads at all', () => {
|
|
251
|
-
// An unmounted volume or a wrong DATA_DIR looks exactly like this. Acting
|
|
252
|
-
// on it would trim the index of a module whose files are merely
|
|
253
|
-
// unreachable.
|
|
254
|
-
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
255
|
-
for (const rev of [1, 2, 3]) storage.removePackage('m', `1.0.0+${rev}`);
|
|
256
|
-
|
|
257
|
-
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
258
|
-
|
|
259
|
-
expect(report.unreadable).toEqual(['m']);
|
|
260
|
-
expect(report.removedCount).toBe(0);
|
|
261
|
-
expect(storage.readIndex('m')).toHaveLength(3);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test('a dangling entry does not stop the rest of the sweep', () => {
|
|
265
|
-
publish('m', '1.0.0+1');
|
|
266
|
-
publish('m', '1.0.0+2');
|
|
267
|
-
publish('m', '1.0.0+3');
|
|
268
|
-
storage.removePackage('m', '1.0.0+1');
|
|
269
|
-
|
|
270
|
-
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
271
|
-
|
|
272
|
-
expect(report.danglingCount).toBe(1);
|
|
273
|
-
// 1.0.0+2 is superseded and still had its payload, so it is swept normally.
|
|
274
|
-
expect(report.removedCount).toBe(1);
|
|
275
|
-
expect(storage.readIndex('m').map((e) => e.vers)).toEqual(['1.0.0+1', '1.0.0+3']);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
test('never sweeps the last SERVABLE version because a newer line is dangling', () => {
|
|
279
|
-
// The failure this guards: retention computed over every index line lets a
|
|
280
|
-
// dangling NEWEST entry satisfy both the per-release rule and the
|
|
281
|
-
// latest-line rule, so every servable older version reads as superseded.
|
|
282
|
-
// The module ends up listed, with one index line, and undownloadable.
|
|
283
|
-
publish('m', '1.0.0+1');
|
|
284
|
-
publish('m', '1.0.0+2');
|
|
285
|
-
publish('m', '1.0.0+3');
|
|
286
|
-
storage.removePackage('m', '1.0.0+3'); // the newest line is now dangling
|
|
287
|
-
|
|
288
|
-
sweep(storage, { keepBuildRevisions: 1 });
|
|
289
|
-
|
|
290
|
-
// Something is still downloadable, which is the whole point.
|
|
291
|
-
const servable = storage
|
|
292
|
-
.readIndex('m')
|
|
293
|
-
.map((e) => e.vers)
|
|
294
|
-
.filter((v) => storage.packageExists('m', v));
|
|
295
|
-
expect(servable.length).toBeGreaterThan(0);
|
|
296
|
-
expect(servable).toContain('1.0.0+2');
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
test('an empty store sweeps cleanly', () => {
|
|
300
|
-
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
301
|
-
expect(report.removedCount).toBe(0);
|
|
302
|
-
expect(report.modules).toEqual([]);
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
describe('updateIndex atomicity', () => {
|
|
307
|
-
test('leaves no temp file behind', () => {
|
|
308
|
-
publish('m', '1.0.0+1');
|
|
309
|
-
storage.updateIndex('m', storage.readIndex('m'));
|
|
310
|
-
expect(existsSync(`${storage.indexPath('m')}.tmp`)).toBe(false);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test('a write that cannot land leaves the original index intact', () => {
|
|
314
|
-
publish('m', '1.0.0+1');
|
|
315
|
-
publish('m', '1.0.0+2');
|
|
316
|
-
const before = storage.readIndex('m');
|
|
317
|
-
// Block the temp path so the new content cannot be written. A truncating
|
|
318
|
-
// in-place write ignores the obstruction and replaces the index anyway;
|
|
319
|
-
// the rename path fails with the original still on disk. This stands in
|
|
320
|
-
// for the failure that actually matters — ENOSPC on the full disk this
|
|
321
|
-
// sweep exists to relieve — which needs an fs seam to reproduce directly.
|
|
322
|
-
mkdirSync(`${storage.indexPath('m')}.tmp`, { recursive: true });
|
|
323
|
-
expect(() => storage.updateIndex('m', before.slice(0, 1))).toThrow();
|
|
324
|
-
expect(storage.readIndex('m')).toEqual(before);
|
|
325
|
-
});
|
|
326
|
-
});
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
|
|
3
|
-
|
|
4
|
-
describe('isValidName', () => {
|
|
5
|
-
test.each(['homebridge', 'my-module', 'a', 'a1', 'dns-external', 'x-y-z-2'])(
|
|
6
|
-
'accepts %s',
|
|
7
|
-
(name) => {
|
|
8
|
-
expect(isValidName(name)).toBe(true);
|
|
9
|
-
},
|
|
10
|
-
);
|
|
11
|
-
|
|
12
|
-
test.each([
|
|
13
|
-
['empty', ''],
|
|
14
|
-
['uppercase', 'Homebridge'],
|
|
15
|
-
['underscore', 'my_module'],
|
|
16
|
-
['leading hyphen', '-foo'],
|
|
17
|
-
['trailing hyphen', 'foo-'],
|
|
18
|
-
['double hyphen', 'foo--bar'],
|
|
19
|
-
['space', 'foo bar'],
|
|
20
|
-
['path traversal', '../etc'],
|
|
21
|
-
['slash', 'foo/bar'],
|
|
22
|
-
['dot', 'foo.bar'],
|
|
23
|
-
['null byte', 'foo\0bar'],
|
|
24
|
-
['newline', 'foo\nbar'],
|
|
25
|
-
['carriage return', 'foo\rbar'],
|
|
26
|
-
['URL-encoded slash after decode would be', '..%2F..'],
|
|
27
|
-
['over 128 chars', `${'a'.repeat(129)}`],
|
|
28
|
-
])('rejects %s', (_label, name) => {
|
|
29
|
-
expect(isValidName(name)).toBe(false);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('isValidVersion', () => {
|
|
34
|
-
test.each(['1.0.0+1', '1.4.2+1', '0.0.1+99', '1.5.0+1', '2:1.0.0+1', '0.0.0+1'])(
|
|
35
|
-
'accepts %s',
|
|
36
|
-
(vers) => {
|
|
37
|
-
expect(isValidVersion(vers)).toBe(true);
|
|
38
|
-
},
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
test.each([
|
|
42
|
-
['empty', ''],
|
|
43
|
-
['no pkgrev', '1.0.0'],
|
|
44
|
-
['pkgrev not numeric', '1.0.0+abc'],
|
|
45
|
-
['semver not numeric', 'a.b.c+1'],
|
|
46
|
-
['hyphen pkgrev', '1.0.0-1'],
|
|
47
|
-
['path traversal', '../../etc'],
|
|
48
|
-
['slash', '1.0.0+1/foo'],
|
|
49
|
-
['dot-dot', '1.0.0+1/..'],
|
|
50
|
-
['null byte', '1.0.0+1\0'],
|
|
51
|
-
['newline', '1.0.0+1\n'],
|
|
52
|
-
['space', '1.0.0 +1'],
|
|
53
|
-
['extra dot', '1.0.0.0+1'],
|
|
54
|
-
['negative pkgrev', '1.0.0+-1'],
|
|
55
|
-
['four parts', '1.2.3.4+1'],
|
|
56
|
-
['pkgrev missing', '1.0.0+'],
|
|
57
|
-
['URL-encoded bad version', '..%2F..%2Fetc'],
|
|
58
|
-
['over 128 chars', `${'1'.repeat(129)}.0.0+1`],
|
|
59
|
-
])('rejects %s', (_label, vers) => {
|
|
60
|
-
expect(isValidVersion(vers)).toBe(false);
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('validateNameAndVersion', () => {
|
|
65
|
-
test('accepts a matched valid pair', () => {
|
|
66
|
-
expect(validateNameAndVersion('homebridge', '1.4.2+1')).toEqual({ ok: true });
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('flags the name field when bad', () => {
|
|
70
|
-
const r = validateNameAndVersion('../etc', '1.0.0+1');
|
|
71
|
-
expect(r.ok).toBe(false);
|
|
72
|
-
if (!r.ok) expect(r.field).toBe('name');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('flags the vers field when bad', () => {
|
|
76
|
-
const r = validateNameAndVersion('homebridge', '../etc');
|
|
77
|
-
expect(r.ok).toBe(false);
|
|
78
|
-
if (!r.ok) expect(r.field).toBe('vers');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('reports name first when both are bad (fail-fast)', () => {
|
|
82
|
-
const r = validateNameAndVersion('../etc', '1.0.0');
|
|
83
|
-
expect(r.ok).toBe(false);
|
|
84
|
-
if (!r.ok) expect(r.field).toBe('name');
|
|
85
|
-
});
|
|
86
|
-
});
|