@serfab/cadre-host 0.10.0 → 0.12.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.
Files changed (158) hide show
  1. package/README.md +406 -296
  2. package/dist/bin/host.d.ts +11 -7
  3. package/dist/bin/host.js +40 -12
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/installer/config.d.ts +4 -4
  6. package/dist/installer/config.js +5 -39
  7. package/dist/installer/config.js.map +1 -1
  8. package/dist/installer/identity.d.ts +8 -1
  9. package/dist/installer/identity.js +8 -1
  10. package/dist/installer/identity.js.map +1 -1
  11. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  12. package/dist/orchestrator/host-process-orchestrator.js +9 -4
  13. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  14. package/dist/server/settings-store.d.ts +1 -1
  15. package/dist/server/settings-store.js +5 -1
  16. package/dist/server/settings-store.js.map +1 -1
  17. package/dist/server/static.js +38 -38
  18. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  19. package/dist/ui/index.html +12 -12
  20. package/package.json +4 -4
  21. package/service/cadre-host.service.tmpl +16 -16
  22. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  23. package/service/install-service.ps1 +98 -98
  24. package/service/uninstall-service.ps1 +19 -19
  25. package/src/__tests__/build-targets.test.ts +26 -26
  26. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  27. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  28. package/src/__tests__/cli.smoke.test.ts +51 -46
  29. package/src/__tests__/global-setup.ts +56 -51
  30. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  31. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  32. package/src/__tests__/orchestrator-node-identity.test.ts +5 -5
  33. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  34. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  35. package/src/__tests__/orchestrator-push.test.ts +145 -145
  36. package/src/__tests__/orchestrator.test.ts +951 -951
  37. package/src/auth/__tests__/duration.test.ts +28 -28
  38. package/src/auth/__tests__/self-label.test.ts +85 -85
  39. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  40. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  41. package/src/auth/duration.ts +33 -33
  42. package/src/auth/index.ts +21 -21
  43. package/src/auth/self-label.ts +74 -74
  44. package/src/auth/trust-circle-store.ts +155 -155
  45. package/src/auth/types.ts +99 -99
  46. package/src/bin/__tests__/cli-reference.test.ts +167 -0
  47. package/src/bin/host.ts +1326 -1299
  48. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  49. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  50. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  51. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  52. package/src/donation/__tests__/grant-service.test.ts +213 -213
  53. package/src/donation/__tests__/grant-store.test.ts +113 -113
  54. package/src/donation/donation-service.ts +962 -962
  55. package/src/donation/donation-store.ts +171 -171
  56. package/src/donation/donation-supervisor.ts +388 -388
  57. package/src/donation/grant-service.ts +200 -200
  58. package/src/donation/grant-store.ts +152 -152
  59. package/src/donation/index.ts +64 -64
  60. package/src/donation/types.ts +243 -243
  61. package/src/installer/__tests__/config.test.ts +134 -112
  62. package/src/installer/__tests__/identity.test.ts +34 -34
  63. package/src/installer/__tests__/paths.test.ts +47 -47
  64. package/src/installer/__tests__/service-host.test.ts +62 -62
  65. package/src/installer/__tests__/wizard.test.ts +64 -64
  66. package/src/installer/config.ts +5 -43
  67. package/src/installer/identity.ts +56 -49
  68. package/src/installer/paths.ts +109 -109
  69. package/src/installer/platform.ts +27 -27
  70. package/src/installer/service-host/index.ts +21 -21
  71. package/src/installer/service-host/template.ts +21 -21
  72. package/src/installer/wizard.ts +103 -103
  73. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  74. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  75. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  76. package/src/nat/__tests__/external-ip.test.ts +124 -124
  77. package/src/nat/__tests__/nat-service.test.ts +18 -0
  78. package/src/nat/__tests__/nat-store.test.ts +93 -93
  79. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  80. package/src/nat/__tests__/reachability.test.ts +24 -24
  81. package/src/nat/__tests__/secrets.test.ts +178 -178
  82. package/src/nat/address-resolver.ts +51 -51
  83. package/src/nat/ddns/duckdns.ts +76 -76
  84. package/src/nat/ddns/index.ts +55 -55
  85. package/src/nat/ddns/updater.ts +243 -243
  86. package/src/nat/external-ip.ts +154 -154
  87. package/src/nat/index.ts +71 -71
  88. package/src/nat/nat-service.ts +501 -501
  89. package/src/nat/nat-store.ts +154 -154
  90. package/src/nat/port-mapper.ts +329 -329
  91. package/src/nat/reachability.ts +34 -34
  92. package/src/nat/secrets/file-store.ts +130 -130
  93. package/src/nat/secrets/index.ts +112 -112
  94. package/src/nat/secrets/keytar-store.ts +67 -67
  95. package/src/nat/types.ts +159 -159
  96. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  97. package/src/orchestrator/host-process-orchestrator.ts +9 -4
  98. package/src/orchestrator/index.ts +10 -10
  99. package/src/orchestrator/log-rotator.ts +53 -53
  100. package/src/orchestrator/node-identity.ts +40 -40
  101. package/src/orchestrator/pid-liveness.ts +24 -24
  102. package/src/orchestrator/port-allocator.ts +91 -91
  103. package/src/orchestrator/state-store.ts +90 -90
  104. package/src/orchestrator/types.ts +141 -141
  105. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  106. package/src/owner/index.ts +12 -12
  107. package/src/owner/owner-node-client.ts +259 -259
  108. package/src/push/__tests__/push.test.ts +90 -90
  109. package/src/push/index.ts +146 -146
  110. package/src/server/__tests__/bus.test.ts +38 -38
  111. package/src/server/__tests__/error-handler.test.ts +118 -118
  112. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  113. package/src/server/__tests__/grants-route.test.ts +263 -263
  114. package/src/server/__tests__/nodes-route.test.ts +259 -259
  115. package/src/server/__tests__/origin-guard.test.ts +72 -72
  116. package/src/server/__tests__/publishers.test.ts +216 -216
  117. package/src/server/__tests__/server.smoke.test.ts +253 -253
  118. package/src/server/__tests__/settings-route.test.ts +195 -194
  119. package/src/server/__tests__/sse-route.test.ts +119 -119
  120. package/src/server/__tests__/static.test.ts +145 -145
  121. package/src/server/__tests__/status-route.test.ts +134 -134
  122. package/src/server/__tests__/strands-route.test.ts +233 -233
  123. package/src/server/error-handler.ts +178 -178
  124. package/src/server/events/bus.ts +42 -42
  125. package/src/server/events/sse-route.ts +81 -81
  126. package/src/server/events/types.ts +23 -23
  127. package/src/server/index.ts +267 -267
  128. package/src/server/origin-guard.ts +84 -84
  129. package/src/server/routes/grants-admin.ts +41 -41
  130. package/src/server/routes/grants.ts +210 -210
  131. package/src/server/routes/nat.ts +63 -63
  132. package/src/server/routes/nodes.ts +170 -170
  133. package/src/server/routes/settings.ts +123 -123
  134. package/src/server/routes/status.ts +118 -118
  135. package/src/server/routes/strands.ts +54 -54
  136. package/src/server/routes/trust-circle.ts +49 -49
  137. package/src/server/routes/update.ts +43 -43
  138. package/src/server/server.ts +122 -122
  139. package/src/server/settings-store.ts +5 -1
  140. package/src/server/static.ts +207 -207
  141. package/src/strands/__tests__/strand-service.test.ts +165 -165
  142. package/src/strands/index.ts +14 -14
  143. package/src/strands/strand-service.ts +135 -135
  144. package/src/strands/types.ts +102 -102
  145. package/src/update/__tests__/apply.test.ts +158 -158
  146. package/src/update/__tests__/manifest.test.ts +128 -128
  147. package/src/update/__tests__/release-key.test.ts +42 -42
  148. package/src/update/__tests__/sign.test.ts +78 -78
  149. package/src/update/__tests__/store.test.ts +81 -81
  150. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  151. package/src/update/__tests__/version.test.ts +31 -31
  152. package/src/update/apply.ts +190 -190
  153. package/src/update/index.ts +341 -341
  154. package/src/update/release-key.ts +71 -71
  155. package/src/update/sign.ts +74 -74
  156. package/src/update/store.ts +101 -101
  157. package/src/update/types.ts +109 -109
  158. 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
+ });