@openparachute/hub 0.7.7-rc.6 → 0.7.7-rc.8
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__/account-api.test.ts +287 -27
- package/src/__tests__/account-session.test.ts +244 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/account-api.ts +111 -66
- package/src/account-session.ts +132 -0
- package/src/api-invites.ts +19 -0
- package/src/hub-server.ts +35 -5
- package/src/hub-settings.ts +15 -1
- package/src/invites.ts +42 -0
- package/src/sessions.ts +66 -30
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { checkAccountDescriptor, validateVaultScopes } from "@openparachute/door-contract";
|
|
6
7
|
import {
|
|
7
8
|
handleAccountCapabilities,
|
|
8
9
|
handleAccountCreateVault,
|
|
@@ -12,7 +13,10 @@ import {
|
|
|
12
13
|
handleAccountRoot,
|
|
13
14
|
handleAccountSetVaultCaps,
|
|
14
15
|
} from "../account-api.ts";
|
|
16
|
+
import { handleCreateInvite } from "../api-invites.ts";
|
|
15
17
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
18
|
+
import { getSetting } from "../hub-settings.ts";
|
|
19
|
+
import { findInviteByRawToken, recordInviteRedemption, revokeInvite } from "../invites.ts";
|
|
16
20
|
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
17
21
|
import { upsertService } from "../services-manifest.ts";
|
|
18
22
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -115,31 +119,251 @@ function jsonReq(path: string, token: string, method: string, body?: unknown): R
|
|
|
115
119
|
// GET /.well-known/parachute-account — the capabilities descriptor
|
|
116
120
|
// ===========================================================================
|
|
117
121
|
describe("handleAccountCapabilities", () => {
|
|
118
|
-
test("descriptor is public and
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
122
|
+
test("descriptor is public, wildcard-CORS, and conforms to the shared door-contract canon", async () => {
|
|
123
|
+
const h = makeHarness();
|
|
124
|
+
try {
|
|
125
|
+
const res = handleAccountCapabilities(
|
|
126
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
127
|
+
{ db: h.db, issuer: `${ISSUER}/` },
|
|
128
|
+
);
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
131
|
+
expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
|
|
132
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
133
|
+
// The shared conformance checker — empty issue list means the shape
|
|
134
|
+
// is byte-conformant with the canonical ParachuteAccountDescriptor.
|
|
135
|
+
expect(checkAccountDescriptor(body, { issuer: ISSUER, door: "hub" })).toEqual([]);
|
|
136
|
+
expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
|
|
137
|
+
expect(body.door).toBe("hub");
|
|
138
|
+
expect(body.account_endpoint).toBe(`${ISSUER}/account`);
|
|
139
|
+
expect(body.auth).toEqual({ methods: ["password"], signin_path: "/login" });
|
|
140
|
+
expect(body.vault_url_template).toContain("{name}");
|
|
141
|
+
expect(body.vault_url_template).toBe(`${ISSUER}/vault/{name}`);
|
|
142
|
+
expect(body.capabilities).toEqual({
|
|
143
|
+
vault_create: true,
|
|
144
|
+
vault_rename: false,
|
|
145
|
+
vault_delete: true,
|
|
146
|
+
});
|
|
147
|
+
expect(body.plans).toEqual([]);
|
|
148
|
+
expect(body.signup_path).toBeUndefined(); // fresh hub, no active public invite
|
|
149
|
+
// Hub extras — additive, outside the shared contract (conformance
|
|
150
|
+
// walks expected keys only, so these ride along without breaking it).
|
|
151
|
+
const features = body.features as Record<string, unknown>;
|
|
152
|
+
expect(features.billing).toBe(false);
|
|
153
|
+
expect(features.modules).toBe(true);
|
|
154
|
+
expect(features.expose).toBe(true);
|
|
155
|
+
expect(features.import).toBe(true);
|
|
156
|
+
expect(features.export).toBe(true);
|
|
157
|
+
expect(body.caps_writable).toBe(true);
|
|
158
|
+
} finally {
|
|
159
|
+
h.cleanup();
|
|
160
|
+
}
|
|
135
161
|
});
|
|
136
162
|
|
|
137
163
|
test("405 on non-GET", () => {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
const h = makeHarness();
|
|
165
|
+
try {
|
|
166
|
+
const res = handleAccountCapabilities(
|
|
167
|
+
new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
|
|
168
|
+
{ db: h.db, issuer: ISSUER },
|
|
169
|
+
);
|
|
170
|
+
expect(res.status).toBe(405);
|
|
171
|
+
} finally {
|
|
172
|
+
h.cleanup();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
// Q2 — the conditional `signup_path`. Present only while an active
|
|
178
|
+
// multi-use (public-signup) invite exists; absent on a fresh hub and
|
|
179
|
+
// absent again once the invite is revoked / exhausted / expired (the
|
|
180
|
+
// setting is lazily cleared on the next read, invites.ts's
|
|
181
|
+
// activePublicSignupPath).
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
test("signup_path appears after minting a max_uses:3 invite, disappears on revoke", async () => {
|
|
184
|
+
const h = makeHarness();
|
|
185
|
+
try {
|
|
186
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
187
|
+
allowMulti: true,
|
|
188
|
+
passwordChanged: true,
|
|
189
|
+
});
|
|
190
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
191
|
+
const mintRes = await handleCreateInvite(
|
|
192
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 3 }),
|
|
193
|
+
{ db: h.db, issuer: ISSUER },
|
|
194
|
+
);
|
|
195
|
+
expect(mintRes.status).toBe(201);
|
|
196
|
+
const minted = (await mintRes.json()) as { token: string };
|
|
197
|
+
|
|
198
|
+
const afterMint = handleAccountCapabilities(
|
|
199
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
200
|
+
{ db: h.db, issuer: ISSUER },
|
|
201
|
+
);
|
|
202
|
+
const afterMintBody = (await afterMint.json()) as Record<string, unknown>;
|
|
203
|
+
expect(afterMintBody.signup_path).toBe(`/account/setup/${minted.token}`);
|
|
204
|
+
|
|
205
|
+
const invite = findInviteByRawToken(h.db, minted.token);
|
|
206
|
+
expect(invite).not.toBeNull();
|
|
207
|
+
expect(revokeInvite(h.db, invite?.tokenHash ?? "")).toBe(true);
|
|
208
|
+
|
|
209
|
+
const afterRevoke = handleAccountCapabilities(
|
|
210
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
211
|
+
{ db: h.db, issuer: ISSUER },
|
|
212
|
+
);
|
|
213
|
+
expect(((await afterRevoke.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
214
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined(); // lazily cleared
|
|
215
|
+
} finally {
|
|
216
|
+
h.cleanup();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("signup_path disappears once the invite is exhausted (used_count == max_uses)", async () => {
|
|
221
|
+
const h = makeHarness();
|
|
222
|
+
try {
|
|
223
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
224
|
+
allowMulti: true,
|
|
225
|
+
passwordChanged: true,
|
|
226
|
+
});
|
|
227
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
228
|
+
const mintRes = await handleCreateInvite(
|
|
229
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 2 }),
|
|
230
|
+
{ db: h.db, issuer: ISSUER },
|
|
231
|
+
);
|
|
232
|
+
const minted = (await mintRes.json()) as { token: string };
|
|
233
|
+
const invite = findInviteByRawToken(h.db, minted.token);
|
|
234
|
+
expect(invite).not.toBeNull();
|
|
235
|
+
|
|
236
|
+
// Redeem both seats directly (invites.ts core — the redeem HTTP flow
|
|
237
|
+
// itself is out of scope here). `redeemed_user_id` FKs to `users`, so
|
|
238
|
+
// reuse the admin row as the redeemer stand-in — the test only cares
|
|
239
|
+
// that TWO redemptions land, not who they belong to.
|
|
240
|
+
expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
|
|
241
|
+
expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
|
|
242
|
+
|
|
243
|
+
const res = handleAccountCapabilities(
|
|
244
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
245
|
+
{ db: h.db, issuer: ISSUER },
|
|
246
|
+
);
|
|
247
|
+
expect(((await res.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
248
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
249
|
+
} finally {
|
|
250
|
+
h.cleanup();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("signup_path disappears once the invite expires (injected clock)", async () => {
|
|
255
|
+
const h = makeHarness();
|
|
256
|
+
try {
|
|
257
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
258
|
+
allowMulti: true,
|
|
259
|
+
passwordChanged: true,
|
|
260
|
+
});
|
|
261
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
262
|
+
const mintTime = new Date("2026-01-01T00:00:00.000Z");
|
|
263
|
+
const mintRes = await handleCreateInvite(
|
|
264
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 5, expires_in: 60 }),
|
|
265
|
+
{ db: h.db, issuer: ISSUER, now: () => mintTime },
|
|
266
|
+
);
|
|
267
|
+
expect(mintRes.status).toBe(201);
|
|
268
|
+
|
|
269
|
+
const stillLive = handleAccountCapabilities(
|
|
270
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
271
|
+
{ db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 30_000) },
|
|
272
|
+
);
|
|
273
|
+
expect(((await stillLive.json()) as Record<string, unknown>).signup_path).toBeDefined();
|
|
274
|
+
|
|
275
|
+
const afterExpiry = handleAccountCapabilities(
|
|
276
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
277
|
+
{ db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 120_000) },
|
|
278
|
+
);
|
|
279
|
+
expect(((await afterExpiry.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
280
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
281
|
+
} finally {
|
|
282
|
+
h.cleanup();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("signup_path is NOT set for a multi-use NON-provisioning invite (B1: a team link must never leak on the public descriptor)", async () => {
|
|
287
|
+
const h = makeHarness();
|
|
288
|
+
try {
|
|
289
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
290
|
+
allowMulti: true,
|
|
291
|
+
passwordChanged: true,
|
|
292
|
+
});
|
|
293
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
294
|
+
// A multi-use link with provision_vault=false is a team invite (here
|
|
295
|
+
// account-only; a shared-vault one additionally pins an existing
|
|
296
|
+
// vault_name and would hand out team-vault WRITE) — NOT a public signup
|
|
297
|
+
// that gives each redeemer their own fresh vault. It must never be
|
|
298
|
+
// persisted to public_signup_token nor advertised via the anonymous,
|
|
299
|
+
// wildcard-CORS descriptor. Same `provisionVault` guard covers both the
|
|
300
|
+
// account-only and shared-vault non-provisioning shapes.
|
|
301
|
+
const mintRes = await handleCreateInvite(
|
|
302
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 3, provision_vault: false }),
|
|
303
|
+
{ db: h.db, issuer: ISSUER },
|
|
304
|
+
);
|
|
305
|
+
expect(mintRes.status).toBe(201);
|
|
306
|
+
// The raw token was NOT persisted (the write-side guard)...
|
|
307
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
308
|
+
// ...and the public descriptor advertises no signup_path.
|
|
309
|
+
const desc = handleAccountCapabilities(
|
|
310
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
311
|
+
{ db: h.db, issuer: ISSUER },
|
|
312
|
+
);
|
|
313
|
+
expect(((await desc.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
314
|
+
} finally {
|
|
315
|
+
h.cleanup();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ===========================================================================
|
|
321
|
+
// The shared door-contract validateVaultScopes — behavioral no-op proof
|
|
322
|
+
// (hub-parity P2, item 5). These are the SAME fixtures the hub's prior local
|
|
323
|
+
// `parseScopesBody` scope-shape logic was exercised against (see the
|
|
324
|
+
// `handleAccountMintVaultToken` describe block below for the HTTP-level
|
|
325
|
+
// equivalents); asserting them directly against the shared validator proves
|
|
326
|
+
// the swap didn't change hub's wire behavior.
|
|
327
|
+
// ===========================================================================
|
|
328
|
+
describe("validateVaultScopes (shared door-contract validator) — hub's prior fixtures", () => {
|
|
329
|
+
test("undefined / null / [] all default to read+write for the named vault", () => {
|
|
330
|
+
for (const requested of [undefined, null, []]) {
|
|
331
|
+
expect(validateVaultScopes(requested, "field-notes")).toEqual({
|
|
332
|
+
ok: true,
|
|
333
|
+
scopes: ["vault:field-notes:read", "vault:field-notes:write"],
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("an explicit single-scope array is honored verbatim", () => {
|
|
339
|
+
expect(validateVaultScopes(["vault:field-notes:read"], "field-notes")).toEqual({
|
|
340
|
+
ok: true,
|
|
341
|
+
scopes: ["vault:field-notes:read"],
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("a scope naming another vault is invalid_scope", () => {
|
|
346
|
+
expect(validateVaultScopes(["vault:other:read"], "field-notes")).toEqual({
|
|
347
|
+
ok: false,
|
|
348
|
+
reason: "invalid_scope",
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("a non-array requested value is invalid_request", () => {
|
|
353
|
+
expect(validateVaultScopes("not-an-array", "field-notes")).toEqual({
|
|
354
|
+
ok: false,
|
|
355
|
+
reason: "invalid_request",
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("a mixed array with a non-string entry is invalid_request, even when a valid scope leads", () => {
|
|
360
|
+
// The whole-array pre-scan (byte-exact with hub's prior parseScopesBody):
|
|
361
|
+
// a non-string entry anywhere rejects the WHOLE request as
|
|
362
|
+
// invalid_request, never a positional invalid_scope.
|
|
363
|
+
expect(validateVaultScopes(["vault:field-notes:read", 123], "field-notes")).toEqual({
|
|
364
|
+
ok: false,
|
|
365
|
+
reason: "invalid_request",
|
|
366
|
+
});
|
|
143
367
|
});
|
|
144
368
|
});
|
|
145
369
|
|
|
@@ -327,6 +551,26 @@ describe("handleAccountCreateVault", () => {
|
|
|
327
551
|
h.cleanup();
|
|
328
552
|
}
|
|
329
553
|
});
|
|
554
|
+
|
|
555
|
+
// Q6 (hub-parity P2): create-existing-name converges on 409 vault_taken —
|
|
556
|
+
// this facade no longer answers 200-idempotent. `provisionVault` itself
|
|
557
|
+
// stays idempotent (the invite-redeem caller doesn't route through here).
|
|
558
|
+
test("409 vault_taken on an existing vault name (Q6 — no longer 200-idempotent)", async () => {
|
|
559
|
+
const h = makeHarness(["beta", "personal"]);
|
|
560
|
+
try {
|
|
561
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
562
|
+
const res = await handleAccountCreateVault(
|
|
563
|
+
jsonReq("/account/vaults", token, "POST", { name: "beta" }),
|
|
564
|
+
deps(h),
|
|
565
|
+
);
|
|
566
|
+
expect(res.status).toBe(409);
|
|
567
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
568
|
+
expect(body.error).toBe("vault_taken");
|
|
569
|
+
expect(body.message).toBe("That vault name is already taken.");
|
|
570
|
+
} finally {
|
|
571
|
+
h.cleanup();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
330
574
|
});
|
|
331
575
|
|
|
332
576
|
// ===========================================================================
|
|
@@ -489,7 +733,7 @@ describe("account caps", () => {
|
|
|
489
733
|
// GET /account — bootstrap
|
|
490
734
|
// ===========================================================================
|
|
491
735
|
describe("handleAccountRoot", () => {
|
|
492
|
-
test("returns
|
|
736
|
+
test("returns the contract's AccountBootstrap — {id:self, door:hub, email}", async () => {
|
|
493
737
|
const h = makeHarness();
|
|
494
738
|
try {
|
|
495
739
|
const user = await createUser(h.db, "operator", "any-password", {
|
|
@@ -500,10 +744,26 @@ describe("handleAccountRoot", () => {
|
|
|
500
744
|
const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
|
|
501
745
|
const res = await handleAccountRoot(withBearer("/account", token), deps(h));
|
|
502
746
|
expect(res.status).toBe(200);
|
|
503
|
-
const body = (await res.json()) as {
|
|
504
|
-
expect(body
|
|
505
|
-
|
|
506
|
-
|
|
747
|
+
const body = (await res.json()) as { id: string; email?: string; door: string };
|
|
748
|
+
expect(body).toEqual({ id: "self", door: "hub", email: "op@example.com" });
|
|
749
|
+
} finally {
|
|
750
|
+
h.cleanup();
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("omits email when the operator row has none", async () => {
|
|
755
|
+
const h = makeHarness();
|
|
756
|
+
try {
|
|
757
|
+
const user = await createUser(h.db, "operator", "any-password", {
|
|
758
|
+
allowMulti: true,
|
|
759
|
+
passwordChanged: true,
|
|
760
|
+
});
|
|
761
|
+
const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
|
|
762
|
+
const res = await handleAccountRoot(withBearer("/account", token), deps(h));
|
|
763
|
+
expect(res.status).toBe(200);
|
|
764
|
+
const body = (await res.json()) as { id: string; email?: string; door: string };
|
|
765
|
+
expect(body).toEqual({ id: "self", door: "hub" });
|
|
766
|
+
expect(body.email).toBeUndefined();
|
|
507
767
|
} finally {
|
|
508
768
|
h.cleanup();
|
|
509
769
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { checkAccountSessionResponse } from "@openparachute/door-contract";
|
|
7
|
+
import { handleAccountSession } from "../account-session.ts";
|
|
8
|
+
import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken, parseCsrfCookie } from "../csrf.ts";
|
|
9
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
10
|
+
import {
|
|
11
|
+
SESSION_COOKIE_NAME,
|
|
12
|
+
SESSION_SLIDE_THRESHOLD_MS,
|
|
13
|
+
SESSION_TTL_MS,
|
|
14
|
+
buildSessionCookie,
|
|
15
|
+
createSession,
|
|
16
|
+
findSession,
|
|
17
|
+
} from "../sessions.ts";
|
|
18
|
+
import { createUser } from "../users.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tests for `GET /account/session` — the same-origin boot oracle (hub-parity
|
|
22
|
+
* P1, the twin of cloud's `account-session.ts`). Covers:
|
|
23
|
+
* - both branches' body shape (signed-out / signed-in).
|
|
24
|
+
* - CSRF minted when absent, reused (no re-Set-Cookie) when present — on
|
|
25
|
+
* BOTH branches (the G2 anonymous-CSRF invariant).
|
|
26
|
+
* - a deleted-user session row falls back to signed-out.
|
|
27
|
+
* - the bounded slide: an old-but-live session rolls + re-issues the
|
|
28
|
+
* cookie; a fresh session does neither.
|
|
29
|
+
* - 405 on non-GET, `cache-control: no-store`, no CORS headers.
|
|
30
|
+
* - drift: `checkAccountSessionResponse` reports zero issues against the
|
|
31
|
+
* live handler on both branches.
|
|
32
|
+
*/
|
|
33
|
+
const ISSUER = "https://hub.test";
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
db: Database;
|
|
37
|
+
cleanup: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeHarness(): Harness {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-session-"));
|
|
42
|
+
const db = openHubDb(hubDbPath(dir));
|
|
43
|
+
return {
|
|
44
|
+
db,
|
|
45
|
+
cleanup: () => {
|
|
46
|
+
db.close();
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let harness: Harness;
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
harness = makeHarness();
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
harness.cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function getReq(cookie?: string, method = "GET"): Request {
|
|
61
|
+
return new Request(`${ISSUER}/account/session`, {
|
|
62
|
+
method,
|
|
63
|
+
...(cookie ? { headers: { cookie } } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sessionCookiePair(sid: string): string {
|
|
68
|
+
return buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("handleAccountSession — signed-out branch", () => {
|
|
72
|
+
test("no cookie at all → {signed_in:false, csrf} + CSRF Set-Cookie minted", async () => {
|
|
73
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = (await res.json()) as { signed_in: boolean; csrf: string };
|
|
76
|
+
expect(body.signed_in).toBe(false);
|
|
77
|
+
expect(typeof body.csrf).toBe("string");
|
|
78
|
+
expect(body.csrf.length).toBeGreaterThan(0);
|
|
79
|
+
|
|
80
|
+
const setCookie = res.headers.getSetCookie();
|
|
81
|
+
const csrfSet = setCookie.find((c) => c.includes(CSRF_COOKIE_NAME));
|
|
82
|
+
expect(csrfSet).toBeDefined();
|
|
83
|
+
expect(parseCsrfCookie(csrfSet ?? null)).toBe(body.csrf);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("reuses an existing CSRF cookie without re-minting (no Set-Cookie)", async () => {
|
|
87
|
+
const existing = generateCsrfToken();
|
|
88
|
+
const cookie = buildCsrfCookie(existing).split(";")[0] ?? "";
|
|
89
|
+
const res = handleAccountSession(getReq(cookie), { db: harness.db });
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
const body = (await res.json()) as { signed_in: boolean; csrf: string };
|
|
92
|
+
expect(body.signed_in).toBe(false);
|
|
93
|
+
expect(body.csrf).toBe(existing);
|
|
94
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a session cookie naming a deleted user → signed-out", async () => {
|
|
98
|
+
// The on-disk FK normally guards against a session outliving its user, so
|
|
99
|
+
// forge the orphaned row directly with `PRAGMA foreign_keys=OFF` (the
|
|
100
|
+
// `api-account.test.ts` precedent) — simulating a delete-user-via-SQL-shell
|
|
101
|
+
// race, not something reachable through the app's own APIs.
|
|
102
|
+
const sessionId = "test-orphaned-session-id-base64url";
|
|
103
|
+
harness.db.exec("PRAGMA foreign_keys = OFF");
|
|
104
|
+
try {
|
|
105
|
+
harness.db
|
|
106
|
+
.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
107
|
+
.run(
|
|
108
|
+
sessionId,
|
|
109
|
+
"nonexistent-user-uuid",
|
|
110
|
+
new Date(Date.now() + SESSION_TTL_MS).toISOString(),
|
|
111
|
+
new Date().toISOString(),
|
|
112
|
+
);
|
|
113
|
+
} finally {
|
|
114
|
+
harness.db.exec("PRAGMA foreign_keys = ON");
|
|
115
|
+
}
|
|
116
|
+
const cookie = sessionCookiePair(sessionId);
|
|
117
|
+
const res = handleAccountSession(getReq(cookie), { db: harness.db });
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
const body = (await res.json()) as { signed_in: boolean };
|
|
120
|
+
expect(body.signed_in).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("drift: checkAccountSessionResponse reports zero issues (signed-out)", async () => {
|
|
124
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
125
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
126
|
+
expect(checkAccountSessionResponse(body, { signedIn: false })).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("handleAccountSession — signed-in branch", () => {
|
|
131
|
+
test("username always present; no email when the user row has none", async () => {
|
|
132
|
+
const user = await createUser(harness.db, "operator", "pw", { passwordChanged: true });
|
|
133
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
134
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
137
|
+
expect(body.signed_in).toBe(true);
|
|
138
|
+
expect(body.username).toBe("operator");
|
|
139
|
+
expect(body.email).toBeUndefined();
|
|
140
|
+
expect(body.account_created_at).toBe(user.createdAt);
|
|
141
|
+
expect(body.password_change_required).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("email present when the user row has one", async () => {
|
|
145
|
+
const user = await createUser(harness.db, "withmail", "pw", {
|
|
146
|
+
passwordChanged: true,
|
|
147
|
+
email: "friend@example.com",
|
|
148
|
+
});
|
|
149
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
150
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
151
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
152
|
+
expect(body.email).toBe("friend@example.com");
|
|
153
|
+
expect(body.username).toBe("withmail");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("password_change_required:true for a not-yet-rotated (passwordChanged:false) user", async () => {
|
|
157
|
+
// createUser defaults passwordChanged to false — the admin-created-user
|
|
158
|
+
// posture (force-redirect on first sign-in).
|
|
159
|
+
const user = await createUser(harness.db, "temp", "pw");
|
|
160
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
161
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
162
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
163
|
+
expect(body.signed_in).toBe(true);
|
|
164
|
+
expect(body.password_change_required).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("mints CSRF when absent even on the signed-in branch", async () => {
|
|
168
|
+
const user = await createUser(harness.db, "operator2", "pw", { passwordChanged: true });
|
|
169
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
170
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
171
|
+
const body = (await res.json()) as { csrf: string };
|
|
172
|
+
expect(typeof body.csrf).toBe("string");
|
|
173
|
+
expect(body.csrf.length).toBeGreaterThan(0);
|
|
174
|
+
const setCookie = res.headers.getSetCookie();
|
|
175
|
+
expect(setCookie.some((c) => c.includes(CSRF_COOKIE_NAME))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("drift: checkAccountSessionResponse reports zero issues (signed-in)", async () => {
|
|
179
|
+
const user = await createUser(harness.db, "driftcheck", "pw", { passwordChanged: true });
|
|
180
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
181
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
182
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
183
|
+
expect(checkAccountSessionResponse(body, { signedIn: true })).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("handleAccountSession — bounded slide", () => {
|
|
188
|
+
test("an old-but-live session (within the slide threshold of expiry) rolls + re-issues the cookie", async () => {
|
|
189
|
+
const user = await createUser(harness.db, "slider", "pw", { passwordChanged: true });
|
|
190
|
+
const t0 = new Date();
|
|
191
|
+
// Created far enough in the past that remaining life < TTL - threshold.
|
|
192
|
+
const createdAt = new Date(
|
|
193
|
+
t0.getTime() - (SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS + 60_000),
|
|
194
|
+
);
|
|
195
|
+
const session = createSession(harness.db, { userId: user.id, now: () => createdAt });
|
|
196
|
+
const originalExpiry = new Date(session.expiresAt).getTime();
|
|
197
|
+
|
|
198
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
const setCookie = res.headers.getSetCookie();
|
|
201
|
+
const sessionSet = setCookie.find((c) => c.includes(SESSION_COOKIE_NAME));
|
|
202
|
+
expect(sessionSet).toBeDefined();
|
|
203
|
+
expect(sessionSet).toContain("HttpOnly");
|
|
204
|
+
expect(sessionSet).toContain("SameSite=Lax");
|
|
205
|
+
|
|
206
|
+
const found = findSession(harness.db, session.id);
|
|
207
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a fresh session does NOT slide — no session Set-Cookie, no DB write", async () => {
|
|
211
|
+
const user = await createUser(harness.db, "fresh", "pw", { passwordChanged: true });
|
|
212
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
213
|
+
const originalExpiry = session.expiresAt;
|
|
214
|
+
|
|
215
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
216
|
+
expect(res.status).toBe(200);
|
|
217
|
+
const setCookie = res.headers.getSetCookie();
|
|
218
|
+
expect(setCookie.some((c) => c.includes(SESSION_COOKIE_NAME))).toBe(false);
|
|
219
|
+
|
|
220
|
+
const found = findSession(harness.db, session.id);
|
|
221
|
+
expect(found?.expiresAt).toBe(originalExpiry);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("handleAccountSession — method + headers", () => {
|
|
226
|
+
test("405 on POST", async () => {
|
|
227
|
+
const res = handleAccountSession(getReq(undefined, "POST"), { db: harness.db });
|
|
228
|
+
expect(res.status).toBe(405);
|
|
229
|
+
expect(res.headers.get("allow")).toBe("GET");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("cache-control: no-store on the 200 path", async () => {
|
|
233
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
234
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("no access-control-allow-origin (same-origin only, no CORS)", async () => {
|
|
238
|
+
const req = new Request(`${ISSUER}/account/session`, {
|
|
239
|
+
headers: { origin: "https://evil.example" },
|
|
240
|
+
});
|
|
241
|
+
const res = handleAccountSession(req, { db: harness.db });
|
|
242
|
+
expect(res.headers.get("access-control-allow-origin")).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -603,4 +603,29 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
603
603
|
expect(res.headers.get("location")).toBe("/login");
|
|
604
604
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
|
|
605
605
|
});
|
|
606
|
+
|
|
607
|
+
// App-wire pin (hub-parity P1, §8-M4): the app's `logout()` posts form-
|
|
608
|
+
// encoded, not JSON (`parachute-app/src/lib/account/client.ts:246-258`:
|
|
609
|
+
// `content-type: application/x-www-form-urlencoded`, body
|
|
610
|
+
// `new URLSearchParams({ __csrf: csrf }).toString()`, credentials included).
|
|
611
|
+
// No hub code changed for this — the existing formData()-based handler
|
|
612
|
+
// already accepts exactly this shape; this test freezes the byte-shape so a
|
|
613
|
+
// future refactor of the handler can't silently drop form-body support out
|
|
614
|
+
// from under the app.
|
|
615
|
+
test("app-wire pin — form-encoded body matching client.ts's exact logout() shape", async () => {
|
|
616
|
+
const cookie = await cookieForUser(harness.db, "appuser", "pw");
|
|
617
|
+
const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
|
|
618
|
+
const req = new Request("http://hub.test/logout", {
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: {
|
|
621
|
+
cookie,
|
|
622
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
623
|
+
},
|
|
624
|
+
body: new URLSearchParams({ [CSRF_FIELD_NAME]: TEST_CSRF }).toString(),
|
|
625
|
+
});
|
|
626
|
+
const res = await handleAdminLogoutPost(harness.db, req);
|
|
627
|
+
expect(res.status).toBe(302);
|
|
628
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
629
|
+
expect(findSession(harness.db, sid)).toBeNull();
|
|
630
|
+
});
|
|
606
631
|
});
|