@openparachute/hub 0.5.7 → 0.5.10-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hub-local key/value settings (hub#268).
|
|
3
|
+
*
|
|
4
|
+
* Covers the bare KV API (get/set/delete) and the two domain helpers
|
|
5
|
+
* the wizard + oauth handlers consume: setup_expose_mode validation and
|
|
6
|
+
* the first-client auto-approve window (open + check + consume + clear).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
16
|
+
MODULE_INSTALL_CHANNELS,
|
|
17
|
+
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
18
|
+
SETUP_EXPOSE_MODES,
|
|
19
|
+
consumeFirstClientAutoApproveWindow,
|
|
20
|
+
deleteSetting,
|
|
21
|
+
getModuleInstallChannel,
|
|
22
|
+
getSetting,
|
|
23
|
+
isFirstClientAutoApproveWindowOpen,
|
|
24
|
+
isModuleInstallChannel,
|
|
25
|
+
isSetupExposeMode,
|
|
26
|
+
openFirstClientAutoApproveWindow,
|
|
27
|
+
setModuleInstallChannel,
|
|
28
|
+
setSetting,
|
|
29
|
+
} from "../hub-settings.ts";
|
|
30
|
+
|
|
31
|
+
describe("hub-settings — bare KV", () => {
|
|
32
|
+
let dir: string;
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
37
|
+
|
|
38
|
+
test("getSetting returns undefined for an absent key", () => {
|
|
39
|
+
const db = openHubDb(hubDbPath(dir));
|
|
40
|
+
try {
|
|
41
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
42
|
+
} finally {
|
|
43
|
+
db.close();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("setSetting writes a value getSetting reads back", () => {
|
|
48
|
+
const db = openHubDb(hubDbPath(dir));
|
|
49
|
+
try {
|
|
50
|
+
setSetting(db, "setup_expose_mode", "tailnet");
|
|
51
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("tailnet");
|
|
52
|
+
} finally {
|
|
53
|
+
db.close();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("setSetting overwrites an existing value (UPSERT)", () => {
|
|
58
|
+
const db = openHubDb(hubDbPath(dir));
|
|
59
|
+
try {
|
|
60
|
+
setSetting(db, "setup_expose_mode", "tailnet");
|
|
61
|
+
setSetting(db, "setup_expose_mode", "public");
|
|
62
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("public");
|
|
63
|
+
} finally {
|
|
64
|
+
db.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("deleteSetting removes a row + idempotently no-ops on a missing key", () => {
|
|
69
|
+
const db = openHubDb(hubDbPath(dir));
|
|
70
|
+
try {
|
|
71
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
72
|
+
deleteSetting(db, "setup_expose_mode");
|
|
73
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
74
|
+
// Second delete is a no-op.
|
|
75
|
+
deleteSetting(db, "setup_expose_mode");
|
|
76
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
77
|
+
} finally {
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("setSetting updates the updated_at column on every write", () => {
|
|
83
|
+
const db = openHubDb(hubDbPath(dir));
|
|
84
|
+
try {
|
|
85
|
+
const t1 = new Date("2026-05-19T00:00:00.000Z");
|
|
86
|
+
const t2 = new Date("2026-05-19T00:01:00.000Z");
|
|
87
|
+
setSetting(db, "setup_expose_mode", "localhost", () => t1);
|
|
88
|
+
setSetting(db, "setup_expose_mode", "localhost", () => t2);
|
|
89
|
+
// Re-write with the same value still bumps updated_at — useful
|
|
90
|
+
// for operational polling that wants to distinguish stale vs
|
|
91
|
+
// fresh state.
|
|
92
|
+
const row = db
|
|
93
|
+
.query<{ updated_at: string }, []>(
|
|
94
|
+
"SELECT updated_at FROM hub_settings WHERE key = 'setup_expose_mode'",
|
|
95
|
+
)
|
|
96
|
+
.get();
|
|
97
|
+
expect(row?.updated_at).toBe(t2.toISOString());
|
|
98
|
+
} finally {
|
|
99
|
+
db.close();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("hub-settings — isSetupExposeMode", () => {
|
|
105
|
+
test("accepts the three canonical values", () => {
|
|
106
|
+
for (const m of SETUP_EXPOSE_MODES) {
|
|
107
|
+
expect(isSetupExposeMode(m)).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rejects anything else (typos, empty, non-string)", () => {
|
|
112
|
+
expect(isSetupExposeMode("local")).toBe(false);
|
|
113
|
+
expect(isSetupExposeMode("LOCALHOST")).toBe(false);
|
|
114
|
+
expect(isSetupExposeMode("")).toBe(false);
|
|
115
|
+
expect(isSetupExposeMode(null)).toBe(false);
|
|
116
|
+
expect(isSetupExposeMode(undefined)).toBe(false);
|
|
117
|
+
expect(isSetupExposeMode(42)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("hub-settings — first-client auto-approve window", () => {
|
|
122
|
+
let dir: string;
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-"));
|
|
125
|
+
});
|
|
126
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
127
|
+
|
|
128
|
+
test("window is closed by default (no row)", () => {
|
|
129
|
+
const db = openHubDb(hubDbPath(dir));
|
|
130
|
+
try {
|
|
131
|
+
expect(isFirstClientAutoApproveWindowOpen(db)).toBe(false);
|
|
132
|
+
} finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("openFirstClientAutoApproveWindow opens a window 60 minutes long", () => {
|
|
138
|
+
const db = openHubDb(hubDbPath(dir));
|
|
139
|
+
try {
|
|
140
|
+
const now = new Date("2026-05-19T00:00:00.000Z");
|
|
141
|
+
openFirstClientAutoApproveWindow(db, () => now);
|
|
142
|
+
const stored = getSetting(db, "pending_first_client_auto_approve_until");
|
|
143
|
+
expect(stored).toBeDefined();
|
|
144
|
+
const parsed = new Date(stored ?? "");
|
|
145
|
+
expect(parsed.getTime() - now.getTime()).toBe(FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS);
|
|
146
|
+
} finally {
|
|
147
|
+
db.close();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("isFirstClientAutoApproveWindowOpen → true within window, false after expiry", () => {
|
|
152
|
+
const db = openHubDb(hubDbPath(dir));
|
|
153
|
+
try {
|
|
154
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
155
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
156
|
+
// 30 minutes in → still open.
|
|
157
|
+
expect(
|
|
158
|
+
isFirstClientAutoApproveWindowOpen(db, () => new Date(t0.getTime() + 30 * 60 * 1000)),
|
|
159
|
+
).toBe(true);
|
|
160
|
+
// 60 minutes + 1 ms in → closed.
|
|
161
|
+
expect(
|
|
162
|
+
isFirstClientAutoApproveWindowOpen(
|
|
163
|
+
db,
|
|
164
|
+
() => new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1),
|
|
165
|
+
),
|
|
166
|
+
).toBe(false);
|
|
167
|
+
} finally {
|
|
168
|
+
db.close();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("consumeFirstClientAutoApproveWindow consumes the window once, then returns false", () => {
|
|
173
|
+
const db = openHubDb(hubDbPath(dir));
|
|
174
|
+
try {
|
|
175
|
+
const now = new Date("2026-05-19T00:00:00.000Z");
|
|
176
|
+
openFirstClientAutoApproveWindow(db, () => now);
|
|
177
|
+
// First call consumes.
|
|
178
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => now)).toBe(true);
|
|
179
|
+
// Second call sees no window.
|
|
180
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => now)).toBe(false);
|
|
181
|
+
// The row is cleared.
|
|
182
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
183
|
+
} finally {
|
|
184
|
+
db.close();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("consume returns false when the window has expired (and clears nothing)", () => {
|
|
189
|
+
const db = openHubDb(hubDbPath(dir));
|
|
190
|
+
try {
|
|
191
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
192
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
193
|
+
const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
|
|
194
|
+
expect(consumeFirstClientAutoApproveWindow(db, () => past)).toBe(false);
|
|
195
|
+
// Row is still there (no implicit cleanup on expiry — the setting
|
|
196
|
+
// just stops being "open"). A future open() resets it.
|
|
197
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
|
|
198
|
+
} finally {
|
|
199
|
+
db.close();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("malformed timestamp string is treated as closed (not parseable → not open)", () => {
|
|
204
|
+
const db = openHubDb(hubDbPath(dir));
|
|
205
|
+
try {
|
|
206
|
+
setSetting(db, "pending_first_client_auto_approve_until", "not-a-date");
|
|
207
|
+
expect(isFirstClientAutoApproveWindowOpen(db)).toBe(false);
|
|
208
|
+
expect(consumeFirstClientAutoApproveWindow(db)).toBe(false);
|
|
209
|
+
} finally {
|
|
210
|
+
db.close();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("reopening the window after consume restarts the 60-minute clock", () => {
|
|
215
|
+
const db = openHubDb(hubDbPath(dir));
|
|
216
|
+
try {
|
|
217
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
218
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
219
|
+
consumeFirstClientAutoApproveWindow(db, () => t0);
|
|
220
|
+
// Re-open at t0 + 90min.
|
|
221
|
+
const t1 = new Date(t0.getTime() + 90 * 60 * 1000);
|
|
222
|
+
openFirstClientAutoApproveWindow(db, () => t1);
|
|
223
|
+
// The new window's expiry is t1 + 60min, not t0 + 60min.
|
|
224
|
+
const stored = getSetting(db, "pending_first_client_auto_approve_until");
|
|
225
|
+
const parsed = new Date(stored ?? "");
|
|
226
|
+
expect(parsed.getTime()).toBe(t1.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS);
|
|
227
|
+
} finally {
|
|
228
|
+
db.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("hub-settings — isModuleInstallChannel", () => {
|
|
234
|
+
test("accepts the two canonical values", () => {
|
|
235
|
+
for (const c of MODULE_INSTALL_CHANNELS) {
|
|
236
|
+
expect(isModuleInstallChannel(c)).toBe(true);
|
|
237
|
+
}
|
|
238
|
+
expect(isModuleInstallChannel("latest")).toBe(true);
|
|
239
|
+
expect(isModuleInstallChannel("rc")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("rejects anything else (typos, empty, non-string, case-mismatch)", () => {
|
|
243
|
+
expect(isModuleInstallChannel("LATEST")).toBe(false);
|
|
244
|
+
expect(isModuleInstallChannel("Latest")).toBe(false);
|
|
245
|
+
expect(isModuleInstallChannel("stable")).toBe(false);
|
|
246
|
+
expect(isModuleInstallChannel("beta")).toBe(false);
|
|
247
|
+
expect(isModuleInstallChannel("")).toBe(false);
|
|
248
|
+
expect(isModuleInstallChannel(null)).toBe(false);
|
|
249
|
+
expect(isModuleInstallChannel(undefined)).toBe(false);
|
|
250
|
+
expect(isModuleInstallChannel(42)).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("hub-settings — module install channel bootstrap", () => {
|
|
255
|
+
let dir: string;
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-channel-"));
|
|
258
|
+
});
|
|
259
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
260
|
+
|
|
261
|
+
test("first read with no env + no row seeds + returns the default (latest)", () => {
|
|
262
|
+
const db = openHubDb(hubDbPath(dir));
|
|
263
|
+
try {
|
|
264
|
+
// Empty env — no PARACHUTE_MODULE_CHANNEL.
|
|
265
|
+
const channel = getModuleInstallChannel(db, { env: {} });
|
|
266
|
+
expect(channel).toBe(DEFAULT_MODULE_INSTALL_CHANNEL);
|
|
267
|
+
expect(channel).toBe("latest");
|
|
268
|
+
// The row is now seeded.
|
|
269
|
+
expect(getSetting(db, "module_install_channel")).toBe("latest");
|
|
270
|
+
} finally {
|
|
271
|
+
db.close();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("first read with PARACHUTE_MODULE_CHANNEL=rc seeds with rc", () => {
|
|
276
|
+
const db = openHubDb(hubDbPath(dir));
|
|
277
|
+
try {
|
|
278
|
+
const channel = getModuleInstallChannel(db, {
|
|
279
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "rc" },
|
|
280
|
+
});
|
|
281
|
+
expect(channel).toBe("rc");
|
|
282
|
+
expect(getSetting(db, "module_install_channel")).toBe("rc");
|
|
283
|
+
} finally {
|
|
284
|
+
db.close();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("first read with PARACHUTE_MODULE_CHANNEL=latest seeds with latest", () => {
|
|
289
|
+
const db = openHubDb(hubDbPath(dir));
|
|
290
|
+
try {
|
|
291
|
+
const channel = getModuleInstallChannel(db, {
|
|
292
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "latest" },
|
|
293
|
+
});
|
|
294
|
+
expect(channel).toBe("latest");
|
|
295
|
+
expect(getSetting(db, "module_install_channel")).toBe("latest");
|
|
296
|
+
} finally {
|
|
297
|
+
db.close();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("invalid PARACHUTE_MODULE_CHANNEL warns + falls back to latest", () => {
|
|
302
|
+
const db = openHubDb(hubDbPath(dir));
|
|
303
|
+
try {
|
|
304
|
+
const warns: string[] = [];
|
|
305
|
+
const channel = getModuleInstallChannel(db, {
|
|
306
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "stable" },
|
|
307
|
+
warn: (msg) => warns.push(msg),
|
|
308
|
+
});
|
|
309
|
+
expect(channel).toBe("latest");
|
|
310
|
+
expect(getSetting(db, "module_install_channel")).toBe("latest");
|
|
311
|
+
expect(warns.length).toBe(1);
|
|
312
|
+
expect(warns[0]).toMatch(/PARACHUTE_MODULE_CHANNEL="stable"/);
|
|
313
|
+
expect(warns[0]).toMatch(/not a valid channel/);
|
|
314
|
+
} finally {
|
|
315
|
+
db.close();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("empty PARACHUTE_MODULE_CHANNEL is treated as unset (no warn)", () => {
|
|
320
|
+
const db = openHubDb(hubDbPath(dir));
|
|
321
|
+
try {
|
|
322
|
+
const warns: string[] = [];
|
|
323
|
+
const channel = getModuleInstallChannel(db, {
|
|
324
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "" },
|
|
325
|
+
warn: (msg) => warns.push(msg),
|
|
326
|
+
});
|
|
327
|
+
expect(channel).toBe("latest");
|
|
328
|
+
expect(warns).toEqual([]);
|
|
329
|
+
} finally {
|
|
330
|
+
db.close();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("once seeded, env var is ignored on subsequent reads", () => {
|
|
335
|
+
const db = openHubDb(hubDbPath(dir));
|
|
336
|
+
try {
|
|
337
|
+
// First read seeds with rc.
|
|
338
|
+
getModuleInstallChannel(db, { env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "rc" } });
|
|
339
|
+
// Second read with a different env value still returns the seeded value.
|
|
340
|
+
const channel = getModuleInstallChannel(db, {
|
|
341
|
+
env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "latest" },
|
|
342
|
+
});
|
|
343
|
+
expect(channel).toBe("rc");
|
|
344
|
+
// And with no env at all.
|
|
345
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("rc");
|
|
346
|
+
} finally {
|
|
347
|
+
db.close();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("setModuleInstallChannel persists the new value, subsequent reads return it", () => {
|
|
352
|
+
const db = openHubDb(hubDbPath(dir));
|
|
353
|
+
try {
|
|
354
|
+
// Seed with rc via env.
|
|
355
|
+
getModuleInstallChannel(db, { env: { [PARACHUTE_MODULE_CHANNEL_ENV]: "rc" } });
|
|
356
|
+
// Admin toggles to latest.
|
|
357
|
+
setModuleInstallChannel(db, "latest");
|
|
358
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("latest");
|
|
359
|
+
// And back to rc — no env needed.
|
|
360
|
+
setModuleInstallChannel(db, "rc");
|
|
361
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("rc");
|
|
362
|
+
} finally {
|
|
363
|
+
db.close();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("corrupted row falls back to latest silently (no throw)", () => {
|
|
368
|
+
const db = openHubDb(hubDbPath(dir));
|
|
369
|
+
try {
|
|
370
|
+
// Simulate a manual sqlite edit / schema drift / external write.
|
|
371
|
+
setSetting(db, "module_install_channel", "bogus");
|
|
372
|
+
expect(getModuleInstallChannel(db, { env: {} })).toBe("latest");
|
|
373
|
+
} finally {
|
|
374
|
+
db.close();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -13,10 +13,9 @@ describe("renderHub", () => {
|
|
|
13
13
|
expect(html).toContain("<script>");
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
test("fetches /.well-known/parachute.json
|
|
16
|
+
test("fetches /.well-known/parachute.json for the Use section", () => {
|
|
17
17
|
expect(html).toContain("/.well-known/parachute.json");
|
|
18
18
|
expect(html).toContain("doc.services");
|
|
19
|
-
expect(html).toContain("doc.vaults");
|
|
20
19
|
});
|
|
21
20
|
|
|
22
21
|
test("uses parachute.computer sage palette and serif/sans fonts", () => {
|
|
@@ -30,79 +29,150 @@ describe("renderHub", () => {
|
|
|
30
29
|
expect(html).toContain("prefers-color-scheme: dark");
|
|
31
30
|
});
|
|
32
31
|
|
|
33
|
-
test("
|
|
34
|
-
expect(html).toContain("
|
|
32
|
+
test("renders two sections: Services and Admin, each with its own heading + grid", () => {
|
|
33
|
+
expect(html).toContain('id="services-section"');
|
|
34
|
+
expect(html).toContain('id="admin-section"');
|
|
35
|
+
expect(html).toContain('id="services-grid"');
|
|
36
|
+
expect(html).toContain('id="admin-grid"');
|
|
37
|
+
expect(html).toContain("<h2>Services</h2>");
|
|
38
|
+
expect(html).toContain("<h2>Admin</h2>");
|
|
35
39
|
});
|
|
36
40
|
|
|
37
|
-
test("
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
test("Services section sub-text frames the section as service-owned surfaces", () => {
|
|
42
|
+
// Services-vs-Admin axis is OWNERSHIP (services own their UIs vs
|
|
43
|
+
// hub owns host admin), not function. The sub-text reflects that —
|
|
44
|
+
// earlier "Browse, transcribe, and run" framing put it on the
|
|
45
|
+
// function axis, which broke down once you noticed services have
|
|
46
|
+
// UIs that mix use / config / admin.
|
|
47
|
+
expect(html).toContain("Surfaces provided by services");
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
test("
|
|
44
|
-
|
|
50
|
+
test("Services tiles are data-driven from each service's uiUrl + displayName", () => {
|
|
51
|
+
// Phase D consumer-side: SERVICE_LABELS / SERVICE_ORDER hardcoding
|
|
52
|
+
// retired. Each well-known services[] row carries displayName + uiUrl
|
|
53
|
+
// (sourced from module.json via hub-server's loadServiceUiMetadata);
|
|
54
|
+
// tiles render directly from those.
|
|
55
|
+
expect(html).toContain("svc.uiUrl");
|
|
56
|
+
expect(html).toContain("svc.displayName");
|
|
57
|
+
// No more hardcoded short→label map.
|
|
58
|
+
expect(html).not.toContain("'Notes', desc:");
|
|
59
|
+
expect(html).not.toContain("['notes', 'scribe', 'agent']");
|
|
45
60
|
});
|
|
46
61
|
|
|
47
|
-
test("
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
expect(html).toContain("
|
|
62
|
+
test("Services skip rule emerges from data, not name-checks (vault has no uiUrl)", () => {
|
|
63
|
+
// The previous `isVaultName` hardcoded skip is gone — vault doesn't
|
|
64
|
+
// declare uiUrl, so it naturally doesn't render. Other API-only
|
|
65
|
+
// modules (current or future) get the same treatment for free.
|
|
66
|
+
expect(html).toContain("if (!svc || !svc.uiUrl) continue;");
|
|
67
|
+
// The function definition is gone (the comment may still mention the
|
|
68
|
+
// name as historical context — we only care about the active code).
|
|
69
|
+
expect(html).not.toContain("function isVaultName");
|
|
55
70
|
});
|
|
56
71
|
|
|
57
|
-
test("
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
expect(html).toContain("
|
|
72
|
+
test("Services tiles sort alphabetically by displayName", () => {
|
|
73
|
+
// Per the module-json-extensibility pattern doc — default ordering
|
|
74
|
+
// until a `displayOrder` field surfaces. localeCompare keeps the
|
|
75
|
+
// ordering stable across locales for ASCII labels.
|
|
76
|
+
expect(html).toContain("a.title.localeCompare(b.title)");
|
|
62
77
|
});
|
|
63
78
|
|
|
64
|
-
test("
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
expect(html).
|
|
68
|
-
expect(html).toContain("
|
|
79
|
+
test("Admin section is hardcoded (always visible) with three entries", () => {
|
|
80
|
+
expect(html).toContain("ADMIN_ENTRIES");
|
|
81
|
+
expect(html).toContain("/admin/vaults");
|
|
82
|
+
expect(html).toContain("/admin/permissions");
|
|
83
|
+
expect(html).toContain("/admin/tokens");
|
|
69
84
|
});
|
|
70
85
|
|
|
71
|
-
test("
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
expect(html).toContain("
|
|
75
|
-
expect(html).toContain("
|
|
86
|
+
test("Admin section renders synchronously (does not depend on the well-known fetch)", () => {
|
|
87
|
+
// Even if the fetch is slow or fails, the operator should see Admin
|
|
88
|
+
// surfaces — they may be the reason the operator landed on /.
|
|
89
|
+
expect(html).toContain("renderAdmin();");
|
|
90
|
+
expect(html).toContain("Admin section is static");
|
|
76
91
|
});
|
|
77
92
|
|
|
78
|
-
test("
|
|
79
|
-
// Phase
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
expect(html).toContain("
|
|
84
|
-
expect(html).toContain("
|
|
93
|
+
test("Services section empty state guides operators to declare uiUrl", () => {
|
|
94
|
+
// Empty state under Phase D means "no installed service has declared
|
|
95
|
+
// uiUrl yet" — different shape from pre-D ("none installed at all").
|
|
96
|
+
// Hint points at the pattern doc since the fix is in module.json,
|
|
97
|
+
// not at install time.
|
|
98
|
+
expect(html).toContain("No services with a UI declared yet");
|
|
99
|
+
expect(html).toContain("module-json-extensibility");
|
|
85
100
|
});
|
|
86
101
|
|
|
87
|
-
test("
|
|
88
|
-
expect(html).toContain("
|
|
89
|
-
expect(html).toContain("parachute install vault");
|
|
102
|
+
test("Use section error state surfaces the underlying message", () => {
|
|
103
|
+
expect(html).toContain("Could not load services");
|
|
90
104
|
});
|
|
91
105
|
|
|
92
|
-
test("
|
|
93
|
-
|
|
106
|
+
test("discovery page fetches with cache: 'no-store' (hub#268 Item 1)", () => {
|
|
107
|
+
// Without `cache: 'no-store'` the browser's HTTP cache can return
|
|
108
|
+
// a stale services list when the operator clicks back to / after
|
|
109
|
+
// installing a module via /admin/modules. Server-side also sets
|
|
110
|
+
// cache-control: no-store on the well-known doc.
|
|
111
|
+
expect(html).toContain("cache: 'no-store'");
|
|
94
112
|
});
|
|
95
113
|
|
|
96
|
-
test("
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
114
|
+
test("discovery page re-fetches on bfcache restore via pageshow (hub#268 Item 1)", () => {
|
|
115
|
+
// When the operator clicks back from /admin/modules to / the
|
|
116
|
+
// browser may restore the prior DOM without re-running the IIFE.
|
|
117
|
+
// The pageshow handler re-runs loadServices() when the page was
|
|
118
|
+
// restored from cache (`e.persisted === true`).
|
|
119
|
+
expect(html).toContain("addEventListener('pageshow'");
|
|
120
|
+
expect(html).toContain("e.persisted");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("does not retain the old aggregate-by-module-type code", () => {
|
|
124
|
+
// The Vault collapse + per-module aggregation pattern is gone — Use
|
|
125
|
+
// entries are direct service-path → label lookups; Admin is hardcoded.
|
|
126
|
+
expect(html).not.toContain("aggregate(services, vaults)");
|
|
127
|
+
expect(html).not.toContain("MODULE_LABELS");
|
|
101
128
|
expect(html).not.toContain("renderConfigField");
|
|
102
|
-
expect(html).not.toContain("fetchConfig");
|
|
103
129
|
expect(html).not.toContain("kind-badge");
|
|
104
|
-
|
|
105
|
-
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("default render (no session) emits the 'Sign in' affordance", () => {
|
|
133
|
+
expect(html).toContain('class="auth-indicator"');
|
|
134
|
+
expect(html).toContain("Sign in");
|
|
135
|
+
expect(html).toContain('href="/login?next=/"');
|
|
136
|
+
// No POST form, no CSRF input — those only appear when signed in.
|
|
137
|
+
expect(html).not.toContain('action="/logout"');
|
|
138
|
+
expect(html).not.toContain("__csrf");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("renderHub — signed-in indicator (rc.13)", () => {
|
|
143
|
+
test("session user → 'Signed in as <name>' + inline POST form with CSRF", () => {
|
|
144
|
+
const html = renderHub({
|
|
145
|
+
session: { displayName: "aaron", csrfToken: "csrf-token-xyz" },
|
|
146
|
+
});
|
|
147
|
+
expect(html).toContain('class="auth-indicator"');
|
|
148
|
+
expect(html).toContain("Signed in as");
|
|
149
|
+
expect(html).toContain("aaron");
|
|
150
|
+
// Inline POST form for sign-out — CSRF token embedded as the
|
|
151
|
+
// existing `__csrf` field name (matches /logout's expectations).
|
|
152
|
+
expect(html).toContain('method="POST" action="/logout"');
|
|
153
|
+
expect(html).toContain('name="__csrf"');
|
|
154
|
+
expect(html).toContain('value="csrf-token-xyz"');
|
|
155
|
+
expect(html).toContain("Sign out");
|
|
156
|
+
// No "Sign in" affordance when signed in.
|
|
157
|
+
expect(html).not.toContain('href="/login?next=/"');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("displayName with HTML special chars is escaped", () => {
|
|
161
|
+
// Username field allows alphanumerics historically, but the
|
|
162
|
+
// displayName field on the wire is forward-compatible with profile
|
|
163
|
+
// names that may contain &, <, >. Escape at render time.
|
|
164
|
+
const html = renderHub({
|
|
165
|
+
session: { displayName: "<aaron>&friends", csrfToken: "tok" },
|
|
166
|
+
});
|
|
167
|
+
expect(html).toContain("<aaron>&friends");
|
|
168
|
+
expect(html).not.toContain("<aaron>&friends");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("CSRF token with HTML special chars is escaped in the value attribute", () => {
|
|
172
|
+
const html = renderHub({
|
|
173
|
+
session: { displayName: "aaron", csrfToken: 'token"with"quotes' },
|
|
174
|
+
});
|
|
175
|
+
expect(html).toContain('value="token"with"quotes"');
|
|
106
176
|
});
|
|
107
177
|
});
|
|
108
178
|
|