@openparachute/hub 0.5.13 → 0.5.14-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -151,7 +151,7 @@ describe("openHubDb + migrate", () => {
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test("v8
|
|
154
|
+
test("v8 added password_changed column (still present at v10)", () => {
|
|
155
155
|
const h = makeHarness();
|
|
156
156
|
try {
|
|
157
157
|
const db = openHubDb(h.dbPath);
|
|
@@ -160,8 +160,9 @@ describe("openHubDb + migrate", () => {
|
|
|
160
160
|
db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
|
|
161
161
|
).map((r) => r.version);
|
|
162
162
|
expect(versions).toContain(8);
|
|
163
|
-
// PRAGMA table_info returns the column shape;
|
|
164
|
-
//
|
|
163
|
+
// PRAGMA table_info returns the column shape; password_changed
|
|
164
|
+
// should still be on users at v10 (only assigned_vault was
|
|
165
|
+
// dropped in v10's recreate).
|
|
165
166
|
interface ColInfo {
|
|
166
167
|
name: string;
|
|
167
168
|
type: string;
|
|
@@ -180,10 +181,8 @@ describe("openHubDb + migrate", () => {
|
|
|
180
181
|
expect(pc?.notnull).toBe(1);
|
|
181
182
|
// Default literal — SQLite returns it as a string "0".
|
|
182
183
|
expect(pc?.dflt_value).toBe("0");
|
|
183
|
-
|
|
184
|
-
expect(
|
|
185
|
-
expect(av?.type).toBe("TEXT");
|
|
186
|
-
expect(av?.notnull).toBe(0);
|
|
184
|
+
// v10 dropped assigned_vault — verify the column is gone.
|
|
185
|
+
expect(byName.has("assigned_vault")).toBe(false);
|
|
187
186
|
} finally {
|
|
188
187
|
db.close();
|
|
189
188
|
}
|
|
@@ -195,21 +194,13 @@ describe("openHubDb + migrate", () => {
|
|
|
195
194
|
test("v8 backfills password_changed=1 for users that pre-date the migration", () => {
|
|
196
195
|
const h = makeHarness();
|
|
197
196
|
try {
|
|
198
|
-
// Stand up a DB at the v7 state by
|
|
199
|
-
//
|
|
200
|
-
// would be invasive. Instead, drive the same migration shape by hand
|
|
201
|
-
// for v1-v7 then insert a row, then call migrate() to apply v8.
|
|
202
|
-
// Cleanest path: openHubDb runs everything, but we want a v7 snapshot.
|
|
203
|
-
// Approach: open with openHubDb (runs all migrations), drop the v8
|
|
204
|
-
// changes, mark v8 unapplied, insert a user with password_changed=0
|
|
205
|
-
// (simulating a row from before the backfill), then re-run migrate.
|
|
206
|
-
// SQLite doesn't have DROP COLUMN pre-3.35 universally, so we do the
|
|
207
|
-
// recreate-and-rename: drop v8's columns by recreating users without
|
|
208
|
-
// them, then delete the v8 schema_version row, then call migrate().
|
|
197
|
+
// Stand up a DB at the v7 state by recreating the users table
|
|
198
|
+
// without the v8/v10 columns and re-running migrate().
|
|
209
199
|
const db = openHubDb(h.dbPath);
|
|
210
200
|
try {
|
|
211
201
|
// Build a v7-shape users table and copy the v8-shape rows.
|
|
212
202
|
db.exec(`
|
|
203
|
+
DROP TABLE IF EXISTS user_vaults;
|
|
213
204
|
CREATE TABLE users_v7 (
|
|
214
205
|
id TEXT PRIMARY KEY,
|
|
215
206
|
username TEXT UNIQUE NOT NULL,
|
|
@@ -222,26 +213,26 @@ describe("openHubDb + migrate", () => {
|
|
|
222
213
|
DROP TABLE users;
|
|
223
214
|
ALTER TABLE users_v7 RENAME TO users;
|
|
224
215
|
`);
|
|
225
|
-
db.exec("DELETE FROM schema_version WHERE version
|
|
216
|
+
db.exec("DELETE FROM schema_version WHERE version IN (8, 10)");
|
|
226
217
|
// Insert a row that pre-dates v8 (no password_changed column yet).
|
|
227
218
|
db.prepare(
|
|
228
219
|
`INSERT INTO users (id, username, password_hash, created_at, updated_at)
|
|
229
220
|
VALUES (?, ?, ?, ?, ?)`,
|
|
230
221
|
).run("legacy-user", "owner", "h", "2026-01-01", "2026-01-01");
|
|
231
|
-
// Now re-run migrations — v8
|
|
222
|
+
// Now re-run migrations — v8 + v10 apply.
|
|
232
223
|
migrate(db);
|
|
233
224
|
const row = db
|
|
234
|
-
.query<{ password_changed: number
|
|
235
|
-
"SELECT password_changed
|
|
225
|
+
.query<{ password_changed: number }, [string]>(
|
|
226
|
+
"SELECT password_changed FROM users WHERE id = ?",
|
|
236
227
|
)
|
|
237
228
|
.get("legacy-user");
|
|
238
229
|
expect(row).not.toBeNull();
|
|
239
230
|
expect(row?.password_changed).toBe(1);
|
|
240
|
-
expect(row?.assigned_vault).toBeNull();
|
|
241
231
|
const versions = (
|
|
242
232
|
db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
|
|
243
233
|
).map((r) => r.version);
|
|
244
234
|
expect(versions).toContain(8);
|
|
235
|
+
expect(versions).toContain(10);
|
|
245
236
|
} finally {
|
|
246
237
|
db.close();
|
|
247
238
|
}
|
|
@@ -250,24 +241,198 @@ describe("openHubDb + migrate", () => {
|
|
|
250
241
|
}
|
|
251
242
|
});
|
|
252
243
|
|
|
253
|
-
test("v8 — fresh inserts default password_changed=0
|
|
244
|
+
test("v8 — fresh inserts default password_changed=0 (v10 dropped assigned_vault)", () => {
|
|
254
245
|
const h = makeHarness();
|
|
255
246
|
try {
|
|
256
247
|
const db = openHubDb(h.dbPath);
|
|
257
248
|
try {
|
|
258
|
-
// Insert via the bare-columns SQL
|
|
259
|
-
// would emit) to confirm the column DEFAULTs work.
|
|
249
|
+
// Insert via the bare-columns SQL to confirm the column DEFAULTs work.
|
|
260
250
|
db.prepare(
|
|
261
251
|
`INSERT INTO users (id, username, password_hash, created_at, updated_at)
|
|
262
252
|
VALUES (?, ?, ?, ?, ?)`,
|
|
263
253
|
).run("u-default", "owner", "h", "2026-01-01", "2026-01-01");
|
|
264
254
|
const row = db
|
|
265
|
-
.query<{ password_changed: number
|
|
266
|
-
"SELECT password_changed
|
|
255
|
+
.query<{ password_changed: number }, [string]>(
|
|
256
|
+
"SELECT password_changed FROM users WHERE id = ?",
|
|
267
257
|
)
|
|
268
258
|
.get("u-default");
|
|
269
259
|
expect(row?.password_changed).toBe(0);
|
|
270
|
-
|
|
260
|
+
// user_vaults table is empty for a default insert.
|
|
261
|
+
const vaultCount = db
|
|
262
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
|
|
263
|
+
.get("u-default");
|
|
264
|
+
expect(vaultCount?.n).toBe(0);
|
|
265
|
+
} finally {
|
|
266
|
+
db.close();
|
|
267
|
+
}
|
|
268
|
+
} finally {
|
|
269
|
+
h.cleanup();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// v10 — user_vaults many-to-many membership (multi-user Phase 2 PR 2)
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
test("v10 creates user_vaults table with the expected shape", () => {
|
|
278
|
+
const h = makeHarness();
|
|
279
|
+
try {
|
|
280
|
+
const db = openHubDb(h.dbPath);
|
|
281
|
+
try {
|
|
282
|
+
const versions = (
|
|
283
|
+
db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
|
|
284
|
+
).map((r) => r.version);
|
|
285
|
+
expect(versions).toContain(10);
|
|
286
|
+
const tables = (
|
|
287
|
+
db
|
|
288
|
+
.query<{ name: string }, []>(
|
|
289
|
+
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
|
|
290
|
+
)
|
|
291
|
+
.all() ?? []
|
|
292
|
+
).map((r) => r.name);
|
|
293
|
+
expect(tables).toContain("user_vaults");
|
|
294
|
+
interface ColInfo {
|
|
295
|
+
name: string;
|
|
296
|
+
type: string;
|
|
297
|
+
notnull: number;
|
|
298
|
+
dflt_value: string | null;
|
|
299
|
+
}
|
|
300
|
+
const cols = db
|
|
301
|
+
.query<ColInfo, []>(
|
|
302
|
+
"SELECT name, type, \"notnull\", dflt_value FROM pragma_table_info('user_vaults')",
|
|
303
|
+
)
|
|
304
|
+
.all();
|
|
305
|
+
const names = cols.map((c) => c.name);
|
|
306
|
+
expect(names).toContain("user_id");
|
|
307
|
+
expect(names).toContain("vault_name");
|
|
308
|
+
expect(names).toContain("role");
|
|
309
|
+
expect(names).toContain("created_at");
|
|
310
|
+
const role = cols.find((c) => c.name === "role");
|
|
311
|
+
expect(role?.notnull).toBe(1);
|
|
312
|
+
// SQLite represents the default literal verbatim — `'write'`.
|
|
313
|
+
expect(role?.dflt_value).toBe("'write'");
|
|
314
|
+
} finally {
|
|
315
|
+
db.close();
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
h.cleanup();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("v10 backfills user_vaults from v9 assigned_vault column", () => {
|
|
323
|
+
const h = makeHarness();
|
|
324
|
+
try {
|
|
325
|
+
const db = openHubDb(h.dbPath);
|
|
326
|
+
try {
|
|
327
|
+
// Rebuild a v9-shape users table (with assigned_vault column),
|
|
328
|
+
// mark v10 unapplied, drop user_vaults, populate fixture rows,
|
|
329
|
+
// then re-run migrate to apply v10's backfill.
|
|
330
|
+
db.exec(`
|
|
331
|
+
DROP TABLE IF EXISTS user_vaults;
|
|
332
|
+
CREATE TABLE users_v9 (
|
|
333
|
+
id TEXT PRIMARY KEY,
|
|
334
|
+
username TEXT UNIQUE NOT NULL,
|
|
335
|
+
password_hash TEXT NOT NULL,
|
|
336
|
+
created_at TEXT NOT NULL,
|
|
337
|
+
updated_at TEXT NOT NULL,
|
|
338
|
+
password_changed INTEGER NOT NULL DEFAULT 0,
|
|
339
|
+
assigned_vault TEXT
|
|
340
|
+
);
|
|
341
|
+
INSERT INTO users_v9 (id, username, password_hash, created_at, updated_at, password_changed, assigned_vault)
|
|
342
|
+
VALUES
|
|
343
|
+
('u-admin', 'admin', 'h', '2026-01-01', '2026-01-01', 1, NULL),
|
|
344
|
+
('u-alice', 'alice', 'h', '2026-01-02', '2026-01-02', 1, 'personal'),
|
|
345
|
+
('u-bob', 'bob', 'h', '2026-01-03', '2026-01-03', 1, 'family');
|
|
346
|
+
DROP TABLE users;
|
|
347
|
+
ALTER TABLE users_v9 RENAME TO users;
|
|
348
|
+
`);
|
|
349
|
+
db.exec("DELETE FROM schema_version WHERE version = 10");
|
|
350
|
+
migrate(db);
|
|
351
|
+
// Expect 2 rows in user_vaults (admin had NULL → no row).
|
|
352
|
+
const rows = db
|
|
353
|
+
.query<{ user_id: string; vault_name: string; role: string }, []>(
|
|
354
|
+
"SELECT user_id, vault_name, role FROM user_vaults ORDER BY user_id ASC",
|
|
355
|
+
)
|
|
356
|
+
.all();
|
|
357
|
+
expect(rows.length).toBe(2);
|
|
358
|
+
expect(rows[0]).toMatchObject({
|
|
359
|
+
user_id: "u-alice",
|
|
360
|
+
vault_name: "personal",
|
|
361
|
+
role: "write",
|
|
362
|
+
});
|
|
363
|
+
expect(rows[1]).toMatchObject({ user_id: "u-bob", vault_name: "family", role: "write" });
|
|
364
|
+
// No row for the admin.
|
|
365
|
+
const adminRows = db
|
|
366
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
|
|
367
|
+
.get("u-admin");
|
|
368
|
+
expect(adminRows?.n).toBe(0);
|
|
369
|
+
// assigned_vault column should be gone.
|
|
370
|
+
interface ColInfo {
|
|
371
|
+
name: string;
|
|
372
|
+
}
|
|
373
|
+
const cols = db.query<ColInfo, []>("SELECT name FROM pragma_table_info('users')").all();
|
|
374
|
+
expect(cols.map((c) => c.name)).not.toContain("assigned_vault");
|
|
375
|
+
} finally {
|
|
376
|
+
db.close();
|
|
377
|
+
}
|
|
378
|
+
} finally {
|
|
379
|
+
h.cleanup();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("v10 FK cascade: deleting a user drops their user_vaults rows", () => {
|
|
384
|
+
const h = makeHarness();
|
|
385
|
+
try {
|
|
386
|
+
const db = openHubDb(h.dbPath);
|
|
387
|
+
try {
|
|
388
|
+
const stamp = "2026-05-27T00:00:00.000Z";
|
|
389
|
+
db.prepare(
|
|
390
|
+
"INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, ?)",
|
|
391
|
+
).run("u1", "alice", "h", stamp, stamp, 1);
|
|
392
|
+
db.prepare(
|
|
393
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
|
|
394
|
+
).run("u1", "personal", "write", stamp);
|
|
395
|
+
db.prepare(
|
|
396
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
|
|
397
|
+
).run("u1", "family", "write", stamp);
|
|
398
|
+
// sanity
|
|
399
|
+
const before = db
|
|
400
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
|
|
401
|
+
.get("u1");
|
|
402
|
+
expect(before?.n).toBe(2);
|
|
403
|
+
// Delete the user — ON DELETE CASCADE should drop the user_vaults rows.
|
|
404
|
+
db.prepare("DELETE FROM users WHERE id = ?").run("u1");
|
|
405
|
+
const after = db
|
|
406
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
|
|
407
|
+
.get("u1");
|
|
408
|
+
expect(after?.n).toBe(0);
|
|
409
|
+
} finally {
|
|
410
|
+
db.close();
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
h.cleanup();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("v10 (user_id, vault_name) PRIMARY KEY blocks duplicate (user, vault) pairs", () => {
|
|
418
|
+
const h = makeHarness();
|
|
419
|
+
try {
|
|
420
|
+
const db = openHubDb(h.dbPath);
|
|
421
|
+
try {
|
|
422
|
+
const stamp = "2026-05-27T00:00:00.000Z";
|
|
423
|
+
db.prepare(
|
|
424
|
+
"INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, ?)",
|
|
425
|
+
).run("u1", "alice", "h", stamp, stamp, 1);
|
|
426
|
+
db.prepare(
|
|
427
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
|
|
428
|
+
).run("u1", "personal", "write", stamp);
|
|
429
|
+
expect(() =>
|
|
430
|
+
db
|
|
431
|
+
.prepare(
|
|
432
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
|
|
433
|
+
)
|
|
434
|
+
.run("u1", "personal", "write", stamp),
|
|
435
|
+
).toThrow();
|
|
271
436
|
} finally {
|
|
272
437
|
db.close();
|
|
273
438
|
}
|
|
@@ -995,22 +995,22 @@ describe("hubFetch routing", () => {
|
|
|
995
995
|
});
|
|
996
996
|
|
|
997
997
|
// Notes-as-app migration Phase 2 (parachute-app design doc §16).
|
|
998
|
-
// `/notes/*` 301-redirects to `/
|
|
998
|
+
// `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land
|
|
999
999
|
// on the apps-hosted Notes. Tested with no DB (the migration-default
|
|
1000
1000
|
// path — absent DB or absent row means redirect-on).
|
|
1001
|
-
test("301: /notes/ → /
|
|
1001
|
+
test("301: /notes/ → /surface/notes/", async () => {
|
|
1002
1002
|
clearNotesRedirectLogState();
|
|
1003
1003
|
const h = makeHarness();
|
|
1004
1004
|
try {
|
|
1005
1005
|
const res = await hubFetch(h.dir)(req("/notes/"));
|
|
1006
1006
|
expect(res.status).toBe(301);
|
|
1007
|
-
expect(res.headers.get("location")).toBe("/
|
|
1007
|
+
expect(res.headers.get("location")).toBe("/surface/notes/");
|
|
1008
1008
|
} finally {
|
|
1009
1009
|
h.cleanup();
|
|
1010
1010
|
}
|
|
1011
1011
|
});
|
|
1012
1012
|
|
|
1013
|
-
test("301: bare /notes → /
|
|
1013
|
+
test("301: bare /notes → /surface/notes", async () => {
|
|
1014
1014
|
// The bare-prefix form (no trailing slash) is the path browsers land
|
|
1015
1015
|
// on when an operator types `https://hub.example/notes` directly.
|
|
1016
1016
|
clearNotesRedirectLogState();
|
|
@@ -1018,7 +1018,7 @@ describe("hubFetch routing", () => {
|
|
|
1018
1018
|
try {
|
|
1019
1019
|
const res = await hubFetch(h.dir)(req("/notes"));
|
|
1020
1020
|
expect(res.status).toBe(301);
|
|
1021
|
-
expect(res.headers.get("location")).toBe("/
|
|
1021
|
+
expect(res.headers.get("location")).toBe("/surface/notes");
|
|
1022
1022
|
} finally {
|
|
1023
1023
|
h.cleanup();
|
|
1024
1024
|
}
|
|
@@ -1030,7 +1030,7 @@ describe("hubFetch routing", () => {
|
|
|
1030
1030
|
try {
|
|
1031
1031
|
const res = await hubFetch(h.dir)(req("/notes/some/path?q=1&n=2"));
|
|
1032
1032
|
expect(res.status).toBe(301);
|
|
1033
|
-
expect(res.headers.get("location")).toBe("/
|
|
1033
|
+
expect(res.headers.get("location")).toBe("/surface/notes/some/path?q=1&n=2");
|
|
1034
1034
|
} finally {
|
|
1035
1035
|
h.cleanup();
|
|
1036
1036
|
}
|
|
@@ -2240,7 +2240,7 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2240
2240
|
// motivator for the `--mount` strip in notes-serve.ts).
|
|
2241
2241
|
//
|
|
2242
2242
|
// Post-parachute-app §16 Phase 2 the `/notes/*` path 301-redirects to
|
|
2243
|
-
// `/
|
|
2243
|
+
// `/surface/notes/*` by default. This test pins the notes-as-module legacy
|
|
2244
2244
|
// path (notes-daemon still serving its own mount); set the opt-out
|
|
2245
2245
|
// flag so the dispatch falls through to the generic proxy.
|
|
2246
2246
|
const h = makeHarness();
|
|
@@ -3453,7 +3453,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3453
3453
|
// Pins the proxy-side wiring of the chrome strip from
|
|
3454
3454
|
// `parachute-patterns/patterns/design-system.md` §7 — every proxied
|
|
3455
3455
|
// text/html response gets the strip injected after the first `<body>`,
|
|
3456
|
-
// with opt-outs for `/
|
|
3456
|
+
// with opt-outs for `/surface/notes/*` (the Notes PWA owns its own chrome).
|
|
3457
3457
|
// The pure rewrite + opt-out logic is covered in chrome-strip.test.ts;
|
|
3458
3458
|
// here we exercise the dispatch integration end-to-end through hubFetch.
|
|
3459
3459
|
|
|
@@ -3608,7 +3608,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3608
3608
|
}
|
|
3609
3609
|
});
|
|
3610
3610
|
|
|
3611
|
-
test("does NOT inject chrome on /
|
|
3611
|
+
test("does NOT inject chrome on /surface/notes/* (Notes PWA owns its own chrome)", async () => {
|
|
3612
3612
|
const h = makeHarness();
|
|
3613
3613
|
const upstream = startHtmlUpstream("<html><body><h1>Notes</h1></body></html>");
|
|
3614
3614
|
try {
|
|
@@ -3616,10 +3616,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3616
3616
|
{
|
|
3617
3617
|
services: [
|
|
3618
3618
|
{
|
|
3619
|
-
name: "parachute-
|
|
3619
|
+
name: "parachute-surface",
|
|
3620
3620
|
port: upstream.port,
|
|
3621
|
-
paths: ["/
|
|
3622
|
-
health: "/
|
|
3621
|
+
paths: ["/surface"],
|
|
3622
|
+
health: "/surface/health",
|
|
3623
3623
|
version: "0.1.0",
|
|
3624
3624
|
},
|
|
3625
3625
|
],
|
|
@@ -3627,7 +3627,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3627
3627
|
h.manifestPath,
|
|
3628
3628
|
);
|
|
3629
3629
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3630
|
-
const res = await fetcher(req("/
|
|
3630
|
+
const res = await fetcher(req("/surface/notes/"));
|
|
3631
3631
|
expect(res.status).toBe(200);
|
|
3632
3632
|
const body = await res.text();
|
|
3633
3633
|
expect(body).toBe("<html><body><h1>Notes</h1></body></html>");
|
|
@@ -3638,7 +3638,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3638
3638
|
}
|
|
3639
3639
|
});
|
|
3640
3640
|
|
|
3641
|
-
test("DOES inject chrome on /
|
|
3641
|
+
test("DOES inject chrome on /surface/admin/* (parachute-app admin, not Notes)", async () => {
|
|
3642
3642
|
const h = makeHarness();
|
|
3643
3643
|
const upstream = startHtmlUpstream("<html><body>app admin</body></html>");
|
|
3644
3644
|
try {
|
|
@@ -3646,10 +3646,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3646
3646
|
{
|
|
3647
3647
|
services: [
|
|
3648
3648
|
{
|
|
3649
|
-
name: "parachute-
|
|
3649
|
+
name: "parachute-surface",
|
|
3650
3650
|
port: upstream.port,
|
|
3651
|
-
paths: ["/
|
|
3652
|
-
health: "/
|
|
3651
|
+
paths: ["/surface"],
|
|
3652
|
+
health: "/surface/health",
|
|
3653
3653
|
version: "0.1.0",
|
|
3654
3654
|
},
|
|
3655
3655
|
],
|
|
@@ -3657,7 +3657,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3657
3657
|
h.manifestPath,
|
|
3658
3658
|
);
|
|
3659
3659
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3660
|
-
const res = await fetcher(req("/
|
|
3660
|
+
const res = await fetcher(req("/surface/admin/"));
|
|
3661
3661
|
expect(res.status).toBe(200);
|
|
3662
3662
|
const body = await res.text();
|
|
3663
3663
|
expect(body).toContain("pc-chrome");
|
|
@@ -3668,7 +3668,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3668
3668
|
}
|
|
3669
3669
|
});
|
|
3670
3670
|
|
|
3671
|
-
test("does NOT inject on /
|
|
3671
|
+
test("does NOT inject on /surface/notes/ sub-paths (asset requests)", async () => {
|
|
3672
3672
|
const h = makeHarness();
|
|
3673
3673
|
const upstream = startHtmlUpstream("<html><body>asset shell</body></html>");
|
|
3674
3674
|
try {
|
|
@@ -3676,10 +3676,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3676
3676
|
{
|
|
3677
3677
|
services: [
|
|
3678
3678
|
{
|
|
3679
|
-
name: "parachute-
|
|
3679
|
+
name: "parachute-surface",
|
|
3680
3680
|
port: upstream.port,
|
|
3681
|
-
paths: ["/
|
|
3682
|
-
health: "/
|
|
3681
|
+
paths: ["/surface"],
|
|
3682
|
+
health: "/surface/health",
|
|
3683
3683
|
version: "0.1.0",
|
|
3684
3684
|
},
|
|
3685
3685
|
],
|
|
@@ -3687,7 +3687,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3687
3687
|
h.manifestPath,
|
|
3688
3688
|
);
|
|
3689
3689
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3690
|
-
const res = await fetcher(req("/
|
|
3690
|
+
const res = await fetcher(req("/surface/notes/index.html"));
|
|
3691
3691
|
expect(res.status).toBe(200);
|
|
3692
3692
|
const body = await res.text();
|
|
3693
3693
|
expect(body).not.toContain("pc-chrome");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the `/notes/*` → `/
|
|
2
|
+
* Tests for the `/notes/*` → `/surface/notes/*` redirect helper (Notes-as-app
|
|
3
3
|
* migration Phase 2, parachute-app design doc §16).
|
|
4
4
|
*
|
|
5
5
|
* Covers the path-match predicate, the target-URL builder, the DB-aware
|
|
@@ -52,30 +52,30 @@ describe("notes-redirect — isLegacyNotesPath", () => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
describe("notes-redirect — buildNotesRedirectTarget", () => {
|
|
55
|
-
test("rewrites the bare path /notes → /
|
|
56
|
-
expect(buildNotesRedirectTarget("/notes", "")).toBe("/
|
|
55
|
+
test("rewrites the bare path /notes → /surface/notes", () => {
|
|
56
|
+
expect(buildNotesRedirectTarget("/notes", "")).toBe("/surface/notes");
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
test("rewrites the trailing-slash form /notes/ → /
|
|
60
|
-
expect(buildNotesRedirectTarget("/notes/", "")).toBe("/
|
|
59
|
+
test("rewrites the trailing-slash form /notes/ → /surface/notes/", () => {
|
|
60
|
+
expect(buildNotesRedirectTarget("/notes/", "")).toBe("/surface/notes/");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test("rewrites a sub-path /notes/sw.js → /
|
|
64
|
-
expect(buildNotesRedirectTarget("/notes/sw.js", "")).toBe("/
|
|
63
|
+
test("rewrites a sub-path /notes/sw.js → /surface/notes/sw.js", () => {
|
|
64
|
+
expect(buildNotesRedirectTarget("/notes/sw.js", "")).toBe("/surface/notes/sw.js");
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
test("preserves a single-param query string", () => {
|
|
68
|
-
expect(buildNotesRedirectTarget("/notes/foo", "?q=1")).toBe("/
|
|
68
|
+
expect(buildNotesRedirectTarget("/notes/foo", "?q=1")).toBe("/surface/notes/foo?q=1");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
test("preserves a multi-param query string verbatim (no re-encoding)", () => {
|
|
72
72
|
expect(buildNotesRedirectTarget("/notes/foo", "?a=1&b=hello%20world")).toBe(
|
|
73
|
-
"/
|
|
73
|
+
"/surface/notes/foo?a=1&b=hello%20world",
|
|
74
74
|
);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
test("preserves the bare /notes + query (no trailing slash on rewrite)", () => {
|
|
78
|
-
expect(buildNotesRedirectTarget("/notes", "?next=foo")).toBe("/
|
|
78
|
+
expect(buildNotesRedirectTarget("/notes", "?next=foo")).toBe("/surface/notes?next=foo");
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
81
|
|
|
@@ -90,13 +90,13 @@ describe("notes-redirect — maybeRedirectNotes", () => {
|
|
|
90
90
|
// Absent DB defaults to redirect-on — the migration-default direction.
|
|
91
91
|
// Operators flipping the opt-out flag have a hub-with-DB; the default
|
|
92
92
|
// doesn't depend on DB readiness.
|
|
93
|
-
expect(maybeRedirectNotes("/notes/foo", "?q=1", undefined)).toBe("/
|
|
93
|
+
expect(maybeRedirectNotes("/notes/foo", "?q=1", undefined)).toBe("/surface/notes/foo?q=1");
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
test("returns the target URL when the path matches and the flag is absent (default)", () => {
|
|
97
97
|
const db = openHubDb(hubDbPath(dir));
|
|
98
98
|
try {
|
|
99
|
-
expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/
|
|
99
|
+
expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/surface/notes/foo");
|
|
100
100
|
} finally {
|
|
101
101
|
db.close();
|
|
102
102
|
}
|
|
@@ -109,7 +109,7 @@ describe("notes-redirect — maybeRedirectNotes", () => {
|
|
|
109
109
|
try {
|
|
110
110
|
setNotesRedirectDisabled(db, true);
|
|
111
111
|
setNotesRedirectDisabled(db, false);
|
|
112
|
-
expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/
|
|
112
|
+
expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/surface/notes/foo");
|
|
113
113
|
} finally {
|
|
114
114
|
db.close();
|
|
115
115
|
}
|
|
@@ -144,18 +144,18 @@ describe("notes-redirect — logNotesRedirect (throttled)", () => {
|
|
|
144
144
|
|
|
145
145
|
test("logs once on the first hit", () => {
|
|
146
146
|
const lines: string[] = [];
|
|
147
|
-
logNotesRedirect("/notes/foo", "/
|
|
147
|
+
logNotesRedirect("/notes/foo", "/surface/notes/foo", {
|
|
148
148
|
now: () => 1_000_000,
|
|
149
149
|
log: (m) => lines.push(m),
|
|
150
150
|
});
|
|
151
|
-
expect(lines).toEqual(["[notes-migration] redirect /notes/foo → /
|
|
151
|
+
expect(lines).toEqual(["[notes-migration] redirect /notes/foo → /surface/notes/foo"]);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
test("throttles repeated hits to the same path within the window", () => {
|
|
155
155
|
const lines: string[] = [];
|
|
156
156
|
// Five hits within a 10-second span — well inside the 60-second window.
|
|
157
157
|
for (let i = 0; i < 5; i++) {
|
|
158
|
-
logNotesRedirect("/notes/foo", "/
|
|
158
|
+
logNotesRedirect("/notes/foo", "/surface/notes/foo", {
|
|
159
159
|
now: () => 1_000_000 + i * 2_000,
|
|
160
160
|
log: (m) => lines.push(m),
|
|
161
161
|
});
|
|
@@ -165,12 +165,12 @@ describe("notes-redirect — logNotesRedirect (throttled)", () => {
|
|
|
165
165
|
|
|
166
166
|
test("re-logs the same path after the window expires", () => {
|
|
167
167
|
const lines: string[] = [];
|
|
168
|
-
logNotesRedirect("/notes/foo", "/
|
|
168
|
+
logNotesRedirect("/notes/foo", "/surface/notes/foo", {
|
|
169
169
|
now: () => 1_000_000,
|
|
170
170
|
log: (m) => lines.push(m),
|
|
171
171
|
});
|
|
172
172
|
// 60_001 ms later → window has rolled, log fires again.
|
|
173
|
-
logNotesRedirect("/notes/foo", "/
|
|
173
|
+
logNotesRedirect("/notes/foo", "/surface/notes/foo", {
|
|
174
174
|
now: () => 1_000_000 + 60_001,
|
|
175
175
|
log: (m) => lines.push(m),
|
|
176
176
|
});
|
|
@@ -179,11 +179,11 @@ describe("notes-redirect — logNotesRedirect (throttled)", () => {
|
|
|
179
179
|
|
|
180
180
|
test("logs distinct paths independently (per-path bucket)", () => {
|
|
181
181
|
const lines: string[] = [];
|
|
182
|
-
logNotesRedirect("/notes/foo", "/
|
|
182
|
+
logNotesRedirect("/notes/foo", "/surface/notes/foo", {
|
|
183
183
|
now: () => 1_000_000,
|
|
184
184
|
log: (m) => lines.push(m),
|
|
185
185
|
});
|
|
186
|
-
logNotesRedirect("/notes/bar", "/
|
|
186
|
+
logNotesRedirect("/notes/bar", "/surface/notes/bar", {
|
|
187
187
|
now: () => 1_000_000,
|
|
188
188
|
log: (m) => lines.push(m),
|
|
189
189
|
});
|