@serfab/cadre-host 0.10.0 → 0.11.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/dist/server/static.js +38 -38
- package/dist/ui/assets/index-C-iks41M.js.map +1 -1
- package/dist/ui/index.html +12 -12
- package/package.json +4 -4
- package/service/cadre-host.service.tmpl +16 -16
- package/service/com.serfab.cadre-host.plist.tmpl +35 -35
- package/service/install-service.ps1 +98 -98
- package/service/uninstall-service.ps1 +19 -19
- package/src/__tests__/build-targets.test.ts +26 -26
- package/src/__tests__/cli-invite.smoke.test.ts +192 -192
- package/src/__tests__/cli-nat.smoke.test.ts +212 -212
- package/src/__tests__/cli.smoke.test.ts +46 -46
- package/src/__tests__/global-setup.ts +51 -51
- package/src/__tests__/host-owner.smoke.test.ts +123 -123
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
- package/src/__tests__/orchestrator-owner.test.ts +198 -198
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
- package/src/__tests__/orchestrator-push.test.ts +145 -145
- package/src/__tests__/orchestrator.test.ts +951 -951
- package/src/auth/__tests__/duration.test.ts +28 -28
- package/src/auth/__tests__/self-label.test.ts +85 -85
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
- package/src/auth/__tests__/trust-circle.test.ts +445 -445
- package/src/auth/duration.ts +33 -33
- package/src/auth/index.ts +21 -21
- package/src/auth/self-label.ts +74 -74
- package/src/auth/trust-circle-store.ts +155 -155
- package/src/auth/types.ts +99 -99
- package/src/donation/__tests__/donation-service.test.ts +1068 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/donation/__tests__/fake-orchestrator.ts +225 -225
- package/src/donation/__tests__/grant-service.test.ts +213 -213
- package/src/donation/__tests__/grant-store.test.ts +113 -113
- package/src/donation/donation-service.ts +962 -962
- package/src/donation/donation-store.ts +171 -171
- package/src/donation/donation-supervisor.ts +388 -388
- package/src/donation/grant-service.ts +200 -200
- package/src/donation/grant-store.ts +152 -152
- package/src/donation/index.ts +64 -64
- package/src/donation/types.ts +243 -243
- package/src/installer/__tests__/identity.test.ts +34 -34
- package/src/installer/__tests__/paths.test.ts +47 -47
- package/src/installer/__tests__/service-host.test.ts +62 -62
- package/src/installer/__tests__/wizard.test.ts +64 -64
- package/src/installer/identity.ts +49 -49
- package/src/installer/paths.ts +109 -109
- package/src/installer/platform.ts +27 -27
- package/src/installer/service-host/index.ts +21 -21
- package/src/installer/service-host/template.ts +21 -21
- package/src/installer/wizard.ts +103 -103
- package/src/nat/__tests__/address-resolver.test.ts +82 -82
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
- package/src/nat/__tests__/ddns-updater.test.ts +253 -253
- package/src/nat/__tests__/external-ip.test.ts +124 -124
- package/src/nat/__tests__/nat-service.test.ts +497 -497
- package/src/nat/__tests__/nat-store.test.ts +93 -93
- package/src/nat/__tests__/port-mapper.test.ts +191 -191
- package/src/nat/__tests__/reachability.test.ts +24 -24
- package/src/nat/__tests__/secrets.test.ts +178 -178
- package/src/nat/address-resolver.ts +51 -51
- package/src/nat/ddns/duckdns.ts +76 -76
- package/src/nat/ddns/index.ts +55 -55
- package/src/nat/ddns/updater.ts +243 -243
- package/src/nat/external-ip.ts +154 -154
- package/src/nat/index.ts +71 -71
- package/src/nat/nat-service.ts +501 -501
- package/src/nat/nat-store.ts +154 -154
- package/src/nat/port-mapper.ts +329 -329
- package/src/nat/reachability.ts +34 -34
- package/src/nat/secrets/file-store.ts +130 -130
- package/src/nat/secrets/index.ts +112 -112
- package/src/nat/secrets/keytar-store.ts +67 -67
- package/src/nat/types.ts +159 -159
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
- package/src/orchestrator/index.ts +10 -10
- package/src/orchestrator/log-rotator.ts +53 -53
- package/src/orchestrator/node-identity.ts +40 -40
- package/src/orchestrator/pid-liveness.ts +24 -24
- package/src/orchestrator/port-allocator.ts +91 -91
- package/src/orchestrator/state-store.ts +90 -90
- package/src/orchestrator/types.ts +141 -141
- package/src/owner/__tests__/owner-node-client.test.ts +194 -194
- package/src/owner/index.ts +12 -12
- package/src/owner/owner-node-client.ts +259 -259
- package/src/push/__tests__/push.test.ts +90 -90
- package/src/push/index.ts +146 -146
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +118 -118
- package/src/server/__tests__/grants-admin-route.test.ts +73 -73
- package/src/server/__tests__/grants-route.test.ts +263 -263
- package/src/server/__tests__/nodes-route.test.ts +259 -259
- package/src/server/__tests__/origin-guard.test.ts +72 -72
- package/src/server/__tests__/publishers.test.ts +216 -216
- package/src/server/__tests__/server.smoke.test.ts +253 -253
- package/src/server/__tests__/settings-route.test.ts +194 -194
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/static.test.ts +145 -145
- package/src/server/__tests__/status-route.test.ts +134 -134
- package/src/server/__tests__/strands-route.test.ts +233 -233
- package/src/server/error-handler.ts +178 -178
- package/src/server/events/bus.ts +42 -42
- package/src/server/events/sse-route.ts +81 -81
- package/src/server/events/types.ts +23 -23
- package/src/server/index.ts +267 -267
- package/src/server/origin-guard.ts +84 -84
- package/src/server/routes/grants-admin.ts +41 -41
- package/src/server/routes/grants.ts +210 -210
- package/src/server/routes/nat.ts +63 -63
- package/src/server/routes/nodes.ts +170 -170
- package/src/server/routes/settings.ts +123 -123
- package/src/server/routes/status.ts +118 -118
- package/src/server/routes/strands.ts +54 -54
- package/src/server/routes/trust-circle.ts +49 -49
- package/src/server/routes/update.ts +43 -43
- package/src/server/server.ts +122 -122
- package/src/server/settings-store.ts +37 -37
- package/src/server/static.ts +207 -207
- package/src/strands/__tests__/strand-service.test.ts +165 -165
- package/src/strands/index.ts +14 -14
- package/src/strands/strand-service.ts +135 -135
- package/src/strands/types.ts +102 -102
- package/src/update/__tests__/apply.test.ts +158 -158
- package/src/update/__tests__/manifest.test.ts +128 -128
- package/src/update/__tests__/release-key.test.ts +42 -42
- package/src/update/__tests__/sign.test.ts +78 -78
- package/src/update/__tests__/store.test.ts +81 -81
- package/src/update/__tests__/update-service.integration.test.ts +256 -256
- package/src/update/__tests__/version.test.ts +31 -31
- package/src/update/apply.ts +190 -190
- package/src/update/index.ts +341 -341
- package/src/update/release-key.ts +71 -71
- package/src/update/sign.ts +74 -74
- package/src/update/store.ts +101 -101
- package/src/update/types.ts +109 -109
- package/src/update/version.ts +80 -80
|
@@ -1,233 +1,233 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `/api/strands` route tests — a real Fastify built by `createLocalUiServer`
|
|
3
|
-
* (so the error handler, the mount gate, and the static not-found fallback are
|
|
4
|
-
* all the production ones) over a fake StrandService.
|
|
5
|
-
*
|
|
6
|
-
* What the service does is covered by `strands/__tests__/strand-service.test.ts`;
|
|
7
|
-
* this file is about the HTTP contract: envelope shape, status mapping, the
|
|
8
|
-
* `confirm` query param, and exactly-once bus publication.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
-
import { tmpdir } from 'node:os';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
|
|
15
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
16
|
-
|
|
17
|
-
import { createLocalUiServer } from '../index.js';
|
|
18
|
-
import type { LocalUiEvent } from '../events/types.js';
|
|
19
|
-
import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
20
|
-
import type { StrandService } from '../../strands/index.js';
|
|
21
|
-
import { StrandError } from '../../strands/index.js';
|
|
22
|
-
import type { StrandListSnapshot, StrandRemovalResult } from '../../strands/index.js';
|
|
23
|
-
|
|
24
|
-
function fakeOrchestrator(): HostProcessOrchestrator {
|
|
25
|
-
return {
|
|
26
|
-
listNodes: () => [],
|
|
27
|
-
getNode: () => undefined,
|
|
28
|
-
resolveDockerId: () => undefined,
|
|
29
|
-
onStateChange: () => () => undefined,
|
|
30
|
-
} as unknown as HostProcessOrchestrator;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const SNAPSHOT: StrandListSnapshot = {
|
|
34
|
-
strands: [
|
|
35
|
-
{ id: 'open-one', type: 'o', running: true, status: 'active' },
|
|
36
|
-
{ id: 'closed-one', type: 'c', running: false, status: null },
|
|
37
|
-
],
|
|
38
|
-
controlConnections: 3,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
interface FakeStrands {
|
|
42
|
-
service: StrandService;
|
|
43
|
-
/** Every (id, confirm) the route forwarded. */
|
|
44
|
-
calls: Array<{ strandId: string; confirm: boolean }>;
|
|
45
|
-
/** Set to make `list` and `remove` throw. */
|
|
46
|
-
failWith?: unknown;
|
|
47
|
-
/** Set to make `remove` report a strand that was never published. */
|
|
48
|
-
absent?: boolean;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function fakeStrandService(): FakeStrands {
|
|
52
|
-
const state: FakeStrands = {
|
|
53
|
-
calls: [],
|
|
54
|
-
service: undefined as unknown as StrandService,
|
|
55
|
-
};
|
|
56
|
-
state.service = {
|
|
57
|
-
async list(): Promise<StrandListSnapshot> {
|
|
58
|
-
if (state.failWith) throw state.failWith;
|
|
59
|
-
return SNAPSHOT;
|
|
60
|
-
},
|
|
61
|
-
async remove(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
|
|
62
|
-
state.calls.push({ strandId, confirm: opts.confirm });
|
|
63
|
-
if (state.failWith) throw state.failWith;
|
|
64
|
-
return state.absent
|
|
65
|
-
? { strandId, published: false, type: null, removed: false, alone: false }
|
|
66
|
-
: { strandId, published: true, type: 'o', removed: true, alone: false };
|
|
67
|
-
},
|
|
68
|
-
} as unknown as StrandService;
|
|
69
|
-
return state;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
let dataDir: string;
|
|
73
|
-
let server: ReturnType<typeof createLocalUiServer>;
|
|
74
|
-
let strands: FakeStrands;
|
|
75
|
-
let events: LocalUiEvent[];
|
|
76
|
-
|
|
77
|
-
function buildServer(opts: { withStrands: boolean }): void {
|
|
78
|
-
server = createLocalUiServer({
|
|
79
|
-
uiPort: 8765,
|
|
80
|
-
dataDir,
|
|
81
|
-
orchestrator: fakeOrchestrator(),
|
|
82
|
-
...(opts.withStrands ? { strands: strands.service } : {}),
|
|
83
|
-
forcePort: 0,
|
|
84
|
-
});
|
|
85
|
-
events = [];
|
|
86
|
-
server.events.subscribe((e) => events.push(e));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
beforeEach(() => {
|
|
90
|
-
dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-strands-route-'));
|
|
91
|
-
strands = fakeStrandService();
|
|
92
|
-
buildServer({ withStrands: true });
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
afterEach(async () => {
|
|
96
|
-
await server.app.close();
|
|
97
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe('GET /api/strands', () => {
|
|
101
|
-
it('returns the snapshot in the /api/* envelope', async () => {
|
|
102
|
-
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
103
|
-
expect(res.statusCode).toBe(200);
|
|
104
|
-
const body = res.json() as { ok: boolean; data: StrandListSnapshot };
|
|
105
|
-
expect(body.ok).toBe(true);
|
|
106
|
-
expect(body.data.strands).toHaveLength(2);
|
|
107
|
-
expect(body.data.strands[0]?.id).toBe('open-one');
|
|
108
|
-
expect(body.data.controlConnections).toBe(3);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('maps a service failure through the same error handler as the DELETE arm', async () => {
|
|
112
|
-
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
113
|
-
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
114
|
-
expect(res.statusCode).toBe(503);
|
|
115
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe('DELETE /api/strands/:id', () => {
|
|
120
|
-
it('removes a published strand → 200 and exactly one strands-changed event', async () => {
|
|
121
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
122
|
-
expect(res.statusCode).toBe(200);
|
|
123
|
-
const body = res.json() as { ok: boolean; data: StrandRemovalResult };
|
|
124
|
-
expect(body.ok).toBe(true);
|
|
125
|
-
expect(body.data).toMatchObject({ strandId: 'open-one', published: true, removed: true });
|
|
126
|
-
expect(events).toEqual([{ type: 'strands-changed', kind: 'removed' }]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('an absent strand → 200, published: false, and NO event', async () => {
|
|
130
|
-
strands.absent = true;
|
|
131
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/never-published' });
|
|
132
|
-
expect(res.statusCode).toBe(200);
|
|
133
|
-
const body = res.json() as { data: StrandRemovalResult };
|
|
134
|
-
expect(body.data.published).toBe(false);
|
|
135
|
-
expect(body.data.removed).toBe(false);
|
|
136
|
-
expect(events).toEqual([]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('forwards confirm=1 as true and its absence as false', async () => {
|
|
140
|
-
await server.app.inject({ method: 'DELETE', url: '/api/strands/a' });
|
|
141
|
-
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=1' });
|
|
142
|
-
await server.app.inject({ method: 'DELETE', url: '/api/strands/c?confirm=true' });
|
|
143
|
-
expect(strands.calls).toEqual([
|
|
144
|
-
{ strandId: 'a', confirm: false },
|
|
145
|
-
{ strandId: 'b', confirm: true },
|
|
146
|
-
{ strandId: 'c', confirm: true },
|
|
147
|
-
]);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('treats a guessy confirm value as NOT confirmed', async () => {
|
|
151
|
-
// `yes`/`on`/empty are refused deliberately — a typo must not destroy a
|
|
152
|
-
// closed strand's membership key.
|
|
153
|
-
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?confirm=yes' });
|
|
154
|
-
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=' });
|
|
155
|
-
expect(strands.calls).toEqual([
|
|
156
|
-
{ strandId: 'a', confirm: false },
|
|
157
|
-
{ strandId: 'b', confirm: false },
|
|
158
|
-
]);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('ignores query params other than confirm rather than proxying them', async () => {
|
|
162
|
-
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?force=1&confirm=1' });
|
|
163
|
-
expect(strands.calls).toEqual([{ strandId: 'a', confirm: true }]);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// The route is one path segment, so an id holding a `/` has to arrive
|
|
167
|
-
// percent-encoded. Fastify decodes `params`, so the service sees the literal id
|
|
168
|
-
// and forwards it — nothing in this chain needs to refuse such an id.
|
|
169
|
-
it('carries a percent-encoded "/" through to the service as a literal', async () => {
|
|
170
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns%2Fstrand' });
|
|
171
|
-
expect(res.statusCode).toBe(200);
|
|
172
|
-
expect(strands.calls).toEqual([{ strandId: 'ns/strand', confirm: false }]);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('404s an UNencoded "/" at the router, before any handler runs', async () => {
|
|
176
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns/strand' });
|
|
177
|
-
expect(res.statusCode).toBe(404);
|
|
178
|
-
expect(strands.calls).toEqual([]);
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('DELETE /api/strands/:id — error mapping', () => {
|
|
183
|
-
it('confirmation_required → 428 with the node message intact', async () => {
|
|
184
|
-
const message = 'Refusing to remove closed strand closed-one without confirmation: …';
|
|
185
|
-
strands.failWith = new StrandError('confirmation_required', message);
|
|
186
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/closed-one' });
|
|
187
|
-
expect(res.statusCode).toBe(428);
|
|
188
|
-
const body = res.json() as { ok: boolean; error: { code: string; message: string } };
|
|
189
|
-
expect(body.ok).toBe(false);
|
|
190
|
-
expect(body.error.code).toBe('confirmation_required');
|
|
191
|
-
expect(body.error.message).toBe(message);
|
|
192
|
-
expect(events).toEqual([]);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('node_unavailable → 503', async () => {
|
|
196
|
-
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
197
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
198
|
-
expect(res.statusCode).toBe(503);
|
|
199
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('invalid_id → 400', async () => {
|
|
203
|
-
strands.failWith = new StrandError('invalid_id', 'A strand id is required');
|
|
204
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/%20' });
|
|
205
|
-
expect(res.statusCode).toBe(400);
|
|
206
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_id');
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('internal → 500', async () => {
|
|
210
|
-
strands.failWith = new StrandError('internal', 'node said something new');
|
|
211
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
212
|
-
expect(res.statusCode).toBe(500);
|
|
213
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('internal');
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('donor-only mode (no strands option)', () => {
|
|
218
|
-
beforeEach(async () => {
|
|
219
|
-
await server.app.close();
|
|
220
|
-
buildServer({ withStrands: false });
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('404s GET /api/strands through the static not-found handler', async () => {
|
|
224
|
-
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
225
|
-
expect(res.statusCode).toBe(404);
|
|
226
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('404s DELETE /api/strands/:id', async () => {
|
|
230
|
-
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
231
|
-
expect(res.statusCode).toBe(404);
|
|
232
|
-
});
|
|
233
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* `/api/strands` route tests — a real Fastify built by `createLocalUiServer`
|
|
3
|
+
* (so the error handler, the mount gate, and the static not-found fallback are
|
|
4
|
+
* all the production ones) over a fake StrandService.
|
|
5
|
+
*
|
|
6
|
+
* What the service does is covered by `strands/__tests__/strand-service.test.ts`;
|
|
7
|
+
* this file is about the HTTP contract: envelope shape, status mapping, the
|
|
8
|
+
* `confirm` query param, and exactly-once bus publication.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { createLocalUiServer } from '../index.js';
|
|
18
|
+
import type { LocalUiEvent } from '../events/types.js';
|
|
19
|
+
import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
20
|
+
import type { StrandService } from '../../strands/index.js';
|
|
21
|
+
import { StrandError } from '../../strands/index.js';
|
|
22
|
+
import type { StrandListSnapshot, StrandRemovalResult } from '../../strands/index.js';
|
|
23
|
+
|
|
24
|
+
function fakeOrchestrator(): HostProcessOrchestrator {
|
|
25
|
+
return {
|
|
26
|
+
listNodes: () => [],
|
|
27
|
+
getNode: () => undefined,
|
|
28
|
+
resolveDockerId: () => undefined,
|
|
29
|
+
onStateChange: () => () => undefined,
|
|
30
|
+
} as unknown as HostProcessOrchestrator;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SNAPSHOT: StrandListSnapshot = {
|
|
34
|
+
strands: [
|
|
35
|
+
{ id: 'open-one', type: 'o', running: true, status: 'active' },
|
|
36
|
+
{ id: 'closed-one', type: 'c', running: false, status: null },
|
|
37
|
+
],
|
|
38
|
+
controlConnections: 3,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface FakeStrands {
|
|
42
|
+
service: StrandService;
|
|
43
|
+
/** Every (id, confirm) the route forwarded. */
|
|
44
|
+
calls: Array<{ strandId: string; confirm: boolean }>;
|
|
45
|
+
/** Set to make `list` and `remove` throw. */
|
|
46
|
+
failWith?: unknown;
|
|
47
|
+
/** Set to make `remove` report a strand that was never published. */
|
|
48
|
+
absent?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeStrandService(): FakeStrands {
|
|
52
|
+
const state: FakeStrands = {
|
|
53
|
+
calls: [],
|
|
54
|
+
service: undefined as unknown as StrandService,
|
|
55
|
+
};
|
|
56
|
+
state.service = {
|
|
57
|
+
async list(): Promise<StrandListSnapshot> {
|
|
58
|
+
if (state.failWith) throw state.failWith;
|
|
59
|
+
return SNAPSHOT;
|
|
60
|
+
},
|
|
61
|
+
async remove(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
|
|
62
|
+
state.calls.push({ strandId, confirm: opts.confirm });
|
|
63
|
+
if (state.failWith) throw state.failWith;
|
|
64
|
+
return state.absent
|
|
65
|
+
? { strandId, published: false, type: null, removed: false, alone: false }
|
|
66
|
+
: { strandId, published: true, type: 'o', removed: true, alone: false };
|
|
67
|
+
},
|
|
68
|
+
} as unknown as StrandService;
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let dataDir: string;
|
|
73
|
+
let server: ReturnType<typeof createLocalUiServer>;
|
|
74
|
+
let strands: FakeStrands;
|
|
75
|
+
let events: LocalUiEvent[];
|
|
76
|
+
|
|
77
|
+
function buildServer(opts: { withStrands: boolean }): void {
|
|
78
|
+
server = createLocalUiServer({
|
|
79
|
+
uiPort: 8765,
|
|
80
|
+
dataDir,
|
|
81
|
+
orchestrator: fakeOrchestrator(),
|
|
82
|
+
...(opts.withStrands ? { strands: strands.service } : {}),
|
|
83
|
+
forcePort: 0,
|
|
84
|
+
});
|
|
85
|
+
events = [];
|
|
86
|
+
server.events.subscribe((e) => events.push(e));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-strands-route-'));
|
|
91
|
+
strands = fakeStrandService();
|
|
92
|
+
buildServer({ withStrands: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await server.app.close();
|
|
97
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('GET /api/strands', () => {
|
|
101
|
+
it('returns the snapshot in the /api/* envelope', async () => {
|
|
102
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
103
|
+
expect(res.statusCode).toBe(200);
|
|
104
|
+
const body = res.json() as { ok: boolean; data: StrandListSnapshot };
|
|
105
|
+
expect(body.ok).toBe(true);
|
|
106
|
+
expect(body.data.strands).toHaveLength(2);
|
|
107
|
+
expect(body.data.strands[0]?.id).toBe('open-one');
|
|
108
|
+
expect(body.data.controlConnections).toBe(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('maps a service failure through the same error handler as the DELETE arm', async () => {
|
|
112
|
+
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
113
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
114
|
+
expect(res.statusCode).toBe(503);
|
|
115
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('DELETE /api/strands/:id', () => {
|
|
120
|
+
it('removes a published strand → 200 and exactly one strands-changed event', async () => {
|
|
121
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
122
|
+
expect(res.statusCode).toBe(200);
|
|
123
|
+
const body = res.json() as { ok: boolean; data: StrandRemovalResult };
|
|
124
|
+
expect(body.ok).toBe(true);
|
|
125
|
+
expect(body.data).toMatchObject({ strandId: 'open-one', published: true, removed: true });
|
|
126
|
+
expect(events).toEqual([{ type: 'strands-changed', kind: 'removed' }]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('an absent strand → 200, published: false, and NO event', async () => {
|
|
130
|
+
strands.absent = true;
|
|
131
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/never-published' });
|
|
132
|
+
expect(res.statusCode).toBe(200);
|
|
133
|
+
const body = res.json() as { data: StrandRemovalResult };
|
|
134
|
+
expect(body.data.published).toBe(false);
|
|
135
|
+
expect(body.data.removed).toBe(false);
|
|
136
|
+
expect(events).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('forwards confirm=1 as true and its absence as false', async () => {
|
|
140
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a' });
|
|
141
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=1' });
|
|
142
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/c?confirm=true' });
|
|
143
|
+
expect(strands.calls).toEqual([
|
|
144
|
+
{ strandId: 'a', confirm: false },
|
|
145
|
+
{ strandId: 'b', confirm: true },
|
|
146
|
+
{ strandId: 'c', confirm: true },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('treats a guessy confirm value as NOT confirmed', async () => {
|
|
151
|
+
// `yes`/`on`/empty are refused deliberately — a typo must not destroy a
|
|
152
|
+
// closed strand's membership key.
|
|
153
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?confirm=yes' });
|
|
154
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=' });
|
|
155
|
+
expect(strands.calls).toEqual([
|
|
156
|
+
{ strandId: 'a', confirm: false },
|
|
157
|
+
{ strandId: 'b', confirm: false },
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('ignores query params other than confirm rather than proxying them', async () => {
|
|
162
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?force=1&confirm=1' });
|
|
163
|
+
expect(strands.calls).toEqual([{ strandId: 'a', confirm: true }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// The route is one path segment, so an id holding a `/` has to arrive
|
|
167
|
+
// percent-encoded. Fastify decodes `params`, so the service sees the literal id
|
|
168
|
+
// and forwards it — nothing in this chain needs to refuse such an id.
|
|
169
|
+
it('carries a percent-encoded "/" through to the service as a literal', async () => {
|
|
170
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns%2Fstrand' });
|
|
171
|
+
expect(res.statusCode).toBe(200);
|
|
172
|
+
expect(strands.calls).toEqual([{ strandId: 'ns/strand', confirm: false }]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('404s an UNencoded "/" at the router, before any handler runs', async () => {
|
|
176
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns/strand' });
|
|
177
|
+
expect(res.statusCode).toBe(404);
|
|
178
|
+
expect(strands.calls).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('DELETE /api/strands/:id — error mapping', () => {
|
|
183
|
+
it('confirmation_required → 428 with the node message intact', async () => {
|
|
184
|
+
const message = 'Refusing to remove closed strand closed-one without confirmation: …';
|
|
185
|
+
strands.failWith = new StrandError('confirmation_required', message);
|
|
186
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/closed-one' });
|
|
187
|
+
expect(res.statusCode).toBe(428);
|
|
188
|
+
const body = res.json() as { ok: boolean; error: { code: string; message: string } };
|
|
189
|
+
expect(body.ok).toBe(false);
|
|
190
|
+
expect(body.error.code).toBe('confirmation_required');
|
|
191
|
+
expect(body.error.message).toBe(message);
|
|
192
|
+
expect(events).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('node_unavailable → 503', async () => {
|
|
196
|
+
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
197
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
198
|
+
expect(res.statusCode).toBe(503);
|
|
199
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('invalid_id → 400', async () => {
|
|
203
|
+
strands.failWith = new StrandError('invalid_id', 'A strand id is required');
|
|
204
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/%20' });
|
|
205
|
+
expect(res.statusCode).toBe(400);
|
|
206
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_id');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('internal → 500', async () => {
|
|
210
|
+
strands.failWith = new StrandError('internal', 'node said something new');
|
|
211
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
212
|
+
expect(res.statusCode).toBe(500);
|
|
213
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('internal');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('donor-only mode (no strands option)', () => {
|
|
218
|
+
beforeEach(async () => {
|
|
219
|
+
await server.app.close();
|
|
220
|
+
buildServer({ withStrands: false });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('404s GET /api/strands through the static not-found handler', async () => {
|
|
224
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
225
|
+
expect(res.statusCode).toBe(404);
|
|
226
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('404s DELETE /api/strands/:id', async () => {
|
|
230
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
231
|
+
expect(res.statusCode).toBe(404);
|
|
232
|
+
});
|
|
233
|
+
});
|