@openparachute/hub 0.5.9-rc.6 → 0.5.10-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 +1 -1
- package/src/__tests__/hub-server.test.ts +188 -2
- package/src/__tests__/oauth-handlers.test.ts +109 -0
- package/src/__tests__/operator-token.test.ts +52 -4
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/cli.ts +28 -0
- package/src/commands/serve.ts +157 -0
- package/src/commands/upgrade.ts +33 -6
- package/src/help.ts +44 -1
- package/src/hub-control.ts +1 -0
- package/src/hub-server.ts +230 -64
- package/src/oauth-handlers.ts +55 -0
- package/src/operator-token.ts +88 -11
- package/src/supervisor.ts +359 -0
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import { findServiceUpstream, findVaultUpstream, hubFetch, layerOf } from "../hu
|
|
|
9
9
|
import { pidPath } from "../process-state.ts";
|
|
10
10
|
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
11
11
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
12
|
+
import { createUser } from "../users.ts";
|
|
12
13
|
|
|
13
14
|
interface Harness {
|
|
14
15
|
dir: string;
|
|
@@ -846,21 +847,31 @@ describe("hubFetch routing", () => {
|
|
|
846
847
|
}
|
|
847
848
|
});
|
|
848
849
|
|
|
849
|
-
test("/oauth/authorize without configured db returns 503", async () => {
|
|
850
|
+
test("/oauth/authorize without configured db returns 503 JSON", async () => {
|
|
850
851
|
const h = makeHarness();
|
|
851
852
|
try {
|
|
852
853
|
const res = await hubFetch(h.dir)(req("/oauth/authorize?client_id=x"));
|
|
853
854
|
expect(res.status).toBe(503);
|
|
855
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
856
|
+
expect(body.error).toBe("service_unavailable");
|
|
857
|
+
expect(body.error_description).toBe("hub db not configured");
|
|
854
858
|
} finally {
|
|
855
859
|
h.cleanup();
|
|
856
860
|
}
|
|
857
861
|
});
|
|
858
862
|
|
|
859
|
-
test("every DB-dependent route returns 503 when getDb is absent (closes #139)", async () => {
|
|
863
|
+
test("every DB-dependent route returns 503 when getDb is absent (closes #139, JSON shape closes #227)", async () => {
|
|
860
864
|
const h = makeHarness();
|
|
861
865
|
try {
|
|
862
866
|
const fetch = hubFetch(h.dir);
|
|
867
|
+
// Every DB-dependent guard returns the same JSON 503 shape
|
|
868
|
+
// (`service_unavailable`) so consumers don't branch on content-type to
|
|
869
|
+
// extract the message. The pattern was already canonical on
|
|
870
|
+
// /api/auth/* (hub#215, #226) and was extended to all guards in
|
|
871
|
+
// hub#227.
|
|
863
872
|
const cases: Array<[string, RequestInit]> = [
|
|
873
|
+
["/oauth/authorize?client_id=x", { method: "GET" }],
|
|
874
|
+
["/oauth/authorize/approve", { method: "POST" }],
|
|
864
875
|
["/oauth/token", { method: "POST" }],
|
|
865
876
|
["/oauth/register", { method: "POST" }],
|
|
866
877
|
["/oauth/revoke", { method: "POST" }],
|
|
@@ -871,10 +882,23 @@ describe("hubFetch routing", () => {
|
|
|
871
882
|
["/login", { method: "POST" }],
|
|
872
883
|
["/logout", { method: "POST" }],
|
|
873
884
|
["/admin/host-admin-token", { method: "GET" }],
|
|
885
|
+
["/admin/vault-admin-token/demo", { method: "GET" }],
|
|
886
|
+
["/api/me", { method: "GET" }],
|
|
887
|
+
["/api/auth/mint-token", { method: "POST" }],
|
|
888
|
+
["/api/auth/revoke-token", { method: "POST" }],
|
|
889
|
+
["/api/auth/tokens", { method: "GET" }],
|
|
890
|
+
["/api/grants", { method: "GET" }],
|
|
891
|
+
["/api/grants/client-x", { method: "DELETE" }],
|
|
892
|
+
["/api/oauth/clients/client-x", { method: "GET" }],
|
|
893
|
+
["/api/oauth/clients/client-x/approve", { method: "POST" }],
|
|
874
894
|
];
|
|
875
895
|
for (const [path, init] of cases) {
|
|
876
896
|
const res = await fetch(req(path, init));
|
|
877
897
|
expect(res.status).toBe(503);
|
|
898
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
|
|
899
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
900
|
+
expect(body.error).toBe("service_unavailable");
|
|
901
|
+
expect(body.error_description).toBe("hub db not configured");
|
|
878
902
|
}
|
|
879
903
|
} finally {
|
|
880
904
|
h.cleanup();
|
|
@@ -886,6 +910,11 @@ describe("hubFetch routing", () => {
|
|
|
886
910
|
try {
|
|
887
911
|
const db = openHubDb(hubDbPath(h.dir));
|
|
888
912
|
try {
|
|
913
|
+
// Seed an admin so the pre-admin setup gate (hub#258) doesn't
|
|
914
|
+
// 503 the request before the OAuth method-allow check runs.
|
|
915
|
+
// OAuth routing semantics are what this test pins; the setup
|
|
916
|
+
// gate has its own coverage in src/__tests__/setup-gate.test.ts.
|
|
917
|
+
await createUser(db, "owner", "pw");
|
|
889
918
|
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
890
919
|
req("/oauth/token", { method: "GET" }),
|
|
891
920
|
);
|
|
@@ -903,6 +932,7 @@ describe("hubFetch routing", () => {
|
|
|
903
932
|
try {
|
|
904
933
|
const db = openHubDb(hubDbPath(h.dir));
|
|
905
934
|
try {
|
|
935
|
+
await createUser(db, "owner", "pw");
|
|
906
936
|
const res = await hubFetch(h.dir, {
|
|
907
937
|
getDb: () => db,
|
|
908
938
|
issuer: "https://hub.example",
|
|
@@ -924,6 +954,162 @@ describe("hubFetch routing", () => {
|
|
|
924
954
|
}
|
|
925
955
|
});
|
|
926
956
|
|
|
957
|
+
// Platform health check (hub#258). Returns 200 JSON regardless of DB
|
|
958
|
+
// state — Render et al. poll this every few seconds and a transient DB
|
|
959
|
+
// open shouldn't cascade into a restart loop. The body advertises the
|
|
960
|
+
// running version so a deploy verifier can confirm the rolled-out
|
|
961
|
+
// image is the one it expected.
|
|
962
|
+
test("/health returns 200 JSON without invoking the db", async () => {
|
|
963
|
+
const h = makeHarness();
|
|
964
|
+
try {
|
|
965
|
+
const res = await hubFetch(h.dir, {
|
|
966
|
+
getDb: () => {
|
|
967
|
+
throw new Error("getDb must not be called by /health");
|
|
968
|
+
},
|
|
969
|
+
})(req("/health"));
|
|
970
|
+
expect(res.status).toBe(200);
|
|
971
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
972
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
973
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
974
|
+
expect(body.status).toBe("ok");
|
|
975
|
+
expect(body.service).toBe("parachute-hub");
|
|
976
|
+
expect(typeof body.version).toBe("string");
|
|
977
|
+
} finally {
|
|
978
|
+
h.cleanup();
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// First-boot setup placeholder (hub#258). When no admin exists the page
|
|
983
|
+
// is the bootstrap onboarding surface; once an admin exists it 301s to
|
|
984
|
+
// /login so a stale bookmark still lands somewhere useful.
|
|
985
|
+
test("/admin/setup renders placeholder HTML when no admin exists", async () => {
|
|
986
|
+
const h = makeHarness();
|
|
987
|
+
try {
|
|
988
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
989
|
+
try {
|
|
990
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
|
|
991
|
+
expect(res.status).toBe(200);
|
|
992
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
993
|
+
const body = await res.text();
|
|
994
|
+
expect(body).toContain("first-boot setup");
|
|
995
|
+
expect(body).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
|
|
996
|
+
} finally {
|
|
997
|
+
db.close();
|
|
998
|
+
}
|
|
999
|
+
} finally {
|
|
1000
|
+
h.cleanup();
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("/admin/setup 301s to /login when an admin already exists", async () => {
|
|
1005
|
+
const h = makeHarness();
|
|
1006
|
+
try {
|
|
1007
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1008
|
+
try {
|
|
1009
|
+
await createUser(db, "owner", "pw");
|
|
1010
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
|
|
1011
|
+
expect(res.status).toBe(301);
|
|
1012
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
1013
|
+
} finally {
|
|
1014
|
+
db.close();
|
|
1015
|
+
}
|
|
1016
|
+
} finally {
|
|
1017
|
+
h.cleanup();
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// Pre-admin lockout (hub#258). When no admin row exists, operator-
|
|
1022
|
+
// facing surfaces (admin/api/login) 503 with a JSON body pointing at
|
|
1023
|
+
// /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
|
|
1024
|
+
// /admin/setup itself) stay open so the container is reachable and
|
|
1025
|
+
// OAuth third parties aren't held hostage by admin onboarding.
|
|
1026
|
+
test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
|
|
1027
|
+
const h = makeHarness();
|
|
1028
|
+
try {
|
|
1029
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1030
|
+
try {
|
|
1031
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/vaults"));
|
|
1032
|
+
expect(res.status).toBe(503);
|
|
1033
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1034
|
+
expect(body.error).toBe("setup_required");
|
|
1035
|
+
expect(body.setup_url).toBe("/admin/setup");
|
|
1036
|
+
} finally {
|
|
1037
|
+
db.close();
|
|
1038
|
+
}
|
|
1039
|
+
} finally {
|
|
1040
|
+
h.cleanup();
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
test("pre-admin lockout: /api/me returns 503 setup_required", async () => {
|
|
1045
|
+
const h = makeHarness();
|
|
1046
|
+
try {
|
|
1047
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1048
|
+
try {
|
|
1049
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1050
|
+
expect(res.status).toBe(503);
|
|
1051
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
1052
|
+
expect(body.error).toBe("setup_required");
|
|
1053
|
+
} finally {
|
|
1054
|
+
db.close();
|
|
1055
|
+
}
|
|
1056
|
+
} finally {
|
|
1057
|
+
h.cleanup();
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
|
|
1062
|
+
const h = makeHarness();
|
|
1063
|
+
try {
|
|
1064
|
+
writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
|
|
1065
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
1066
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1067
|
+
try {
|
|
1068
|
+
const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
|
|
1069
|
+
// /login gated
|
|
1070
|
+
const loginRes = await handler(req("/login"));
|
|
1071
|
+
expect(loginRes.status).toBe(503);
|
|
1072
|
+
// /admin/setup open
|
|
1073
|
+
const setupRes = await handler(req("/admin/setup"));
|
|
1074
|
+
expect(setupRes.status).toBe(200);
|
|
1075
|
+
// /health open
|
|
1076
|
+
const healthRes = await handler(req("/health"));
|
|
1077
|
+
expect(healthRes.status).toBe(200);
|
|
1078
|
+
// / open
|
|
1079
|
+
const rootRes = await handler(req("/"));
|
|
1080
|
+
expect(rootRes.status).toBe(200);
|
|
1081
|
+
// /.well-known/parachute.json open
|
|
1082
|
+
const wkRes = await handler(req("/.well-known/parachute.json"));
|
|
1083
|
+
expect(wkRes.status).toBe(200);
|
|
1084
|
+
} finally {
|
|
1085
|
+
db.close();
|
|
1086
|
+
}
|
|
1087
|
+
} finally {
|
|
1088
|
+
h.cleanup();
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test("pre-admin lockout falls away once an admin exists", async () => {
|
|
1093
|
+
const h = makeHarness();
|
|
1094
|
+
try {
|
|
1095
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1096
|
+
try {
|
|
1097
|
+
// Before: /api/me 503s under the lockout.
|
|
1098
|
+
const before = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1099
|
+
expect(before.status).toBe(503);
|
|
1100
|
+
// After seeding an admin: dispatch resumes normal handling.
|
|
1101
|
+
await createUser(db, "owner", "pw");
|
|
1102
|
+
const after = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
|
|
1103
|
+
// /api/me with no session returns `hasSession: false` 200, not 503.
|
|
1104
|
+
expect(after.status).toBe(200);
|
|
1105
|
+
} finally {
|
|
1106
|
+
db.close();
|
|
1107
|
+
}
|
|
1108
|
+
} finally {
|
|
1109
|
+
h.cleanup();
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
|
|
927
1113
|
test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
|
|
928
1114
|
const h = makeHarness();
|
|
929
1115
|
try {
|
|
@@ -3205,6 +3205,115 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
|
|
|
3205
3205
|
// to the auth-code redirect. Strict superset (incremental scope) and
|
|
3206
3206
|
// revoked grants still show consent.
|
|
3207
3207
|
describe("handleAuthorizeGet — skip consent when scope already granted (#75)", () => {
|
|
3208
|
+
// hub#236 — pin the full silent-approve flow end-to-end in one test.
|
|
3209
|
+
// The per-branch tests below this one cover individual branches (subset,
|
|
3210
|
+
// superset, revoke, unnamed-vault, re-registered-client); this test
|
|
3211
|
+
// walks the operator-visible state machine in a single body so a
|
|
3212
|
+
// regression at any step surfaces immediately, and the JSDoc on
|
|
3213
|
+
// handleAuthorizeGet's silent-approve flow (1-5) has a single load-
|
|
3214
|
+
// bearing test to point at.
|
|
3215
|
+
test("first-use consent → silent-approve → novel-scope re-prompts (full silent-approve flow, #236)", async () => {
|
|
3216
|
+
const { db, cleanup } = await makeDb();
|
|
3217
|
+
try {
|
|
3218
|
+
const user = await createUser(db, "owner", "pw");
|
|
3219
|
+
const session = createSession(db, { userId: user.id });
|
|
3220
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
3221
|
+
const { challenge } = makePkce();
|
|
3222
|
+
const sessionCookie = buildSessionCookie(session.id, 86400);
|
|
3223
|
+
|
|
3224
|
+
// Step 1: first use — no grant exists; consent screen renders.
|
|
3225
|
+
const firstReq = new Request(
|
|
3226
|
+
authorizeUrl({
|
|
3227
|
+
client_id: reg.client.clientId,
|
|
3228
|
+
redirect_uri: "https://app.example/cb",
|
|
3229
|
+
response_type: "code",
|
|
3230
|
+
scope: "vault:default:read",
|
|
3231
|
+
code_challenge: challenge,
|
|
3232
|
+
code_challenge_method: "S256",
|
|
3233
|
+
state: "step1",
|
|
3234
|
+
}),
|
|
3235
|
+
{ headers: { cookie: sessionCookie } },
|
|
3236
|
+
);
|
|
3237
|
+
const firstRes = handleAuthorizeGet(db, firstReq, { issuer: ISSUER });
|
|
3238
|
+
expect(firstRes.status).toBe(200);
|
|
3239
|
+
expect(firstRes.headers.get("content-type")).toContain("text/html");
|
|
3240
|
+
|
|
3241
|
+
// Step 1b: user approves via the consent form — grant gets recorded.
|
|
3242
|
+
const consentRes = await handleAuthorizePost(
|
|
3243
|
+
db,
|
|
3244
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
3245
|
+
method: "POST",
|
|
3246
|
+
body: new URLSearchParams({
|
|
3247
|
+
__action: "consent",
|
|
3248
|
+
__csrf: TEST_CSRF,
|
|
3249
|
+
approve: "yes",
|
|
3250
|
+
client_id: reg.client.clientId,
|
|
3251
|
+
redirect_uri: "https://app.example/cb",
|
|
3252
|
+
response_type: "code",
|
|
3253
|
+
scope: "vault:default:read",
|
|
3254
|
+
code_challenge: challenge,
|
|
3255
|
+
code_challenge_method: "S256",
|
|
3256
|
+
}),
|
|
3257
|
+
headers: {
|
|
3258
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3259
|
+
cookie: `${CSRF_COOKIE}; ${sessionCookie}`,
|
|
3260
|
+
},
|
|
3261
|
+
}),
|
|
3262
|
+
{ issuer: ISSUER },
|
|
3263
|
+
);
|
|
3264
|
+
expect(consentRes.status).toBe(302);
|
|
3265
|
+
|
|
3266
|
+
// Step 2: subsequent use, same scopes — silent-approve fires.
|
|
3267
|
+
// Authoritative assertion: 302 redirect with auth code, NOT a 200
|
|
3268
|
+
// HTML consent screen. This is the operator-visible payoff.
|
|
3269
|
+
const secondReq = new Request(
|
|
3270
|
+
authorizeUrl({
|
|
3271
|
+
client_id: reg.client.clientId,
|
|
3272
|
+
redirect_uri: "https://app.example/cb",
|
|
3273
|
+
response_type: "code",
|
|
3274
|
+
scope: "vault:default:read",
|
|
3275
|
+
code_challenge: challenge,
|
|
3276
|
+
code_challenge_method: "S256",
|
|
3277
|
+
state: "step2",
|
|
3278
|
+
}),
|
|
3279
|
+
{ headers: { cookie: sessionCookie } },
|
|
3280
|
+
);
|
|
3281
|
+
const secondRes = handleAuthorizeGet(db, secondReq, { issuer: ISSUER });
|
|
3282
|
+
expect(secondRes.status).toBe(302);
|
|
3283
|
+
const secondLoc = new URL(secondRes.headers.get("location") ?? "");
|
|
3284
|
+
expect(secondLoc.origin + secondLoc.pathname).toBe("https://app.example/cb");
|
|
3285
|
+
expect(secondLoc.searchParams.get("code")?.length).toBeGreaterThan(20);
|
|
3286
|
+
expect(secondLoc.searchParams.get("state")).toBe("step2");
|
|
3287
|
+
|
|
3288
|
+
// Step 3: subsequent use, novel scope NOT in the grant — gate must
|
|
3289
|
+
// NOT fire; consent re-renders with the new scope explicit. This is
|
|
3290
|
+
// the load-bearing security property: silent-approve must not
|
|
3291
|
+
// silently approve scopes the user never consented to.
|
|
3292
|
+
const novelReq = new Request(
|
|
3293
|
+
authorizeUrl({
|
|
3294
|
+
client_id: reg.client.clientId,
|
|
3295
|
+
redirect_uri: "https://app.example/cb",
|
|
3296
|
+
response_type: "code",
|
|
3297
|
+
// Adds scribe:transcribe to the original vault:default:read.
|
|
3298
|
+
scope: "vault:default:read scribe:transcribe",
|
|
3299
|
+
code_challenge: challenge,
|
|
3300
|
+
code_challenge_method: "S256",
|
|
3301
|
+
state: "step3",
|
|
3302
|
+
}),
|
|
3303
|
+
{ headers: { cookie: sessionCookie } },
|
|
3304
|
+
);
|
|
3305
|
+
const novelRes = handleAuthorizeGet(db, novelReq, { issuer: ISSUER });
|
|
3306
|
+
expect(novelRes.status).toBe(200);
|
|
3307
|
+
expect(novelRes.headers.get("content-type")).toContain("text/html");
|
|
3308
|
+
const novelBody = await novelRes.text();
|
|
3309
|
+
// The new scope appears on the consent page — the user must approve
|
|
3310
|
+
// it explicitly.
|
|
3311
|
+
expect(novelBody).toContain("scribe:transcribe");
|
|
3312
|
+
} finally {
|
|
3313
|
+
cleanup();
|
|
3314
|
+
}
|
|
3315
|
+
});
|
|
3316
|
+
|
|
3208
3317
|
test("first approval records grant; second flow with same scopes skips consent", async () => {
|
|
3209
3318
|
const { db, cleanup } = await makeDb();
|
|
3210
3319
|
try {
|
|
@@ -297,7 +297,7 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
|
|
|
297
297
|
issuer: TEST_ISSUER,
|
|
298
298
|
});
|
|
299
299
|
expect(used).not.toBeNull();
|
|
300
|
-
expect(used?.
|
|
300
|
+
expect(used?.status.kind).toBe("fresh");
|
|
301
301
|
expect(used?.rotated).toBeUndefined();
|
|
302
302
|
expect(used?.token).toBe(issued.token);
|
|
303
303
|
} finally {
|
|
@@ -328,7 +328,7 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
|
|
|
328
328
|
issuer: TEST_ISSUER,
|
|
329
329
|
});
|
|
330
330
|
expect(used).not.toBeNull();
|
|
331
|
-
expect(used?.
|
|
331
|
+
expect(used?.status.kind).toBe("rotated");
|
|
332
332
|
expect(used?.rotated?.scopeSet).toBe("start");
|
|
333
333
|
// The on-disk token is now the rotated one.
|
|
334
334
|
const onDisk = await readOperatorTokenFile(h.dir);
|
|
@@ -370,7 +370,10 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
|
|
|
370
370
|
issuer: TEST_ISSUER,
|
|
371
371
|
});
|
|
372
372
|
expect(used).not.toBeNull();
|
|
373
|
-
expect(used?.
|
|
373
|
+
expect(used?.status.kind).toBe("skipped");
|
|
374
|
+
if (used?.status.kind === "skipped") {
|
|
375
|
+
expect(used.status.reason).toBe("aud-mismatch");
|
|
376
|
+
}
|
|
374
377
|
expect(used?.rotated).toBeUndefined();
|
|
375
378
|
expect(used?.token).toBe(signed.token);
|
|
376
379
|
// On-disk file unchanged.
|
|
@@ -384,6 +387,51 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
|
|
|
384
387
|
}
|
|
385
388
|
});
|
|
386
389
|
|
|
390
|
+
// hub#224 — when a JWT carries the operator audience + short TTL but
|
|
391
|
+
// lacks a recognized `pa_scope_set` claim, the helper now refuses to
|
|
392
|
+
// auto-rotate. Pre-hardening the fallback widened to admin; the test
|
|
393
|
+
// pins the new "skipped, no-scope-set" outcome.
|
|
394
|
+
test("does NOT auto-rotate an aud=operator token that lacks pa_scope_set (no silent widening)", async () => {
|
|
395
|
+
const h = makeHarness();
|
|
396
|
+
try {
|
|
397
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
398
|
+
try {
|
|
399
|
+
rotateSigningKey(db);
|
|
400
|
+
// aud=operator + 1h TTL + NO pa_scope_set claim. Pre-#224 this would
|
|
401
|
+
// fall back to OPERATOR_TOKEN_DEFAULT_SCOPE_SET (admin) on rotation
|
|
402
|
+
// — a silent widening of a token of unknown provenance.
|
|
403
|
+
const signed = await signAccessToken(db, {
|
|
404
|
+
sub: "user-abc",
|
|
405
|
+
scopes: ["scribe:transcribe"],
|
|
406
|
+
audience: OPERATOR_TOKEN_AUDIENCE,
|
|
407
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
408
|
+
issuer: TEST_ISSUER,
|
|
409
|
+
ttlSeconds: 3600,
|
|
410
|
+
});
|
|
411
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
412
|
+
|
|
413
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
414
|
+
configDir: h.dir,
|
|
415
|
+
issuer: TEST_ISSUER,
|
|
416
|
+
});
|
|
417
|
+
expect(used).not.toBeNull();
|
|
418
|
+
expect(used?.status.kind).toBe("skipped");
|
|
419
|
+
if (used?.status.kind === "skipped") {
|
|
420
|
+
expect(used.status.reason).toBe("no-scope-set");
|
|
421
|
+
}
|
|
422
|
+
expect(used?.rotated).toBeUndefined();
|
|
423
|
+
expect(used?.token).toBe(signed.token);
|
|
424
|
+
// On-disk file unchanged — no widening occurred.
|
|
425
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
426
|
+
expect(onDisk).toBe(signed.token);
|
|
427
|
+
} finally {
|
|
428
|
+
db.close();
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
h.cleanup();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
387
435
|
test("returns null when no operator token file exists", async () => {
|
|
388
436
|
const h = makeHarness();
|
|
389
437
|
try {
|
|
@@ -488,7 +536,7 @@ describe("mintOperatorToken registry write (#212)", () => {
|
|
|
488
536
|
configDir: h.dir,
|
|
489
537
|
issuer: TEST_ISSUER,
|
|
490
538
|
});
|
|
491
|
-
expect(used?.
|
|
539
|
+
expect(used?.status.kind).toBe("rotated");
|
|
492
540
|
// The rotated token has a new jti.
|
|
493
541
|
const newJti = used!.payload.jti as string;
|
|
494
542
|
expect(newJti).not.toBe(original.jti);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { seedInitialAdminIfNeeded } from "../commands/serve.ts";
|
|
6
|
+
import { openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { userCount } from "../users.ts";
|
|
8
|
+
|
|
9
|
+
describe("seedInitialAdminIfNeeded", () => {
|
|
10
|
+
let dir: string;
|
|
11
|
+
let dbPath: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
dir = mkdtempSync(join(tmpdir(), "parachute-serve-"));
|
|
15
|
+
dbPath = join(dir, "hub.db");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns 'needs-setup' on fresh state with no env vars", async () => {
|
|
23
|
+
const db = openHubDb(dbPath);
|
|
24
|
+
const result = await seedInitialAdminIfNeeded(db, {}, () => {});
|
|
25
|
+
expect(result).toBe("needs-setup");
|
|
26
|
+
expect(userCount(db)).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("seeds an admin from PARACHUTE_INITIAL_ADMIN_* on fresh state", async () => {
|
|
30
|
+
const db = openHubDb(dbPath);
|
|
31
|
+
const log = mock<(line: string) => void>(() => {});
|
|
32
|
+
const result = await seedInitialAdminIfNeeded(
|
|
33
|
+
db,
|
|
34
|
+
{
|
|
35
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
|
|
36
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "correct horse battery staple",
|
|
37
|
+
},
|
|
38
|
+
log,
|
|
39
|
+
);
|
|
40
|
+
expect(result).toBe("seeded");
|
|
41
|
+
expect(userCount(db)).toBe(1);
|
|
42
|
+
// The log line carries the username so operators can grep container
|
|
43
|
+
// logs to verify the seed fired.
|
|
44
|
+
expect(log).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(log.mock.calls[0]?.[0] ?? "").toContain("seeded initial admin");
|
|
46
|
+
expect(log.mock.calls[0]?.[0] ?? "").toContain("ops");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns 'exists' when an admin already exists, even with env vars set", async () => {
|
|
50
|
+
// Seed once.
|
|
51
|
+
const db = openHubDb(dbPath);
|
|
52
|
+
await seedInitialAdminIfNeeded(
|
|
53
|
+
db,
|
|
54
|
+
{
|
|
55
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
|
|
56
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "first-pw",
|
|
57
|
+
},
|
|
58
|
+
() => {},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Same env on a second boot must NOT clobber the existing admin —
|
|
62
|
+
// the seed is first-boot only. (Container restart with the env still
|
|
63
|
+
// set from the Render dashboard is the canonical second-boot.)
|
|
64
|
+
const result = await seedInitialAdminIfNeeded(
|
|
65
|
+
db,
|
|
66
|
+
{
|
|
67
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
|
|
68
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "different-pw",
|
|
69
|
+
},
|
|
70
|
+
() => {},
|
|
71
|
+
);
|
|
72
|
+
expect(result).toBe("exists");
|
|
73
|
+
expect(userCount(db)).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("treats whitespace-only username as missing (needs-setup)", async () => {
|
|
77
|
+
const db = openHubDb(dbPath);
|
|
78
|
+
const result = await seedInitialAdminIfNeeded(
|
|
79
|
+
db,
|
|
80
|
+
{
|
|
81
|
+
PARACHUTE_INITIAL_ADMIN_USERNAME: " ",
|
|
82
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD: "pw",
|
|
83
|
+
},
|
|
84
|
+
() => {},
|
|
85
|
+
);
|
|
86
|
+
expect(result).toBe("needs-setup");
|
|
87
|
+
expect(userCount(db)).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("requires both username and password — half-set env is needs-setup", async () => {
|
|
91
|
+
const db = openHubDb(dbPath);
|
|
92
|
+
const result = await seedInitialAdminIfNeeded(
|
|
93
|
+
db,
|
|
94
|
+
{ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops" },
|
|
95
|
+
() => {},
|
|
96
|
+
);
|
|
97
|
+
expect(result).toBe("needs-setup");
|
|
98
|
+
expect(userCount(db)).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|