@openparachute/hub 0.7.6 → 0.7.7-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
ACCOUNT_ERROR_CODES,
|
|
8
|
+
checkAccountDescriptor,
|
|
9
|
+
checkVaultTokenMintResponse,
|
|
10
|
+
validateVaultScopes,
|
|
11
|
+
} from "@openparachute/door-contract";
|
|
12
|
+
import {
|
|
13
|
+
handleAccountCapabilities,
|
|
14
|
+
handleAccountCreateVault,
|
|
15
|
+
handleAccountGetVaultCaps,
|
|
16
|
+
handleAccountListVaults,
|
|
17
|
+
handleAccountMintVaultToken,
|
|
18
|
+
handleAccountRoot,
|
|
19
|
+
handleAccountSetVaultCaps,
|
|
20
|
+
} from "../account-api.ts";
|
|
21
|
+
import { handleCreateInvite } from "../api-invites.ts";
|
|
22
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
23
|
+
import { getSetting } from "../hub-settings.ts";
|
|
24
|
+
import { findInviteByRawToken, recordInviteRedemption, revokeInvite } from "../invites.ts";
|
|
25
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
26
|
+
import { upsertService } from "../services-manifest.ts";
|
|
27
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
28
|
+
import { createUser } from "../users.ts";
|
|
29
|
+
import { getVaultCap } from "../vault-caps.ts";
|
|
30
|
+
|
|
31
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
32
|
+
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
33
|
+
const ACCOUNT_ADMIN_SCOPE = "account:self:admin";
|
|
34
|
+
const ACCOUNT_READ_SCOPE = "account:self:read";
|
|
35
|
+
|
|
36
|
+
type RunResult = { exitCode: number; stdout: string; stderr: string };
|
|
37
|
+
|
|
38
|
+
/** Mirror of the `parachute-vault create --json` stdout shape (see admin-vaults.test). */
|
|
39
|
+
function vaultCreateJson(
|
|
40
|
+
name: string,
|
|
41
|
+
token = `hubjwt.${name}.access`,
|
|
42
|
+
tokenGuidance?: string,
|
|
43
|
+
): string {
|
|
44
|
+
return JSON.stringify({
|
|
45
|
+
name,
|
|
46
|
+
token,
|
|
47
|
+
...(tokenGuidance ? { token_guidance: tokenGuidance } : {}),
|
|
48
|
+
paths: {
|
|
49
|
+
vault_dir: `/home/test/.parachute/vault/${name}`,
|
|
50
|
+
vault_db: `/home/test/.parachute/vault/${name}/vault.db`,
|
|
51
|
+
vault_config: `/home/test/.parachute/vault/${name}/config.yaml`,
|
|
52
|
+
},
|
|
53
|
+
set_as_default: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Harness {
|
|
58
|
+
db: Database;
|
|
59
|
+
dir: string;
|
|
60
|
+
manifestPath: string;
|
|
61
|
+
cleanup: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeHarness(vaultNames: string[] = ["beta", "personal"]): Harness {
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-api-"));
|
|
66
|
+
const db = openHubDb(hubDbPath(dir));
|
|
67
|
+
rotateSigningKey(db);
|
|
68
|
+
const manifestPath = join(dir, "services.json");
|
|
69
|
+
const paths = vaultNames.map((n) => `/vault/${n}`);
|
|
70
|
+
writeFileSync(
|
|
71
|
+
manifestPath,
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
services: [
|
|
74
|
+
{ name: "parachute-vault", port: 4101, paths, health: "/health", version: "0.4.2" },
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
db,
|
|
80
|
+
dir,
|
|
81
|
+
manifestPath,
|
|
82
|
+
cleanup: () => {
|
|
83
|
+
db.close();
|
|
84
|
+
rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function deps(
|
|
90
|
+
h: Harness,
|
|
91
|
+
extra: Partial<{ runCommand: (cmd: readonly string[]) => Promise<RunResult> }> = {},
|
|
92
|
+
) {
|
|
93
|
+
return { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, ...extra };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function bearer(h: Harness, scopes: string[], sub = "operator-user"): Promise<string> {
|
|
97
|
+
const minted = await signAccessToken(h.db, {
|
|
98
|
+
sub,
|
|
99
|
+
scopes,
|
|
100
|
+
audience: "account",
|
|
101
|
+
clientId: "parachute-hub-spa",
|
|
102
|
+
issuer: ISSUER,
|
|
103
|
+
ttlSeconds: 600,
|
|
104
|
+
});
|
|
105
|
+
return minted.token;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function withBearer(path: string, token: string | null, init: RequestInit = {}): Request {
|
|
109
|
+
const headers = new Headers(init.headers ?? {});
|
|
110
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
111
|
+
return new Request(`${ISSUER}${path}`, { ...init, headers });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function jsonReq(path: string, token: string, method: string, body?: unknown): Request {
|
|
115
|
+
const init: RequestInit = { method };
|
|
116
|
+
if (body !== undefined) {
|
|
117
|
+
init.body = JSON.stringify(body);
|
|
118
|
+
init.headers = { "content-type": "application/json" };
|
|
119
|
+
}
|
|
120
|
+
return withBearer(path, token, init);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ===========================================================================
|
|
124
|
+
// GET /.well-known/parachute-account — the capabilities descriptor
|
|
125
|
+
// ===========================================================================
|
|
126
|
+
describe("handleAccountCapabilities", () => {
|
|
127
|
+
test("descriptor is public, wildcard-CORS, and conforms to the shared door-contract canon", async () => {
|
|
128
|
+
const h = makeHarness();
|
|
129
|
+
try {
|
|
130
|
+
const res = handleAccountCapabilities(
|
|
131
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
132
|
+
{ db: h.db, issuer: `${ISSUER}/` },
|
|
133
|
+
);
|
|
134
|
+
expect(res.status).toBe(200);
|
|
135
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
136
|
+
expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
|
|
137
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
138
|
+
// The shared conformance checker — empty issue list means the shape
|
|
139
|
+
// is byte-conformant with the canonical ParachuteAccountDescriptor.
|
|
140
|
+
expect(checkAccountDescriptor(body, { issuer: ISSUER, door: "hub" })).toEqual([]);
|
|
141
|
+
expect(body.issuer).toBe(ISSUER); // trailing slash trimmed
|
|
142
|
+
expect(body.door).toBe("hub");
|
|
143
|
+
expect(body.account_endpoint).toBe(`${ISSUER}/account`);
|
|
144
|
+
expect(body.auth).toEqual({ methods: ["password"], signin_path: "/login" });
|
|
145
|
+
expect(body.vault_url_template).toContain("{name}");
|
|
146
|
+
expect(body.vault_url_template).toBe(`${ISSUER}/vault/{name}`);
|
|
147
|
+
expect(body.capabilities).toEqual({
|
|
148
|
+
vault_create: true,
|
|
149
|
+
vault_rename: false,
|
|
150
|
+
vault_delete: true,
|
|
151
|
+
});
|
|
152
|
+
expect(body.plans).toEqual([]);
|
|
153
|
+
expect(body.signup_path).toBeUndefined(); // fresh hub, no active public invite
|
|
154
|
+
// Hub extras — additive, outside the shared contract (conformance
|
|
155
|
+
// walks expected keys only, so these ride along without breaking it).
|
|
156
|
+
const features = body.features as Record<string, unknown>;
|
|
157
|
+
expect(features.billing).toBe(false);
|
|
158
|
+
expect(features.modules).toBe(true);
|
|
159
|
+
expect(features.expose).toBe(true);
|
|
160
|
+
expect(features.import).toBe(true);
|
|
161
|
+
expect(features.export).toBe(true);
|
|
162
|
+
expect(body.caps_writable).toBe(true);
|
|
163
|
+
} finally {
|
|
164
|
+
h.cleanup();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("405 on non-GET", () => {
|
|
169
|
+
const h = makeHarness();
|
|
170
|
+
try {
|
|
171
|
+
const res = handleAccountCapabilities(
|
|
172
|
+
new Request(`${ISSUER}/.well-known/parachute-account`, { method: "POST" }),
|
|
173
|
+
{ db: h.db, issuer: ISSUER },
|
|
174
|
+
);
|
|
175
|
+
expect(res.status).toBe(405);
|
|
176
|
+
} finally {
|
|
177
|
+
h.cleanup();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
// Q2 — the conditional `signup_path`. Present only while an active
|
|
183
|
+
// multi-use (public-signup) invite exists; absent on a fresh hub and
|
|
184
|
+
// absent again once the invite is revoked / exhausted / expired (the
|
|
185
|
+
// setting is lazily cleared on the next read, invites.ts's
|
|
186
|
+
// activePublicSignupPath).
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
test("signup_path appears after minting a max_uses:3 invite, disappears on revoke", async () => {
|
|
189
|
+
const h = makeHarness();
|
|
190
|
+
try {
|
|
191
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
192
|
+
allowMulti: true,
|
|
193
|
+
passwordChanged: true,
|
|
194
|
+
});
|
|
195
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
196
|
+
const mintRes = await handleCreateInvite(
|
|
197
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 3 }),
|
|
198
|
+
{ db: h.db, issuer: ISSUER },
|
|
199
|
+
);
|
|
200
|
+
expect(mintRes.status).toBe(201);
|
|
201
|
+
const minted = (await mintRes.json()) as { token: string };
|
|
202
|
+
|
|
203
|
+
const afterMint = handleAccountCapabilities(
|
|
204
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
205
|
+
{ db: h.db, issuer: ISSUER },
|
|
206
|
+
);
|
|
207
|
+
const afterMintBody = (await afterMint.json()) as Record<string, unknown>;
|
|
208
|
+
expect(afterMintBody.signup_path).toBe(`/account/setup/${minted.token}`);
|
|
209
|
+
|
|
210
|
+
const invite = findInviteByRawToken(h.db, minted.token);
|
|
211
|
+
expect(invite).not.toBeNull();
|
|
212
|
+
expect(revokeInvite(h.db, invite?.tokenHash ?? "")).toBe(true);
|
|
213
|
+
|
|
214
|
+
const afterRevoke = handleAccountCapabilities(
|
|
215
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
216
|
+
{ db: h.db, issuer: ISSUER },
|
|
217
|
+
);
|
|
218
|
+
expect(((await afterRevoke.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
219
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined(); // lazily cleared
|
|
220
|
+
} finally {
|
|
221
|
+
h.cleanup();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("signup_path disappears once the invite is exhausted (used_count == max_uses)", async () => {
|
|
226
|
+
const h = makeHarness();
|
|
227
|
+
try {
|
|
228
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
229
|
+
allowMulti: true,
|
|
230
|
+
passwordChanged: true,
|
|
231
|
+
});
|
|
232
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
233
|
+
const mintRes = await handleCreateInvite(
|
|
234
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 2 }),
|
|
235
|
+
{ db: h.db, issuer: ISSUER },
|
|
236
|
+
);
|
|
237
|
+
const minted = (await mintRes.json()) as { token: string };
|
|
238
|
+
const invite = findInviteByRawToken(h.db, minted.token);
|
|
239
|
+
expect(invite).not.toBeNull();
|
|
240
|
+
|
|
241
|
+
// Redeem both seats directly (invites.ts core — the redeem HTTP flow
|
|
242
|
+
// itself is out of scope here). `redeemed_user_id` FKs to `users`, so
|
|
243
|
+
// reuse the admin row as the redeemer stand-in — the test only cares
|
|
244
|
+
// that TWO redemptions land, not who they belong to.
|
|
245
|
+
expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
|
|
246
|
+
expect(recordInviteRedemption(h.db, invite?.tokenHash ?? "", admin.id)).toBe(true);
|
|
247
|
+
|
|
248
|
+
const res = handleAccountCapabilities(
|
|
249
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
250
|
+
{ db: h.db, issuer: ISSUER },
|
|
251
|
+
);
|
|
252
|
+
expect(((await res.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
253
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
254
|
+
} finally {
|
|
255
|
+
h.cleanup();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("signup_path disappears once the invite expires (injected clock)", async () => {
|
|
260
|
+
const h = makeHarness();
|
|
261
|
+
try {
|
|
262
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
263
|
+
allowMulti: true,
|
|
264
|
+
passwordChanged: true,
|
|
265
|
+
});
|
|
266
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
267
|
+
const mintTime = new Date("2026-01-01T00:00:00.000Z");
|
|
268
|
+
const mintRes = await handleCreateInvite(
|
|
269
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 5, expires_in: 60 }),
|
|
270
|
+
{ db: h.db, issuer: ISSUER, now: () => mintTime },
|
|
271
|
+
);
|
|
272
|
+
expect(mintRes.status).toBe(201);
|
|
273
|
+
|
|
274
|
+
const stillLive = handleAccountCapabilities(
|
|
275
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
276
|
+
{ db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 30_000) },
|
|
277
|
+
);
|
|
278
|
+
expect(((await stillLive.json()) as Record<string, unknown>).signup_path).toBeDefined();
|
|
279
|
+
|
|
280
|
+
const afterExpiry = handleAccountCapabilities(
|
|
281
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
282
|
+
{ db: h.db, issuer: ISSUER, now: () => new Date(mintTime.getTime() + 120_000) },
|
|
283
|
+
);
|
|
284
|
+
expect(((await afterExpiry.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
285
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
286
|
+
} finally {
|
|
287
|
+
h.cleanup();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
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 () => {
|
|
292
|
+
const h = makeHarness();
|
|
293
|
+
try {
|
|
294
|
+
const admin = await createUser(h.db, "operator", "any-password", {
|
|
295
|
+
allowMulti: true,
|
|
296
|
+
passwordChanged: true,
|
|
297
|
+
});
|
|
298
|
+
const adminToken = await bearer(h, [HOST_ADMIN_SCOPE], admin.id);
|
|
299
|
+
// A multi-use link with provision_vault=false is a team invite (here
|
|
300
|
+
// account-only; a shared-vault one additionally pins an existing
|
|
301
|
+
// vault_name and would hand out team-vault WRITE) — NOT a public signup
|
|
302
|
+
// that gives each redeemer their own fresh vault. It must never be
|
|
303
|
+
// persisted to public_signup_token nor advertised via the anonymous,
|
|
304
|
+
// wildcard-CORS descriptor. Same `provisionVault` guard covers both the
|
|
305
|
+
// account-only and shared-vault non-provisioning shapes.
|
|
306
|
+
const mintRes = await handleCreateInvite(
|
|
307
|
+
jsonReq("/api/invites", adminToken, "POST", { max_uses: 3, provision_vault: false }),
|
|
308
|
+
{ db: h.db, issuer: ISSUER },
|
|
309
|
+
);
|
|
310
|
+
expect(mintRes.status).toBe(201);
|
|
311
|
+
// The raw token was NOT persisted (the write-side guard)...
|
|
312
|
+
expect(getSetting(h.db, "public_signup_token")).toBeUndefined();
|
|
313
|
+
// ...and the public descriptor advertises no signup_path.
|
|
314
|
+
const desc = handleAccountCapabilities(
|
|
315
|
+
new Request(`${ISSUER}/.well-known/parachute-account`),
|
|
316
|
+
{ db: h.db, issuer: ISSUER },
|
|
317
|
+
);
|
|
318
|
+
expect(((await desc.json()) as Record<string, unknown>).signup_path).toBeUndefined();
|
|
319
|
+
} finally {
|
|
320
|
+
h.cleanup();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ===========================================================================
|
|
326
|
+
// The shared door-contract validateVaultScopes — behavioral no-op proof
|
|
327
|
+
// (hub-parity P2, item 5). These are the SAME fixtures the hub's prior local
|
|
328
|
+
// `parseScopesBody` scope-shape logic was exercised against (see the
|
|
329
|
+
// `handleAccountMintVaultToken` describe block below for the HTTP-level
|
|
330
|
+
// equivalents); asserting them directly against the shared validator proves
|
|
331
|
+
// the swap didn't change hub's wire behavior.
|
|
332
|
+
// ===========================================================================
|
|
333
|
+
describe("validateVaultScopes (shared door-contract validator) — hub's prior fixtures", () => {
|
|
334
|
+
test("undefined / null / [] all default to read+write for the named vault", () => {
|
|
335
|
+
for (const requested of [undefined, null, []]) {
|
|
336
|
+
expect(validateVaultScopes(requested, "field-notes")).toEqual({
|
|
337
|
+
ok: true,
|
|
338
|
+
scopes: ["vault:field-notes:read", "vault:field-notes:write"],
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("an explicit single-scope array is honored verbatim", () => {
|
|
344
|
+
expect(validateVaultScopes(["vault:field-notes:read"], "field-notes")).toEqual({
|
|
345
|
+
ok: true,
|
|
346
|
+
scopes: ["vault:field-notes:read"],
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a scope naming another vault is invalid_scope", () => {
|
|
351
|
+
expect(validateVaultScopes(["vault:other:read"], "field-notes")).toEqual({
|
|
352
|
+
ok: false,
|
|
353
|
+
reason: "invalid_scope",
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("a non-array requested value is invalid_request", () => {
|
|
358
|
+
expect(validateVaultScopes("not-an-array", "field-notes")).toEqual({
|
|
359
|
+
ok: false,
|
|
360
|
+
reason: "invalid_request",
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("a mixed array with a non-string entry is invalid_request, even when a valid scope leads", () => {
|
|
365
|
+
// The whole-array pre-scan (byte-exact with hub's prior parseScopesBody):
|
|
366
|
+
// a non-string entry anywhere rejects the WHOLE request as
|
|
367
|
+
// invalid_request, never a positional invalid_scope.
|
|
368
|
+
expect(validateVaultScopes(["vault:field-notes:read", 123], "field-notes")).toEqual({
|
|
369
|
+
ok: false,
|
|
370
|
+
reason: "invalid_request",
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ===========================================================================
|
|
376
|
+
// Auth gates (shared shape across the surface)
|
|
377
|
+
// ===========================================================================
|
|
378
|
+
describe("account API — auth gates", () => {
|
|
379
|
+
test("401 with no Authorization header", async () => {
|
|
380
|
+
const h = makeHarness();
|
|
381
|
+
try {
|
|
382
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", null), deps(h));
|
|
383
|
+
expect(res.status).toBe(401);
|
|
384
|
+
// H1.3 — pin the CURRENT shape: auth-gate failures on /account/* emit
|
|
385
|
+
// {error, error_description} (door-contract account-contract.ts:244-246),
|
|
386
|
+
// via the shared `adminAuthErrorResponse` translator.
|
|
387
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
388
|
+
expect(body.error).toBe("invalid_token");
|
|
389
|
+
expect(typeof body.error_description).toBe("string");
|
|
390
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
391
|
+
} finally {
|
|
392
|
+
h.cleanup();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("403 when the token carries neither account:self:* nor host:admin", async () => {
|
|
397
|
+
const h = makeHarness();
|
|
398
|
+
try {
|
|
399
|
+
const token = await bearer(h, ["vault:beta:read"]);
|
|
400
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
401
|
+
expect(res.status).toBe(403);
|
|
402
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
403
|
+
expect(body.error).toBe("insufficient_scope");
|
|
404
|
+
expect(typeof body.error_description).toBe("string");
|
|
405
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
406
|
+
} finally {
|
|
407
|
+
h.cleanup();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("a plain parachute:host:admin token is accepted (H1-independent)", async () => {
|
|
412
|
+
const h = makeHarness();
|
|
413
|
+
try {
|
|
414
|
+
const token = await bearer(h, [HOST_ADMIN_SCOPE]);
|
|
415
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
416
|
+
expect(res.status).toBe(200);
|
|
417
|
+
} finally {
|
|
418
|
+
h.cleanup();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("an account:self:admin token is accepted", async () => {
|
|
423
|
+
const h = makeHarness();
|
|
424
|
+
try {
|
|
425
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
426
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
427
|
+
expect(res.status).toBe(200);
|
|
428
|
+
} finally {
|
|
429
|
+
h.cleanup();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("account:self:read is accepted on reads but rejected (403) on mutations", async () => {
|
|
434
|
+
const h = makeHarness();
|
|
435
|
+
try {
|
|
436
|
+
const token = await bearer(h, [ACCOUNT_READ_SCOPE]);
|
|
437
|
+
const read = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
438
|
+
expect(read.status).toBe(200);
|
|
439
|
+
const write = await handleAccountCreateVault(
|
|
440
|
+
jsonReq("/account/vaults", token, "POST", { name: "nope" }),
|
|
441
|
+
deps(h),
|
|
442
|
+
);
|
|
443
|
+
expect(write.status).toBe(403);
|
|
444
|
+
} finally {
|
|
445
|
+
h.cleanup();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ===========================================================================
|
|
451
|
+
// GET /account/vaults — list
|
|
452
|
+
// ===========================================================================
|
|
453
|
+
describe("handleAccountListVaults", () => {
|
|
454
|
+
test("lists every registered vault with url + version + caps", async () => {
|
|
455
|
+
const h = makeHarness(["alpha", "beta"]);
|
|
456
|
+
try {
|
|
457
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
458
|
+
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
459
|
+
expect(res.status).toBe(200);
|
|
460
|
+
const body = (await res.json()) as {
|
|
461
|
+
vaults: Array<{
|
|
462
|
+
name: string;
|
|
463
|
+
url: string;
|
|
464
|
+
version: string;
|
|
465
|
+
caps: { cap_bytes: number | null };
|
|
466
|
+
}>;
|
|
467
|
+
};
|
|
468
|
+
const names = body.vaults.map((v) => v.name).sort();
|
|
469
|
+
expect(names).toEqual(["alpha", "beta"]);
|
|
470
|
+
const alpha = body.vaults.find((v) => v.name === "alpha");
|
|
471
|
+
expect(alpha?.url).toBe(`${ISSUER}/vault/alpha`);
|
|
472
|
+
expect(alpha?.version).toBe("0.4.2");
|
|
473
|
+
expect(alpha?.caps.cap_bytes).toBeNull();
|
|
474
|
+
} finally {
|
|
475
|
+
h.cleanup();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ===========================================================================
|
|
481
|
+
// POST /account/vaults — create (returns a ready-to-use vault token)
|
|
482
|
+
// ===========================================================================
|
|
483
|
+
describe("handleAccountCreateVault", () => {
|
|
484
|
+
test("201 returns the vault token + services block on a fresh create", async () => {
|
|
485
|
+
const h = makeHarness(["default"]);
|
|
486
|
+
try {
|
|
487
|
+
const token = await bearer(h, [HOST_ADMIN_SCOPE]);
|
|
488
|
+
const runCommand = async (_cmd: readonly string[]): Promise<RunResult> => {
|
|
489
|
+
upsertService(
|
|
490
|
+
{
|
|
491
|
+
name: "parachute-vault",
|
|
492
|
+
port: 4101,
|
|
493
|
+
paths: ["/vault/default", "/vault/work"],
|
|
494
|
+
health: "/health",
|
|
495
|
+
version: "0.4.2",
|
|
496
|
+
},
|
|
497
|
+
h.manifestPath,
|
|
498
|
+
);
|
|
499
|
+
return { exitCode: 0, stdout: vaultCreateJson("work", "hubjwt.work.access"), stderr: "" };
|
|
500
|
+
};
|
|
501
|
+
const res = await handleAccountCreateVault(
|
|
502
|
+
jsonReq("/account/vaults", token, "POST", { name: "work" }),
|
|
503
|
+
deps(h, { runCommand }),
|
|
504
|
+
);
|
|
505
|
+
expect(res.status).toBe(201);
|
|
506
|
+
const body = (await res.json()) as {
|
|
507
|
+
name: string;
|
|
508
|
+
url: string;
|
|
509
|
+
vault_token: string;
|
|
510
|
+
services: Record<string, { url: string; version: string }>;
|
|
511
|
+
};
|
|
512
|
+
expect(body.name).toBe("work");
|
|
513
|
+
expect(body.url).toBe(`${ISSUER}/vault/work`);
|
|
514
|
+
expect(body.vault_token).toBe("hubjwt.work.access");
|
|
515
|
+
expect(body.services["vault:work"]?.url).toBe(`${ISSUER}/vault/work`);
|
|
516
|
+
expect(body.services["vault:work"]?.version).toBe("0.4.2");
|
|
517
|
+
} finally {
|
|
518
|
+
h.cleanup();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("empty vault_token + token_guidance flow through so the app can fall back", async () => {
|
|
523
|
+
const h = makeHarness(["default"]);
|
|
524
|
+
try {
|
|
525
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
526
|
+
const runCommand = async (_cmd: readonly string[]): Promise<RunResult> => {
|
|
527
|
+
upsertService(
|
|
528
|
+
{
|
|
529
|
+
name: "parachute-vault",
|
|
530
|
+
port: 4101,
|
|
531
|
+
paths: ["/vault/default", "/vault/work"],
|
|
532
|
+
health: "/health",
|
|
533
|
+
version: "0.4.2",
|
|
534
|
+
},
|
|
535
|
+
h.manifestPath,
|
|
536
|
+
);
|
|
537
|
+
return {
|
|
538
|
+
exitCode: 0,
|
|
539
|
+
stdout: vaultCreateJson("work", "", "no hub origin reachable to mint against"),
|
|
540
|
+
stderr: "",
|
|
541
|
+
};
|
|
542
|
+
};
|
|
543
|
+
const res = await handleAccountCreateVault(
|
|
544
|
+
jsonReq("/account/vaults", token, "POST", { name: "work" }),
|
|
545
|
+
deps(h, { runCommand }),
|
|
546
|
+
);
|
|
547
|
+
expect(res.status).toBe(201);
|
|
548
|
+
const body = (await res.json()) as { vault_token: string; token_guidance?: string };
|
|
549
|
+
expect(body.vault_token).toBe("");
|
|
550
|
+
expect(body.token_guidance).toBe("no hub origin reachable to mint against");
|
|
551
|
+
} finally {
|
|
552
|
+
h.cleanup();
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("400 invalid_name on a missing name", async () => {
|
|
557
|
+
const h = makeHarness();
|
|
558
|
+
try {
|
|
559
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
560
|
+
const res = await handleAccountCreateVault(
|
|
561
|
+
jsonReq("/account/vaults", token, "POST", {}),
|
|
562
|
+
deps(h),
|
|
563
|
+
);
|
|
564
|
+
expect(res.status).toBe(400);
|
|
565
|
+
expect(((await res.json()) as { error: string }).error).toBe("invalid_name");
|
|
566
|
+
} finally {
|
|
567
|
+
h.cleanup();
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Q6 (hub-parity P2): create-existing-name converges on 409 vault_taken —
|
|
572
|
+
// this facade no longer answers 200-idempotent. `provisionVault` itself
|
|
573
|
+
// stays idempotent (the invite-redeem caller doesn't route through here).
|
|
574
|
+
test("409 vault_taken on an existing vault name (Q6 — no longer 200-idempotent)", async () => {
|
|
575
|
+
const h = makeHarness(["beta", "personal"]);
|
|
576
|
+
try {
|
|
577
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
578
|
+
const res = await handleAccountCreateVault(
|
|
579
|
+
jsonReq("/account/vaults", token, "POST", { name: "beta" }),
|
|
580
|
+
deps(h),
|
|
581
|
+
);
|
|
582
|
+
expect(res.status).toBe(409);
|
|
583
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
584
|
+
expect(body.error).toBe("vault_taken");
|
|
585
|
+
expect(body.message).toBe("That vault name is already taken.");
|
|
586
|
+
} finally {
|
|
587
|
+
h.cleanup();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// ===========================================================================
|
|
593
|
+
// POST /account/vaults/<name>/token — per-vault token mint
|
|
594
|
+
// ===========================================================================
|
|
595
|
+
describe("handleAccountMintVaultToken", () => {
|
|
596
|
+
test("mints a vault token with aud=vault.<name> and default read+write scope", async () => {
|
|
597
|
+
const h = makeHarness(["field-notes"]);
|
|
598
|
+
try {
|
|
599
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
600
|
+
const res = await handleAccountMintVaultToken(
|
|
601
|
+
withBearer("/account/vaults/field-notes/token", token, { method: "POST" }),
|
|
602
|
+
"field-notes",
|
|
603
|
+
deps(h),
|
|
604
|
+
);
|
|
605
|
+
expect(res.status).toBe(200);
|
|
606
|
+
const body = (await res.json()) as {
|
|
607
|
+
vault_token: string;
|
|
608
|
+
expires_at: string;
|
|
609
|
+
services: Record<string, { url: string }>;
|
|
610
|
+
};
|
|
611
|
+
expect(body.vault_token.length).toBeGreaterThan(0);
|
|
612
|
+
expect(body.services["vault:field-notes"]?.url).toBe(`${ISSUER}/vault/field-notes`);
|
|
613
|
+
// H1.2 — door-contract conformance against the live
|
|
614
|
+
// `POST /account/vaults/<name>/token` success body (V1.4/C1.4 twin
|
|
615
|
+
// coverage, hub half).
|
|
616
|
+
expect(checkVaultTokenMintResponse(body, "field-notes")).toEqual([]);
|
|
617
|
+
const validated = await validateAccessToken(h.db, body.vault_token, ISSUER);
|
|
618
|
+
expect(validated.payload.aud).toBe("vault.field-notes");
|
|
619
|
+
expect(validated.payload.scope).toBe("vault:field-notes:read vault:field-notes:write");
|
|
620
|
+
} finally {
|
|
621
|
+
h.cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("honors an explicit scopes list", async () => {
|
|
626
|
+
const h = makeHarness(["field-notes"]);
|
|
627
|
+
try {
|
|
628
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
629
|
+
const res = await handleAccountMintVaultToken(
|
|
630
|
+
jsonReq("/account/vaults/field-notes/token", token, "POST", {
|
|
631
|
+
scopes: ["vault:field-notes:read"],
|
|
632
|
+
}),
|
|
633
|
+
"field-notes",
|
|
634
|
+
deps(h),
|
|
635
|
+
);
|
|
636
|
+
expect(res.status).toBe(200);
|
|
637
|
+
const body = (await res.json()) as { vault_token: string };
|
|
638
|
+
const validated = await validateAccessToken(h.db, body.vault_token, ISSUER);
|
|
639
|
+
expect(validated.payload.scope).toBe("vault:field-notes:read");
|
|
640
|
+
} finally {
|
|
641
|
+
h.cleanup();
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("404 when the vault does not exist", async () => {
|
|
646
|
+
const h = makeHarness(["field-notes"]);
|
|
647
|
+
try {
|
|
648
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
649
|
+
const res = await handleAccountMintVaultToken(
|
|
650
|
+
withBearer("/account/vaults/ghost/token", token, { method: "POST" }),
|
|
651
|
+
"ghost",
|
|
652
|
+
deps(h),
|
|
653
|
+
);
|
|
654
|
+
expect(res.status).toBe(404);
|
|
655
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
656
|
+
expect(body.error).toBe("vault_not_found");
|
|
657
|
+
// H1.3 — pin the CURRENT shape: resource-level failures on /account/*
|
|
658
|
+
// emit {error, message} (door-contract account-contract.ts:244-246) —
|
|
659
|
+
// distinct from the auth-gate's {error, error_description} above.
|
|
660
|
+
expect(typeof body.message).toBe("string");
|
|
661
|
+
expect(body.message.length).toBeGreaterThan(0);
|
|
662
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
663
|
+
} finally {
|
|
664
|
+
h.cleanup();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("400 invalid_scope when a requested scope names another vault", async () => {
|
|
669
|
+
const h = makeHarness(["field-notes", "other"]);
|
|
670
|
+
try {
|
|
671
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
672
|
+
const res = await handleAccountMintVaultToken(
|
|
673
|
+
jsonReq("/account/vaults/field-notes/token", token, "POST", {
|
|
674
|
+
scopes: ["vault:other:read"],
|
|
675
|
+
}),
|
|
676
|
+
"field-notes",
|
|
677
|
+
deps(h),
|
|
678
|
+
);
|
|
679
|
+
expect(res.status).toBe(400);
|
|
680
|
+
expect(((await res.json()) as { error: string }).error).toBe("invalid_scope");
|
|
681
|
+
} finally {
|
|
682
|
+
h.cleanup();
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// ===========================================================================
|
|
688
|
+
// GET / PUT /account/vaults/<name>/caps
|
|
689
|
+
// ===========================================================================
|
|
690
|
+
describe("account caps", () => {
|
|
691
|
+
test("GET reports null cap for an uncapped vault; PUT sets it; GET reflects", async () => {
|
|
692
|
+
const h = makeHarness(["field-notes"]);
|
|
693
|
+
try {
|
|
694
|
+
const adminTok = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
695
|
+
const readTok = await bearer(h, [ACCOUNT_READ_SCOPE]);
|
|
696
|
+
|
|
697
|
+
const get0 = await handleAccountGetVaultCaps(
|
|
698
|
+
withBearer("/account/vaults/field-notes/caps", readTok),
|
|
699
|
+
"field-notes",
|
|
700
|
+
deps(h),
|
|
701
|
+
);
|
|
702
|
+
expect(get0.status).toBe(200);
|
|
703
|
+
expect(
|
|
704
|
+
((await get0.json()) as { caps: { cap_bytes: number | null } }).caps.cap_bytes,
|
|
705
|
+
).toBeNull();
|
|
706
|
+
|
|
707
|
+
const put = await handleAccountSetVaultCaps(
|
|
708
|
+
jsonReq("/account/vaults/field-notes/caps", adminTok, "PUT", { cap_bytes: 1048576 }),
|
|
709
|
+
"field-notes",
|
|
710
|
+
deps(h),
|
|
711
|
+
);
|
|
712
|
+
expect(put.status).toBe(200);
|
|
713
|
+
expect(getVaultCap(h.db, "field-notes")?.capBytes).toBe(1048576);
|
|
714
|
+
|
|
715
|
+
const get1 = await handleAccountGetVaultCaps(
|
|
716
|
+
withBearer("/account/vaults/field-notes/caps", readTok),
|
|
717
|
+
"field-notes",
|
|
718
|
+
deps(h),
|
|
719
|
+
);
|
|
720
|
+
expect(((await get1.json()) as { caps: { cap_bytes: number | null } }).caps.cap_bytes).toBe(
|
|
721
|
+
1048576,
|
|
722
|
+
);
|
|
723
|
+
} finally {
|
|
724
|
+
h.cleanup();
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("PUT 400 on a non-positive cap", async () => {
|
|
729
|
+
const h = makeHarness(["field-notes"]);
|
|
730
|
+
try {
|
|
731
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
732
|
+
const res = await handleAccountSetVaultCaps(
|
|
733
|
+
jsonReq("/account/vaults/field-notes/caps", token, "PUT", { cap_bytes: 0 }),
|
|
734
|
+
"field-notes",
|
|
735
|
+
deps(h),
|
|
736
|
+
);
|
|
737
|
+
expect(res.status).toBe(400);
|
|
738
|
+
} finally {
|
|
739
|
+
h.cleanup();
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("GET 404 for an unknown vault", async () => {
|
|
744
|
+
const h = makeHarness(["field-notes"]);
|
|
745
|
+
try {
|
|
746
|
+
const token = await bearer(h, [ACCOUNT_ADMIN_SCOPE]);
|
|
747
|
+
const res = await handleAccountGetVaultCaps(
|
|
748
|
+
withBearer("/account/vaults/ghost/caps", token),
|
|
749
|
+
"ghost",
|
|
750
|
+
deps(h),
|
|
751
|
+
);
|
|
752
|
+
expect(res.status).toBe(404);
|
|
753
|
+
} finally {
|
|
754
|
+
h.cleanup();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// ===========================================================================
|
|
760
|
+
// GET /account — bootstrap
|
|
761
|
+
// ===========================================================================
|
|
762
|
+
describe("handleAccountRoot", () => {
|
|
763
|
+
test("returns the contract's AccountBootstrap — {id:self, door:hub, email}", async () => {
|
|
764
|
+
const h = makeHarness();
|
|
765
|
+
try {
|
|
766
|
+
const user = await createUser(h.db, "operator", "any-password", {
|
|
767
|
+
allowMulti: true,
|
|
768
|
+
passwordChanged: true,
|
|
769
|
+
email: "op@example.com",
|
|
770
|
+
});
|
|
771
|
+
const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
|
|
772
|
+
const res = await handleAccountRoot(withBearer("/account", token), deps(h));
|
|
773
|
+
expect(res.status).toBe(200);
|
|
774
|
+
const body = (await res.json()) as { id: string; email?: string; door: string };
|
|
775
|
+
expect(body).toEqual({ id: "self", door: "hub", email: "op@example.com" });
|
|
776
|
+
} finally {
|
|
777
|
+
h.cleanup();
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("omits email when the operator row has none", async () => {
|
|
782
|
+
const h = makeHarness();
|
|
783
|
+
try {
|
|
784
|
+
const user = await createUser(h.db, "operator", "any-password", {
|
|
785
|
+
allowMulti: true,
|
|
786
|
+
passwordChanged: true,
|
|
787
|
+
});
|
|
788
|
+
const token = await bearer(h, [ACCOUNT_READ_SCOPE], user.id);
|
|
789
|
+
const res = await handleAccountRoot(withBearer("/account", token), deps(h));
|
|
790
|
+
expect(res.status).toBe(200);
|
|
791
|
+
const body = (await res.json()) as { id: string; email?: string; door: string };
|
|
792
|
+
expect(body).toEqual({ id: "self", door: "hub" });
|
|
793
|
+
expect(body.email).toBeUndefined();
|
|
794
|
+
} finally {
|
|
795
|
+
h.cleanup();
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
});
|