@serfab/cadre-host 1.14.0 → 1.15.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/README.md +19 -4
- package/dist/bin/host.js +32 -20
- package/dist/bin/host.js.map +1 -1
- package/dist/bin/nat-output.d.ts +5 -0
- package/dist/bin/nat-output.js +12 -1
- package/dist/bin/nat-output.js.map +1 -1
- package/dist/bin/ui-access.d.ts +22 -0
- package/dist/bin/ui-access.js +44 -0
- package/dist/bin/ui-access.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +25 -3
- package/dist/hosted/claim-details.js +31 -37
- package/dist/hosted/claim-details.js.map +1 -1
- package/dist/hosted/hosted-node-service.d.ts +7 -0
- package/dist/hosted/hosted-node-service.js +16 -4
- package/dist/hosted/hosted-node-service.js.map +1 -1
- package/dist/hosted/hosted-node-watcher.d.ts +16 -4
- package/dist/hosted/hosted-node-watcher.js +45 -9
- package/dist/hosted/hosted-node-watcher.js.map +1 -1
- package/dist/hosted/node-status.d.ts +19 -3
- package/dist/hosted/node-status.js +21 -5
- package/dist/hosted/node-status.js.map +1 -1
- package/dist/hosted/types.d.ts +12 -5
- package/dist/hosted/types.js.map +1 -1
- package/dist/installer/config.d.ts +23 -0
- package/dist/installer/config.js +46 -0
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +2 -0
- package/dist/installer/index.js +7 -16
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/wizard.d.ts +12 -2
- package/dist/installer/wizard.js +15 -7
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/index.d.ts +2 -1
- package/dist/nat/index.js +1 -0
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +22 -5
- package/dist/nat/nat-service.js +75 -26
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.js +11 -0
- package/dist/nat/nat-store.js.map +1 -1
- package/dist/nat/network-mode.d.ts +19 -0
- package/dist/nat/network-mode.js +32 -0
- package/dist/nat/network-mode.js.map +1 -0
- package/dist/nat/reachability.d.ts +5 -4
- package/dist/nat/reachability.js +19 -6
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +26 -6
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
- package/dist/orchestrator/host-process-orchestrator.js +6 -15
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/package-version.d.ts +1 -0
- package/dist/package-version.js +4 -0
- package/dist/package-version.js.map +1 -0
- package/dist/server/routes/settings.d.ts +1 -0
- package/dist/server/routes/settings.js +15 -1
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.js +2 -32
- package/dist/server/routes/status.js.map +1 -1
- package/dist/ui/assets/index-BHd8CWZA.css +1 -0
- package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
- package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
- package/dist/ui/index.html +2 -2
- package/package.json +4 -5
- package/src/__tests__/cli.smoke.test.ts +1 -1
- package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
- package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
- package/src/__tests__/orchestrator-handle-contract.ts +1 -1
- package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
- package/src/__tests__/orchestrator-push.test.ts +3 -3
- package/src/__tests__/orchestrator.test.ts +3 -5
- package/src/__tests__/ui-access.test.ts +30 -0
- package/src/bin/host.ts +30 -20
- package/src/bin/nat-output.ts +12 -1
- package/src/bin/ui-access.ts +50 -0
- package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
- package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
- package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
- package/src/hosted/claim-details.ts +44 -37
- package/src/hosted/hosted-node-service.ts +23 -4
- package/src/hosted/hosted-node-watcher.ts +288 -250
- package/src/hosted/node-status.ts +31 -5
- package/src/hosted/types.ts +160 -153
- package/src/installer/__tests__/installer.smoke.test.ts +184 -182
- package/src/installer/__tests__/wizard.test.ts +76 -56
- package/src/installer/config.ts +66 -0
- package/src/installer/index.ts +252 -259
- package/src/installer/wizard.ts +101 -87
- package/src/nat/__tests__/nat-service.test.ts +608 -540
- package/src/nat/__tests__/nat-store.test.ts +130 -127
- package/src/nat/__tests__/network-mode.test.ts +38 -0
- package/src/nat/index.ts +91 -87
- package/src/nat/nat-service.ts +79 -26
- package/src/nat/nat-store.ts +249 -237
- package/src/nat/network-mode.ts +36 -0
- package/src/nat/reachability.ts +155 -143
- package/src/nat/types.ts +232 -209
- package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
- package/src/package-version.ts +4 -0
- package/src/server/__tests__/fakes.ts +41 -40
- package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
- package/src/server/__tests__/settings-route.test.ts +31 -0
- package/src/server/routes/settings.ts +128 -115
- package/src/server/routes/status.ts +72 -102
- package/dist/ui/assets/index-4FWuU8ye.js +0 -19
- package/dist/ui/assets/index-Di9Q4d01.css +0 -1
|
@@ -1,40 +1,41 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared fakes for the local-UI server tests. Holds no tests of its own.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { NatService, NatChangeListener } from '../../nat/index.js';
|
|
6
|
-
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
7
|
-
|
|
8
|
-
export const SAMPLE_CONNECTIVITY: NatStatusSnapshot = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared fakes for the local-UI server tests. Holds no tests of its own.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NatService, NatChangeListener } from '../../nat/index.js';
|
|
6
|
+
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
7
|
+
|
|
8
|
+
export const SAMPLE_CONNECTIVITY: NatStatusSnapshot = {
|
|
9
|
+
network: { setting: 'auto', mode: 'lan', publicInterfaceIp: null },
|
|
10
|
+
upnpEnabled: true,
|
|
11
|
+
gateway: { found: true, lanAddress: '192.168.1.20', routerExternalIp: '203.0.113.5', lastError: null },
|
|
12
|
+
externalIp: '203.0.113.5',
|
|
13
|
+
externalIpDetectedAt: null,
|
|
14
|
+
cgnatDetected: false,
|
|
15
|
+
directReachability: 'reachable',
|
|
16
|
+
lastTestedAt: null,
|
|
17
|
+
ddns: {
|
|
18
|
+
providerId: null,
|
|
19
|
+
hostname: null,
|
|
20
|
+
externallyManaged: false,
|
|
21
|
+
lastUpdateAt: null,
|
|
22
|
+
lastUpdateOk: null,
|
|
23
|
+
lastError: null,
|
|
24
|
+
},
|
|
25
|
+
nodes: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A NAT service that answers only the reads the server makes on its own
|
|
30
|
+
* (status, boot publish) and the writes the settings route forwards. `emit`
|
|
31
|
+
* fires the change listeners the server registered, for tests of that wiring.
|
|
32
|
+
*/
|
|
33
|
+
export function fakeNat(snapshot: NatStatusSnapshot = SAMPLE_CONNECTIVITY): NatService & { emit(snap: NatStatusSnapshot): void } {
|
|
34
|
+
const listeners = new Set<NatChangeListener>();
|
|
35
|
+
return {
|
|
36
|
+
getStatus: () => snapshot,
|
|
37
|
+
onChange: (listener: NatChangeListener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
38
|
+
putSettings: async () => snapshot,
|
|
39
|
+
emit: (snap: NatStatusSnapshot) => { for (const l of listeners) l(snap); },
|
|
40
|
+
} as unknown as NatService & { emit(snap: NatStatusSnapshot): void };
|
|
41
|
+
}
|
|
@@ -1,160 +1,161 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `/api/hosted-nodes` route tests: the status code each outcome maps to, driven
|
|
3
|
-
* against a stub service so no child spawns, and the join-by-invitation refusals
|
|
4
|
-
* against the real service over the fake orchestrator, where "before anything is
|
|
5
|
-
* spawned" can be checked. The service's own behaviour is
|
|
6
|
-
* `hosted/__tests__/hosted-node-service.test.ts`.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import Fastify from 'fastify';
|
|
10
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
-
import { tmpdir } from 'node:os';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
-
|
|
15
|
-
import { HostedNodeService } from '../../hosted/hosted-node-service.js';
|
|
16
|
-
import { HostedNodeStore } from '../../hosted/hosted-node-store.js';
|
|
17
|
-
import { HostedNodeError, type HostedNodeView } from '../../hosted/types.js';
|
|
18
|
-
import { FakeOrchestrator } from '../../hosted/__tests__/fake-orchestrator.js';
|
|
19
|
-
import { encodedTestInvitation } from '../../hosted/__tests__/test-invitation.js';
|
|
20
|
-
import { registerErrorHandler } from '../error-handler.js';
|
|
21
|
-
import { registerHostedNodesRoutes } from '../routes/hosted-nodes.js';
|
|
22
|
-
|
|
23
|
-
const UNCLAIMED: HostedNodeView = {
|
|
24
|
-
id: 'hn_a', join: { kind: 'claim' }, partyId: 'unclaimed', profile: 'storage', status: 'unclaimed',
|
|
25
|
-
dockerId: '1:a', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
|
26
|
-
};
|
|
27
|
-
const JOINED: HostedNodeView = { ...UNCLAIMED, id: 'hn_b', status: 'joined', partyId: 'party-P', ownerKey: 'owner' };
|
|
28
|
-
|
|
29
|
-
/** A service with two nodes, answering as the real one would for each. */
|
|
30
|
-
function stubService(): HostedNodeService & { removed: string[] } {
|
|
31
|
-
const nodes = new Map([[UNCLAIMED.id, UNCLAIMED], [JOINED.id, JOINED]]);
|
|
32
|
-
const removed: string[] = [];
|
|
33
|
-
const require = (id: string): HostedNodeView => {
|
|
34
|
-
const node = nodes.get(id);
|
|
35
|
-
if (!node) throw new HostedNodeError('not_found', `No such hosted node: ${id}`);
|
|
36
|
-
return node;
|
|
37
|
-
};
|
|
38
|
-
return {
|
|
39
|
-
removed,
|
|
40
|
-
list: () => [...nodes.values()],
|
|
41
|
-
get: (id: string) => nodes.get(id),
|
|
42
|
-
join: async () => ({ ...UNCLAIMED, id: 'hn_new' }),
|
|
43
|
-
claimDetails: async (id: string) => {
|
|
44
|
-
const node = require(id);
|
|
45
|
-
if (node.status !== 'unclaimed') throw new HostedNodeError('invalid_state', `Hosted node ${id} is ${node.status}`);
|
|
46
|
-
if (id === 'hn_starting') throw new HostedNodeError('node_unavailable', 'not yet');
|
|
47
|
-
return { payload: 'sereus-join:1.x', peerId: '12D3KooW', multiaddrs: ['/ip4/192.168.1.20/tcp/1/ws/p2p/12D3KooW'], reachability: null };
|
|
48
|
-
},
|
|
49
|
-
remove: async (id: string) => { require(id); removed.push(id); },
|
|
50
|
-
reset: async (id: string) => { require(id); return { ...UNCLAIMED, id: 'hn_reset' }; },
|
|
51
|
-
} as unknown as HostedNodeService & { removed: string[] };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
describe('/api/hosted-nodes routes', () => {
|
|
55
|
-
let app: ReturnType<typeof Fastify>;
|
|
56
|
-
let service: ReturnType<typeof stubService>;
|
|
57
|
-
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
app = Fastify();
|
|
60
|
-
registerErrorHandler(app);
|
|
61
|
-
service = stubService();
|
|
62
|
-
registerHostedNodesRoutes(app, { hostedNodes: service });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
afterEach(async () => {
|
|
66
|
-
await app.close();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('answers each outcome with its status code', async () => {
|
|
70
|
-
expect((await app.inject({ method: 'GET', url: '/api/hosted-nodes' })).json()).toEqual({ ok: true, data: { nodes: [UNCLAIMED, JOINED] } });
|
|
71
|
-
|
|
72
|
-
const created = await app.inject({ method: 'POST', url: '/api/hosted-nodes' });
|
|
73
|
-
expect(created.statusCode).toBe(201);
|
|
74
|
-
expect(created.json()).toMatchObject({ ok: true, data: { node: { id: 'hn_new', status: 'unclaimed' } } });
|
|
75
|
-
|
|
76
|
-
expect((await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_a' })).json()).toEqual({ ok: true, data: { node: UNCLAIMED } });
|
|
77
|
-
const missing = await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_nobody' });
|
|
78
|
-
expect(missing.statusCode).toBe(404);
|
|
79
|
-
expect(missing.json()).toMatchObject({ ok: false, error: { code: 'not_found' } });
|
|
80
|
-
|
|
81
|
-
const claim = await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_a/claim' });
|
|
82
|
-
expect(claim.statusCode).toBe(200);
|
|
83
|
-
expect(claim.json()).toMatchObject({ ok: true, data: { payload: 'sereus-join:1.x', peerId: '12D3KooW' } });
|
|
84
|
-
|
|
85
|
-
const removed = await app.inject({ method: 'DELETE', url: '/api/hosted-nodes/hn_b' });
|
|
86
|
-
expect(removed.statusCode).toBe(204);
|
|
87
|
-
expect(service.removed).toEqual(['hn_b']);
|
|
88
|
-
expect((await app.inject({ method: 'DELETE', url: '/api/hosted-nodes/hn_nobody' })).statusCode).toBe(404);
|
|
89
|
-
|
|
90
|
-
const reset = await app.inject({ method: 'POST', url: '/api/hosted-nodes/hn_a/reset' });
|
|
91
|
-
expect(reset.statusCode).toBe(201);
|
|
92
|
-
expect(reset.json()).toMatchObject({ ok: true, data: { node: { id: 'hn_reset' } } });
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('answers 409 for the claim details of a joined node', async () => {
|
|
96
|
-
const res = await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_b/claim' });
|
|
97
|
-
expect(res.statusCode).toBe(409);
|
|
98
|
-
expect(res.json()).toMatchObject({ ok: false, error: { code: 'invalid_state' } });
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe('/api/hosted-nodes join by invitation, over the real service', () => {
|
|
103
|
-
let app: ReturnType<typeof Fastify>;
|
|
104
|
-
let orch: FakeOrchestrator;
|
|
105
|
-
let store: HostedNodeStore;
|
|
106
|
-
let tmpRoot: string;
|
|
107
|
-
|
|
108
|
-
beforeEach(() => {
|
|
109
|
-
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-hosted-route-'));
|
|
110
|
-
orch = new FakeOrchestrator();
|
|
111
|
-
store = new HostedNodeStore(tmpRoot);
|
|
112
|
-
const hostedNodes = new HostedNodeService({
|
|
113
|
-
orchestrator: orch,
|
|
114
|
-
store,
|
|
115
|
-
addresses: { publicAddressesFor: () => [], getStatus: () => ({ nodes: [] }) },
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
expect(res.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
expect(refused.
|
|
151
|
-
expect(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
expect(retried.
|
|
159
|
-
|
|
160
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* `/api/hosted-nodes` route tests: the status code each outcome maps to, driven
|
|
3
|
+
* against a stub service so no child spawns, and the join-by-invitation refusals
|
|
4
|
+
* against the real service over the fake orchestrator, where "before anything is
|
|
5
|
+
* spawned" can be checked. The service's own behaviour is
|
|
6
|
+
* `hosted/__tests__/hosted-node-service.test.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Fastify from 'fastify';
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { HostedNodeService } from '../../hosted/hosted-node-service.js';
|
|
16
|
+
import { HostedNodeStore } from '../../hosted/hosted-node-store.js';
|
|
17
|
+
import { HostedNodeError, type HostedNodeView } from '../../hosted/types.js';
|
|
18
|
+
import { FakeOrchestrator } from '../../hosted/__tests__/fake-orchestrator.js';
|
|
19
|
+
import { encodedTestInvitation } from '../../hosted/__tests__/test-invitation.js';
|
|
20
|
+
import { registerErrorHandler } from '../error-handler.js';
|
|
21
|
+
import { registerHostedNodesRoutes } from '../routes/hosted-nodes.js';
|
|
22
|
+
|
|
23
|
+
const UNCLAIMED: HostedNodeView = {
|
|
24
|
+
id: 'hn_a', join: { kind: 'claim' }, partyId: 'unclaimed', profile: 'storage', status: 'unclaimed',
|
|
25
|
+
dockerId: '1:a', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
|
26
|
+
};
|
|
27
|
+
const JOINED: HostedNodeView = { ...UNCLAIMED, id: 'hn_b', status: 'joined', partyId: 'party-P', ownerKey: 'owner' };
|
|
28
|
+
|
|
29
|
+
/** A service with two nodes, answering as the real one would for each. */
|
|
30
|
+
function stubService(): HostedNodeService & { removed: string[] } {
|
|
31
|
+
const nodes = new Map([[UNCLAIMED.id, UNCLAIMED], [JOINED.id, JOINED]]);
|
|
32
|
+
const removed: string[] = [];
|
|
33
|
+
const require = (id: string): HostedNodeView => {
|
|
34
|
+
const node = nodes.get(id);
|
|
35
|
+
if (!node) throw new HostedNodeError('not_found', `No such hosted node: ${id}`);
|
|
36
|
+
return node;
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
removed,
|
|
40
|
+
list: () => [...nodes.values()],
|
|
41
|
+
get: (id: string) => nodes.get(id),
|
|
42
|
+
join: async () => ({ ...UNCLAIMED, id: 'hn_new' }),
|
|
43
|
+
claimDetails: async (id: string) => {
|
|
44
|
+
const node = require(id);
|
|
45
|
+
if (node.status !== 'unclaimed') throw new HostedNodeError('invalid_state', `Hosted node ${id} is ${node.status}`);
|
|
46
|
+
if (id === 'hn_starting') throw new HostedNodeError('node_unavailable', 'not yet');
|
|
47
|
+
return { payload: 'sereus-join:1.x', peerId: '12D3KooW', multiaddrs: ['/ip4/192.168.1.20/tcp/1/ws/p2p/12D3KooW'], reachability: null };
|
|
48
|
+
},
|
|
49
|
+
remove: async (id: string) => { require(id); removed.push(id); },
|
|
50
|
+
reset: async (id: string) => { require(id); return { ...UNCLAIMED, id: 'hn_reset' }; },
|
|
51
|
+
} as unknown as HostedNodeService & { removed: string[] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('/api/hosted-nodes routes', () => {
|
|
55
|
+
let app: ReturnType<typeof Fastify>;
|
|
56
|
+
let service: ReturnType<typeof stubService>;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
app = Fastify();
|
|
60
|
+
registerErrorHandler(app);
|
|
61
|
+
service = stubService();
|
|
62
|
+
registerHostedNodesRoutes(app, { hostedNodes: service });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await app.close();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('answers each outcome with its status code', async () => {
|
|
70
|
+
expect((await app.inject({ method: 'GET', url: '/api/hosted-nodes' })).json()).toEqual({ ok: true, data: { nodes: [UNCLAIMED, JOINED] } });
|
|
71
|
+
|
|
72
|
+
const created = await app.inject({ method: 'POST', url: '/api/hosted-nodes' });
|
|
73
|
+
expect(created.statusCode).toBe(201);
|
|
74
|
+
expect(created.json()).toMatchObject({ ok: true, data: { node: { id: 'hn_new', status: 'unclaimed' } } });
|
|
75
|
+
|
|
76
|
+
expect((await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_a' })).json()).toEqual({ ok: true, data: { node: UNCLAIMED } });
|
|
77
|
+
const missing = await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_nobody' });
|
|
78
|
+
expect(missing.statusCode).toBe(404);
|
|
79
|
+
expect(missing.json()).toMatchObject({ ok: false, error: { code: 'not_found' } });
|
|
80
|
+
|
|
81
|
+
const claim = await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_a/claim' });
|
|
82
|
+
expect(claim.statusCode).toBe(200);
|
|
83
|
+
expect(claim.json()).toMatchObject({ ok: true, data: { payload: 'sereus-join:1.x', peerId: '12D3KooW' } });
|
|
84
|
+
|
|
85
|
+
const removed = await app.inject({ method: 'DELETE', url: '/api/hosted-nodes/hn_b' });
|
|
86
|
+
expect(removed.statusCode).toBe(204);
|
|
87
|
+
expect(service.removed).toEqual(['hn_b']);
|
|
88
|
+
expect((await app.inject({ method: 'DELETE', url: '/api/hosted-nodes/hn_nobody' })).statusCode).toBe(404);
|
|
89
|
+
|
|
90
|
+
const reset = await app.inject({ method: 'POST', url: '/api/hosted-nodes/hn_a/reset' });
|
|
91
|
+
expect(reset.statusCode).toBe(201);
|
|
92
|
+
expect(reset.json()).toMatchObject({ ok: true, data: { node: { id: 'hn_reset' } } });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('answers 409 for the claim details of a joined node', async () => {
|
|
96
|
+
const res = await app.inject({ method: 'GET', url: '/api/hosted-nodes/hn_b/claim' });
|
|
97
|
+
expect(res.statusCode).toBe(409);
|
|
98
|
+
expect(res.json()).toMatchObject({ ok: false, error: { code: 'invalid_state' } });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('/api/hosted-nodes join by invitation, over the real service', () => {
|
|
103
|
+
let app: ReturnType<typeof Fastify>;
|
|
104
|
+
let orch: FakeOrchestrator;
|
|
105
|
+
let store: HostedNodeStore;
|
|
106
|
+
let tmpRoot: string;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-hosted-route-'));
|
|
110
|
+
orch = new FakeOrchestrator();
|
|
111
|
+
store = new HostedNodeStore(tmpRoot);
|
|
112
|
+
const hostedNodes = new HostedNodeService({
|
|
113
|
+
orchestrator: orch,
|
|
114
|
+
store,
|
|
115
|
+
addresses: { publicAddressesFor: () => [], getStatus: () => ({ nodes: [], gateway: { lanAddress: null }, network: { mode: 'lan' } }) },
|
|
116
|
+
primaryLan: async () => undefined,
|
|
117
|
+
});
|
|
118
|
+
app = Fastify();
|
|
119
|
+
registerErrorHandler(app);
|
|
120
|
+
registerHostedNodesRoutes(app, { hostedNodes });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(async () => {
|
|
124
|
+
await app.close();
|
|
125
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('answers 400 for an invitation that does not decode, before anything is written or spawned', async () => {
|
|
129
|
+
for (const invitation of ['not an invitation', 42]) {
|
|
130
|
+
const res = await app.inject({ method: 'POST', url: '/api/hosted-nodes', payload: { invitation } });
|
|
131
|
+
expect(res.statusCode).toBe(400);
|
|
132
|
+
expect(res.json()).toMatchObject({ ok: false, error: { code: 'invalid_request' } });
|
|
133
|
+
}
|
|
134
|
+
expect(store.list()).toEqual([]);
|
|
135
|
+
expect(orch.createCalls).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('answers 409 to Retry unless no member of the cadre could be reached', async () => {
|
|
139
|
+
const created = await app.inject({ method: 'POST', url: '/api/hosted-nodes', payload: { invitation: encodedTestInvitation('party-P') } });
|
|
140
|
+
expect(created.statusCode).toBe(201);
|
|
141
|
+
const { id } = (created.json() as { data: { node: HostedNodeView } }).data.node;
|
|
142
|
+
|
|
143
|
+
// Still joining: nothing to retry.
|
|
144
|
+
expect((await app.inject({ method: 'POST', url: `/api/hosted-nodes/${id}/retry` })).statusCode).toBe(409);
|
|
145
|
+
|
|
146
|
+
// Refused by a member (an expired invitation): final.
|
|
147
|
+
const node = store.get(id)!;
|
|
148
|
+
store.put({ ...node, status: 'error', error: 'Cadre invitation refused: expired (invite-spent)', retryable: false });
|
|
149
|
+
const refused = await app.inject({ method: 'POST', url: `/api/hosted-nodes/${id}/retry` });
|
|
150
|
+
expect(refused.statusCode).toBe(409);
|
|
151
|
+
expect(refused.json()).toMatchObject({ ok: false, error: { code: 'invalid_state' } });
|
|
152
|
+
expect(orch.createCalls).toHaveLength(1);
|
|
153
|
+
|
|
154
|
+
// No member reachable: Retry starts it again.
|
|
155
|
+
store.put({ ...node, status: 'error', error: 'No member named in the invitation could be reached.', retryable: true });
|
|
156
|
+
orch.crash(node.dockerId!);
|
|
157
|
+
const retried = await app.inject({ method: 'POST', url: `/api/hosted-nodes/${id}/retry` });
|
|
158
|
+
expect(retried.statusCode).toBe(200);
|
|
159
|
+
expect(retried.json()).toMatchObject({ ok: true, data: { node: { id, status: 'joining' } } });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -150,6 +150,37 @@ describe('/api/settings routes', () => {
|
|
|
150
150
|
expect(body.error.message).toMatch(/uiPort/);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
it('PUT claimAddresses persists it, and refuses a bad lan or address naming the field', async () => {
|
|
154
|
+
writeConfig(dataDir);
|
|
155
|
+
app = Fastify();
|
|
156
|
+
registerErrorHandler(app);
|
|
157
|
+
const settingsStore = new HostSettingsStore({ dataDir });
|
|
158
|
+
registerSettingsRoutes(app, { settingsStore, nat: fakeNat().svc });
|
|
159
|
+
const put = (payload: unknown) => app!.inject({
|
|
160
|
+
method: 'PUT',
|
|
161
|
+
url: '/api/settings',
|
|
162
|
+
headers: { 'content-type': 'application/json' },
|
|
163
|
+
payload: JSON.stringify(payload),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const ok = await put({ claimAddresses: { lan: '192.168.2.27', addrs: [' /dns4/bateman.cc/tcp/51234/ws ', ''] } });
|
|
167
|
+
expect(ok.statusCode).toBe(200);
|
|
168
|
+
expect(settingsStore.read().claimAddresses).toEqual({ lan: '192.168.2.27', addrs: ['/dns4/bateman.cc/tcp/51234/ws'] });
|
|
169
|
+
|
|
170
|
+
for (const [payload, message] of [
|
|
171
|
+
[{ claimAddresses: { lan: 'sometimes' } }, /claimAddresses.lan/],
|
|
172
|
+
[{ claimAddresses: { addrs: ['not a multiaddr'] } }, /claimAddresses.addrs/],
|
|
173
|
+
[{ claimAddresses: { addrs: ['/dns4/a.example/tcp/443/wss/p2p/12D3KooWQ7wMHcR6Z9zr2qmFyKW6pYp1VzJPfgoTHKGdEfMXz2Ve'] } }, /must not end in \/p2p/],
|
|
174
|
+
[{ claimAddresses: { extra: [] } }, /claimAddresses.extra/],
|
|
175
|
+
[{ claimAddresses: 'auto' }, /must be an object/],
|
|
176
|
+
] as const) {
|
|
177
|
+
const res = await put(payload);
|
|
178
|
+
expect(res.statusCode).toBe(400);
|
|
179
|
+
expect((res.json() as { error: { message: string } }).error.message).toMatch(message);
|
|
180
|
+
}
|
|
181
|
+
expect(settingsStore.read().claimAddresses?.lan).toBe('192.168.2.27');
|
|
182
|
+
});
|
|
183
|
+
|
|
153
184
|
it('PUT unknown setting returns 400', async () => {
|
|
154
185
|
writeConfig(dataDir);
|
|
155
186
|
app = Fastify();
|