@openparachute/hub 0.3.0-rc.1 → 0.5.0
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 +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +712 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +519 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +652 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +242 -37
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1206 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type AuthorizeFormParams,
|
|
4
|
+
escapeHtml,
|
|
5
|
+
renderConsent,
|
|
6
|
+
renderError,
|
|
7
|
+
renderHiddenInputs,
|
|
8
|
+
renderLogin,
|
|
9
|
+
} from "../oauth-ui.ts";
|
|
10
|
+
|
|
11
|
+
const PARAMS: AuthorizeFormParams = {
|
|
12
|
+
clientId: "client-abc",
|
|
13
|
+
redirectUri: "https://app.example/cb",
|
|
14
|
+
responseType: "code",
|
|
15
|
+
scope: "vault:read vault:admin",
|
|
16
|
+
codeChallenge: "ch",
|
|
17
|
+
codeChallengeMethod: "S256",
|
|
18
|
+
state: "xyz",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const CSRF = "csrf-token-fixture";
|
|
22
|
+
|
|
23
|
+
describe("escapeHtml", () => {
|
|
24
|
+
test("escapes the five HTML metacharacters", () => {
|
|
25
|
+
expect(escapeHtml(`<script>alert("x&y'z")</script>`)).toBe(
|
|
26
|
+
"<script>alert("x&y'z")</script>",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("renderHiddenInputs", () => {
|
|
32
|
+
test("emits one hidden input per non-state field, plus state when present", () => {
|
|
33
|
+
const html = renderHiddenInputs(PARAMS);
|
|
34
|
+
expect(html).toContain('name="client_id" value="client-abc"');
|
|
35
|
+
expect(html).toContain('name="redirect_uri" value="https://app.example/cb"');
|
|
36
|
+
expect(html).toContain('name="response_type" value="code"');
|
|
37
|
+
expect(html).toContain('name="scope" value="vault:read vault:admin"');
|
|
38
|
+
expect(html).toContain('name="code_challenge" value="ch"');
|
|
39
|
+
expect(html).toContain('name="code_challenge_method" value="S256"');
|
|
40
|
+
expect(html).toContain('name="state" value="xyz"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("omits state input when state is null", () => {
|
|
44
|
+
const html = renderHiddenInputs({ ...PARAMS, state: null });
|
|
45
|
+
expect(html).not.toContain('name="state"');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("escapes hostile values into hidden inputs", () => {
|
|
49
|
+
const html = renderHiddenInputs({ ...PARAMS, state: `"><script>alert(1)</script>` });
|
|
50
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
51
|
+
expect(html).toContain("<script>");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("renderLogin", () => {
|
|
56
|
+
test("contains form, hidden inputs, and a Sign in submit", () => {
|
|
57
|
+
const html = renderLogin({ params: PARAMS, csrfToken: CSRF });
|
|
58
|
+
expect(html).toContain('action="/oauth/authorize"');
|
|
59
|
+
expect(html).toContain('name="__action" value="login"');
|
|
60
|
+
expect(html).toContain('name="username"');
|
|
61
|
+
expect(html).toContain('name="password"');
|
|
62
|
+
expect(html).toContain("Sign in");
|
|
63
|
+
// Hidden state echoed
|
|
64
|
+
expect(html).toContain('name="state" value="xyz"');
|
|
65
|
+
// Brand styling present
|
|
66
|
+
expect(html).toContain("Parachute");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("renders an error banner when errorMessage is set", () => {
|
|
70
|
+
const html = renderLogin({ params: PARAMS, csrfToken: CSRF, errorMessage: "bad pw" });
|
|
71
|
+
expect(html).toContain("error-banner");
|
|
72
|
+
expect(html).toContain("bad pw");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("escapes the error message", () => {
|
|
76
|
+
const html = renderLogin({
|
|
77
|
+
params: PARAMS,
|
|
78
|
+
csrfToken: CSRF,
|
|
79
|
+
errorMessage: "<script>x</script>",
|
|
80
|
+
});
|
|
81
|
+
expect(html).not.toContain("<script>x</script>");
|
|
82
|
+
expect(html).toContain("<script>");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("renderConsent", () => {
|
|
87
|
+
test("shows client name, client_id, and a row per scope", () => {
|
|
88
|
+
const html = renderConsent({
|
|
89
|
+
params: PARAMS,
|
|
90
|
+
csrfToken: CSRF,
|
|
91
|
+
clientId: "client-abc",
|
|
92
|
+
clientName: "MyApp",
|
|
93
|
+
scopes: ["vault:read", "vault:admin"],
|
|
94
|
+
});
|
|
95
|
+
expect(html).toContain("Authorize");
|
|
96
|
+
expect(html).toContain("MyApp");
|
|
97
|
+
expect(html).toContain("client-abc");
|
|
98
|
+
expect(html).toContain("vault:read");
|
|
99
|
+
expect(html).toContain("vault:admin");
|
|
100
|
+
// Scope explanations from the registry
|
|
101
|
+
expect(html).toContain("Read your notes");
|
|
102
|
+
expect(html).toContain("Full vault access");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("highlights admin scopes with a danger color and badge", () => {
|
|
106
|
+
const html = renderConsent({
|
|
107
|
+
params: PARAMS,
|
|
108
|
+
csrfToken: CSRF,
|
|
109
|
+
clientId: "c",
|
|
110
|
+
clientName: "App",
|
|
111
|
+
scopes: ["vault:admin"],
|
|
112
|
+
});
|
|
113
|
+
expect(html).toContain("scope-admin");
|
|
114
|
+
expect(html).toContain("badge-admin");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("renders unknown scopes verbatim with a muted explanation", () => {
|
|
118
|
+
const html = renderConsent({
|
|
119
|
+
params: PARAMS,
|
|
120
|
+
csrfToken: CSRF,
|
|
121
|
+
clientId: "c",
|
|
122
|
+
clientName: "App",
|
|
123
|
+
scopes: ["mystery.module:do-thing"],
|
|
124
|
+
});
|
|
125
|
+
expect(html).toContain("scope-unknown");
|
|
126
|
+
expect(html).toContain("mystery.module:do-thing");
|
|
127
|
+
expect(html).toContain("no built-in description");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("renders a placeholder when no scopes are requested", () => {
|
|
131
|
+
const html = renderConsent({
|
|
132
|
+
params: PARAMS,
|
|
133
|
+
csrfToken: CSRF,
|
|
134
|
+
clientId: "c",
|
|
135
|
+
clientName: "App",
|
|
136
|
+
scopes: [],
|
|
137
|
+
});
|
|
138
|
+
expect(html).toContain("scope-empty");
|
|
139
|
+
expect(html).toContain("No scopes requested");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("includes Approve and Deny buttons posting __action=consent", () => {
|
|
143
|
+
const html = renderConsent({
|
|
144
|
+
params: PARAMS,
|
|
145
|
+
csrfToken: CSRF,
|
|
146
|
+
clientId: "c",
|
|
147
|
+
clientName: "App",
|
|
148
|
+
scopes: [],
|
|
149
|
+
});
|
|
150
|
+
expect(html).toContain('name="__action" value="consent"');
|
|
151
|
+
expect(html).toContain('name="approve" value="yes"');
|
|
152
|
+
expect(html).toContain('name="approve" value="no"');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("escapes a hostile client name", () => {
|
|
156
|
+
const html = renderConsent({
|
|
157
|
+
params: PARAMS,
|
|
158
|
+
csrfToken: CSRF,
|
|
159
|
+
clientId: "c",
|
|
160
|
+
clientName: "<img src=x onerror=alert(1)>",
|
|
161
|
+
scopes: [],
|
|
162
|
+
});
|
|
163
|
+
expect(html).not.toContain("<img src=x");
|
|
164
|
+
expect(html).toContain("<img");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("renders a vault picker when vaultPicker is set", () => {
|
|
168
|
+
const html = renderConsent({
|
|
169
|
+
params: PARAMS,
|
|
170
|
+
csrfToken: CSRF,
|
|
171
|
+
clientId: "c",
|
|
172
|
+
clientName: "App",
|
|
173
|
+
scopes: ["vault:read"],
|
|
174
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work", "personal"] },
|
|
175
|
+
});
|
|
176
|
+
expect(html).toContain("Pick a vault");
|
|
177
|
+
expect(html).toContain('name="vault_pick" value="work"');
|
|
178
|
+
expect(html).toContain('name="vault_pick" value="personal"');
|
|
179
|
+
// First option pre-checked so a single-vault host doesn't force a click.
|
|
180
|
+
expect(html).toMatch(/name="vault_pick" value="work" checked/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("escapes a hostile vault name in the picker", () => {
|
|
184
|
+
const html = renderConsent({
|
|
185
|
+
params: PARAMS,
|
|
186
|
+
csrfToken: CSRF,
|
|
187
|
+
clientId: "c",
|
|
188
|
+
clientName: "App",
|
|
189
|
+
scopes: ["vault:read"],
|
|
190
|
+
vaultPicker: {
|
|
191
|
+
unnamedVerbs: ["read"],
|
|
192
|
+
availableVaults: [`evil"><script>alert(1)</script>`],
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
196
|
+
expect(html).toContain(""><script>");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("disables the Approve button when no vaults exist", () => {
|
|
200
|
+
const html = renderConsent({
|
|
201
|
+
params: PARAMS,
|
|
202
|
+
csrfToken: CSRF,
|
|
203
|
+
clientId: "c",
|
|
204
|
+
clientName: "App",
|
|
205
|
+
scopes: ["vault:read"],
|
|
206
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: [] },
|
|
207
|
+
});
|
|
208
|
+
expect(html).toContain("no vaults exist");
|
|
209
|
+
expect(html).toContain('value="yes" class="btn btn-primary" disabled');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("renderError", () => {
|
|
214
|
+
test("renders a card with title and message", () => {
|
|
215
|
+
const html = renderError({ title: "Boom", message: "something blew up", status: 400 });
|
|
216
|
+
expect(html).toContain("Boom");
|
|
217
|
+
expect(html).toContain("something blew up");
|
|
218
|
+
expect(html).toContain('class="card"');
|
|
219
|
+
// Brand mark visible so the user knows where they are
|
|
220
|
+
expect(html).toContain("Parachute");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("escapes hostile title + message", () => {
|
|
224
|
+
const html = renderError({
|
|
225
|
+
title: "<script>1</script>",
|
|
226
|
+
message: '"><img>',
|
|
227
|
+
status: 400,
|
|
228
|
+
});
|
|
229
|
+
expect(html).not.toContain("<script>1</script>");
|
|
230
|
+
expect(html).not.toContain('"><img>');
|
|
231
|
+
expect(html).toContain("<script>");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("CSS / styling guarantees", () => {
|
|
236
|
+
test("does not load fonts from a third-party CDN (privacy)", () => {
|
|
237
|
+
const html = renderLogin({ params: PARAMS, csrfToken: CSRF });
|
|
238
|
+
expect(html).not.toContain("fonts.googleapis.com");
|
|
239
|
+
expect(html).not.toContain("fonts.gstatic.com");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("sets referrer policy to no-referrer", () => {
|
|
243
|
+
expect(renderLogin({ params: PARAMS, csrfToken: CSRF })).toContain(
|
|
244
|
+
'name="referrer" content="no-referrer"',
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("declares mobile-friendly viewport", () => {
|
|
249
|
+
expect(renderLogin({ params: PARAMS, csrfToken: CSRF })).toContain(
|
|
250
|
+
'name="viewport" content="width=device-width, initial-scale=1"',
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
8
|
+
import {
|
|
9
|
+
OPERATOR_TOKEN_AUDIENCE,
|
|
10
|
+
OPERATOR_TOKEN_FILENAME,
|
|
11
|
+
OPERATOR_TOKEN_SCOPES,
|
|
12
|
+
OPERATOR_TOKEN_TTL_SECONDS,
|
|
13
|
+
issueOperatorToken,
|
|
14
|
+
mintOperatorToken,
|
|
15
|
+
operatorTokenPath,
|
|
16
|
+
readOperatorTokenFile,
|
|
17
|
+
writeOperatorTokenFile,
|
|
18
|
+
} from "../operator-token.ts";
|
|
19
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
20
|
+
|
|
21
|
+
interface Harness {
|
|
22
|
+
dir: string;
|
|
23
|
+
cleanup: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeHarness(): Harness {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-operator-"));
|
|
28
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TEST_ISSUER = "http://127.0.0.1:1939";
|
|
32
|
+
|
|
33
|
+
describe("mintOperatorToken", () => {
|
|
34
|
+
test("returns a JWT with operator audience, broad scopes, and ~1y TTL", async () => {
|
|
35
|
+
const h = makeHarness();
|
|
36
|
+
try {
|
|
37
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
38
|
+
try {
|
|
39
|
+
rotateSigningKey(db);
|
|
40
|
+
const minted = await mintOperatorToken(db, "user-abc", {
|
|
41
|
+
issuer: TEST_ISSUER,
|
|
42
|
+
now: () => new Date("2026-04-26T00:00:00Z"),
|
|
43
|
+
});
|
|
44
|
+
expect(minted.token.split(".")).toHaveLength(3);
|
|
45
|
+
const validated = await validateAccessToken(db, minted.token, TEST_ISSUER);
|
|
46
|
+
expect(validated.payload.sub).toBe("user-abc");
|
|
47
|
+
expect(validated.payload.aud).toBe(OPERATOR_TOKEN_AUDIENCE);
|
|
48
|
+
expect(validated.payload.iss).toBe(TEST_ISSUER);
|
|
49
|
+
expect(validated.payload.scope).toBe(OPERATOR_TOKEN_SCOPES.join(" "));
|
|
50
|
+
const exp = validated.payload.exp ?? 0;
|
|
51
|
+
const iat = validated.payload.iat ?? 0;
|
|
52
|
+
expect(exp - iat).toBe(OPERATOR_TOKEN_TTL_SECONDS);
|
|
53
|
+
} finally {
|
|
54
|
+
db.close();
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
h.cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("scopes include hub:admin + parachute:host:admin + vault:admin + scribe:admin + channel:send", () => {
|
|
62
|
+
expect(OPERATOR_TOKEN_SCOPES).toEqual([
|
|
63
|
+
"hub:admin",
|
|
64
|
+
"parachute:host:admin",
|
|
65
|
+
"vault:admin",
|
|
66
|
+
"scribe:admin",
|
|
67
|
+
"channel:send",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("writeOperatorTokenFile + readOperatorTokenFile", () => {
|
|
73
|
+
test("writes mode 0600 and round-trips the plaintext", async () => {
|
|
74
|
+
const h = makeHarness();
|
|
75
|
+
try {
|
|
76
|
+
const path = await writeOperatorTokenFile("plaintext-abc", h.dir);
|
|
77
|
+
expect(path).toBe(join(h.dir, OPERATOR_TOKEN_FILENAME));
|
|
78
|
+
const stat = statSync(path);
|
|
79
|
+
// Mask off file-type bits; just compare permission bits.
|
|
80
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
81
|
+
const round = await readOperatorTokenFile(h.dir);
|
|
82
|
+
expect(round).toBe("plaintext-abc");
|
|
83
|
+
} finally {
|
|
84
|
+
h.cleanup();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("readOperatorTokenFile returns null when missing", async () => {
|
|
89
|
+
const h = makeHarness();
|
|
90
|
+
try {
|
|
91
|
+
expect(await readOperatorTokenFile(h.dir)).toBeNull();
|
|
92
|
+
} finally {
|
|
93
|
+
h.cleanup();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("overwrite is atomic — second write replaces the first plaintext", async () => {
|
|
98
|
+
const h = makeHarness();
|
|
99
|
+
try {
|
|
100
|
+
await writeOperatorTokenFile("first", h.dir);
|
|
101
|
+
await writeOperatorTokenFile("second", h.dir);
|
|
102
|
+
const round = await readOperatorTokenFile(h.dir);
|
|
103
|
+
expect(round).toBe("second");
|
|
104
|
+
// No leftover .tmp
|
|
105
|
+
const tmp = `${operatorTokenPath(h.dir)}.tmp`;
|
|
106
|
+
await readFile(tmp).then(
|
|
107
|
+
() => expect.unreachable("tmp file should be renamed away"),
|
|
108
|
+
(err: NodeJS.ErrnoException) => expect(err.code).toBe("ENOENT"),
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
h.cleanup();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("issueOperatorToken", () => {
|
|
117
|
+
test("mints + writes the token to disk in one call", async () => {
|
|
118
|
+
const h = makeHarness();
|
|
119
|
+
try {
|
|
120
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
121
|
+
try {
|
|
122
|
+
rotateSigningKey(db);
|
|
123
|
+
const issued = await issueOperatorToken(db, "user-xyz", {
|
|
124
|
+
dir: h.dir,
|
|
125
|
+
issuer: TEST_ISSUER,
|
|
126
|
+
});
|
|
127
|
+
expect(issued.path).toBe(join(h.dir, OPERATOR_TOKEN_FILENAME));
|
|
128
|
+
const fromDisk = await readOperatorTokenFile(h.dir);
|
|
129
|
+
expect(fromDisk).toBe(issued.token);
|
|
130
|
+
const validated = await validateAccessToken(db, issued.token, TEST_ISSUER);
|
|
131
|
+
expect(validated.payload.sub).toBe("user-xyz");
|
|
132
|
+
expect(validated.payload.iss).toBe(TEST_ISSUER);
|
|
133
|
+
} finally {
|
|
134
|
+
db.close();
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
h.cleanup();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type ProviderAvailability,
|
|
7
|
+
detectProviders,
|
|
8
|
+
isCloudflareReady,
|
|
9
|
+
isTailnetReady,
|
|
10
|
+
} from "../providers/detect.ts";
|
|
11
|
+
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
12
|
+
|
|
13
|
+
interface FixedRunnerOpts {
|
|
14
|
+
tailscaleInstalled?: boolean;
|
|
15
|
+
tailscaleLoggedIn?: boolean;
|
|
16
|
+
tailscaleFunnelCap?: boolean;
|
|
17
|
+
cloudflaredInstalled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fixedRunner(opts: FixedRunnerOpts): { runner: Runner; calls: string[][] } {
|
|
21
|
+
const calls: string[][] = [];
|
|
22
|
+
const runner: Runner = async (cmd) => {
|
|
23
|
+
calls.push([...cmd]);
|
|
24
|
+
const head = cmd.slice(0, 2).join(" ");
|
|
25
|
+
if (head === "tailscale version") {
|
|
26
|
+
return opts.tailscaleInstalled
|
|
27
|
+
? ({ code: 0, stdout: "1.82.0\n", stderr: "" } as CommandResult)
|
|
28
|
+
: ({ code: 127, stdout: "", stderr: "not found" } as CommandResult);
|
|
29
|
+
}
|
|
30
|
+
if (head === "tailscale status") {
|
|
31
|
+
const self: Record<string, unknown> = {};
|
|
32
|
+
if (opts.tailscaleLoggedIn) self.DNSName = "host.example.ts.net.";
|
|
33
|
+
if (opts.tailscaleFunnelCap) self.CapMap = { funnel: null };
|
|
34
|
+
return { code: 0, stdout: JSON.stringify({ Self: self }), stderr: "" } as CommandResult;
|
|
35
|
+
}
|
|
36
|
+
if (head === "cloudflared --version") {
|
|
37
|
+
return opts.cloudflaredInstalled
|
|
38
|
+
? ({ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" } as CommandResult)
|
|
39
|
+
: ({ code: 127, stdout: "", stderr: "not found" } as CommandResult);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
42
|
+
};
|
|
43
|
+
return { runner, calls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeCloudflaredHome(loggedIn: boolean): { home: string; cleanup: () => void } {
|
|
47
|
+
const home = mkdtempSync(join(tmpdir(), "providers-detect-cf-"));
|
|
48
|
+
if (loggedIn) writeFileSync(join(home, "cert.pem"), "---");
|
|
49
|
+
return { home, cleanup: () => rmSync(home, { recursive: true, force: true }) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("detectProviders", () => {
|
|
53
|
+
test("everything available + logged in + funnel granted → both ready", async () => {
|
|
54
|
+
const env = makeCloudflaredHome(true);
|
|
55
|
+
try {
|
|
56
|
+
const { runner } = fixedRunner({
|
|
57
|
+
tailscaleInstalled: true,
|
|
58
|
+
tailscaleLoggedIn: true,
|
|
59
|
+
tailscaleFunnelCap: true,
|
|
60
|
+
cloudflaredInstalled: true,
|
|
61
|
+
});
|
|
62
|
+
const r = await detectProviders({ runner, cloudflaredHome: env.home });
|
|
63
|
+
expect(r).toEqual({
|
|
64
|
+
tailnet: { available: true, loggedIn: true, funnelEnabled: true },
|
|
65
|
+
cloudflare: { available: true, loggedIn: true },
|
|
66
|
+
});
|
|
67
|
+
expect(isTailnetReady(r)).toBe(true);
|
|
68
|
+
expect(isCloudflareReady(r)).toBe(true);
|
|
69
|
+
} finally {
|
|
70
|
+
env.cleanup();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("tailscale installed but Funnel cap missing → not tailnet-ready", async () => {
|
|
75
|
+
const env = makeCloudflaredHome(false);
|
|
76
|
+
try {
|
|
77
|
+
const { runner } = fixedRunner({
|
|
78
|
+
tailscaleInstalled: true,
|
|
79
|
+
tailscaleLoggedIn: true,
|
|
80
|
+
tailscaleFunnelCap: false,
|
|
81
|
+
});
|
|
82
|
+
const r = await detectProviders({ runner, cloudflaredHome: env.home });
|
|
83
|
+
expect(r.tailnet).toEqual({ available: true, loggedIn: true, funnelEnabled: false });
|
|
84
|
+
expect(isTailnetReady(r)).toBe(false);
|
|
85
|
+
} finally {
|
|
86
|
+
env.cleanup();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("cloudflared installed but no cert.pem → not cloudflare-ready", async () => {
|
|
91
|
+
const env = makeCloudflaredHome(false);
|
|
92
|
+
try {
|
|
93
|
+
const { runner } = fixedRunner({ cloudflaredInstalled: true });
|
|
94
|
+
const r = await detectProviders({ runner, cloudflaredHome: env.home });
|
|
95
|
+
expect(r.cloudflare).toEqual({ available: true, loggedIn: false });
|
|
96
|
+
expect(isCloudflareReady(r)).toBe(false);
|
|
97
|
+
} finally {
|
|
98
|
+
env.cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("neither binary installed → no `tailscale status` probe is issued", async () => {
|
|
103
|
+
const env = makeCloudflaredHome(false);
|
|
104
|
+
try {
|
|
105
|
+
const { runner, calls } = fixedRunner({});
|
|
106
|
+
const r = await detectProviders({ runner, cloudflaredHome: env.home });
|
|
107
|
+
const ran = calls.map((c) => c.slice(0, 2).join(" "));
|
|
108
|
+
// `tailscale version` is the gate; if it fails, we skip status to avoid
|
|
109
|
+
// a guaranteed-to-fail call.
|
|
110
|
+
expect(ran).toContain("tailscale version");
|
|
111
|
+
expect(ran).not.toContain("tailscale status");
|
|
112
|
+
expect(r.tailnet.available).toBe(false);
|
|
113
|
+
expect(r.cloudflare.available).toBe(false);
|
|
114
|
+
} finally {
|
|
115
|
+
env.cleanup();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("readiness predicates", () => {
|
|
121
|
+
const base: ProviderAvailability = {
|
|
122
|
+
tailnet: { available: false, loggedIn: false, funnelEnabled: false },
|
|
123
|
+
cloudflare: { available: false, loggedIn: false },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
test("isTailnetReady requires all three legs", () => {
|
|
127
|
+
expect(
|
|
128
|
+
isTailnetReady({
|
|
129
|
+
...base,
|
|
130
|
+
tailnet: { available: true, loggedIn: true, funnelEnabled: true },
|
|
131
|
+
}),
|
|
132
|
+
).toBe(true);
|
|
133
|
+
expect(
|
|
134
|
+
isTailnetReady({
|
|
135
|
+
...base,
|
|
136
|
+
tailnet: { available: true, loggedIn: true, funnelEnabled: false },
|
|
137
|
+
}),
|
|
138
|
+
).toBe(false);
|
|
139
|
+
expect(
|
|
140
|
+
isTailnetReady({
|
|
141
|
+
...base,
|
|
142
|
+
tailnet: { available: true, loggedIn: false, funnelEnabled: true },
|
|
143
|
+
}),
|
|
144
|
+
).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("isCloudflareReady requires both legs", () => {
|
|
148
|
+
expect(isCloudflareReady({ ...base, cloudflare: { available: true, loggedIn: true } })).toBe(
|
|
149
|
+
true,
|
|
150
|
+
);
|
|
151
|
+
expect(isCloudflareReady({ ...base, cloudflare: { available: true, loggedIn: false } })).toBe(
|
|
152
|
+
false,
|
|
153
|
+
);
|
|
154
|
+
expect(isCloudflareReady({ ...base, cloudflare: { available: false, loggedIn: true } })).toBe(
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
FIRST_PARTY_SCOPES,
|
|
4
|
+
NON_REQUESTABLE_SCOPES,
|
|
5
|
+
SCOPE_EXPLANATIONS,
|
|
6
|
+
explainScope,
|
|
7
|
+
isRequestableScope,
|
|
8
|
+
scopeIsAdmin,
|
|
9
|
+
} from "../scope-explanations.ts";
|
|
10
|
+
|
|
11
|
+
describe("SCOPE_EXPLANATIONS", () => {
|
|
12
|
+
test("covers every canonical first-party scope from oauth-scopes.md", () => {
|
|
13
|
+
// Source of truth: parachute-patterns/patterns/oauth-scopes.md.
|
|
14
|
+
const expected = [
|
|
15
|
+
"vault:read",
|
|
16
|
+
"vault:write",
|
|
17
|
+
"vault:admin",
|
|
18
|
+
"scribe:transcribe",
|
|
19
|
+
"scribe:admin",
|
|
20
|
+
"channel:send",
|
|
21
|
+
"hub:admin",
|
|
22
|
+
"parachute:host:admin",
|
|
23
|
+
];
|
|
24
|
+
for (const s of expected) {
|
|
25
|
+
expect(SCOPE_EXPLANATIONS[s]).toBeDefined();
|
|
26
|
+
expect(SCOPE_EXPLANATIONS[s]?.label.length).toBeGreaterThan(10);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("FIRST_PARTY_SCOPES is sorted and matches the keys of SCOPE_EXPLANATIONS", () => {
|
|
31
|
+
expect(FIRST_PARTY_SCOPES).toEqual([...FIRST_PARTY_SCOPES].sort());
|
|
32
|
+
expect(new Set(FIRST_PARTY_SCOPES)).toEqual(new Set(Object.keys(SCOPE_EXPLANATIONS)));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("explainScope", () => {
|
|
37
|
+
test("returns the entry for a known scope", () => {
|
|
38
|
+
expect(explainScope("vault:read")?.level).toBe("read");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns null for an unknown scope", () => {
|
|
42
|
+
expect(explainScope("notes:weird-thing")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("scopeIsAdmin", () => {
|
|
47
|
+
test("true for admin scopes", () => {
|
|
48
|
+
expect(scopeIsAdmin("vault:admin")).toBe(true);
|
|
49
|
+
expect(scopeIsAdmin("hub:admin")).toBe(true);
|
|
50
|
+
expect(scopeIsAdmin("parachute:host:admin")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("false for non-admin and unknown scopes", () => {
|
|
54
|
+
expect(scopeIsAdmin("vault:read")).toBe(false);
|
|
55
|
+
expect(scopeIsAdmin("channel:send")).toBe(false);
|
|
56
|
+
expect(scopeIsAdmin("unknown:anything")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("NON_REQUESTABLE_SCOPES (#96)", () => {
|
|
61
|
+
test("contains parachute:host:admin", () => {
|
|
62
|
+
expect(NON_REQUESTABLE_SCOPES.has("parachute:host:admin")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("does NOT contain hub:admin (intentional asymmetry)", () => {
|
|
66
|
+
// hub:admin is service management an operator may legitimately delegate
|
|
67
|
+
// to a tooling app. parachute:host:admin is cross-vault data sovereignty
|
|
68
|
+
// and stays operator-only-mintable.
|
|
69
|
+
expect(NON_REQUESTABLE_SCOPES.has("hub:admin")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("every non-requestable scope is a known first-party scope", () => {
|
|
73
|
+
for (const s of NON_REQUESTABLE_SCOPES) {
|
|
74
|
+
expect(FIRST_PARTY_SCOPES).toContain(s);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isRequestableScope", () => {
|
|
80
|
+
test("false for parachute:host:admin", () => {
|
|
81
|
+
expect(isRequestableScope("parachute:host:admin")).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("true for hub:admin and other first-party scopes", () => {
|
|
85
|
+
expect(isRequestableScope("hub:admin")).toBe(true);
|
|
86
|
+
expect(isRequestableScope("vault:read")).toBe(true);
|
|
87
|
+
expect(isRequestableScope("vault:admin")).toBe(true);
|
|
88
|
+
expect(isRequestableScope("channel:send")).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("true for unknown scopes (third-party module scopes pass through)", () => {
|
|
92
|
+
expect(isRequestableScope("notes:something-new")).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Per-vault admin scopes are pattern-matched as non-requestable so the
|
|
96
|
+
// public OAuth flow can never mint vault:<name>:admin — only the local
|
|
97
|
+
// session-cookie endpoint at /admin/vault-admin-token/<name> can.
|
|
98
|
+
test("false for any vault:<name>:admin scope", () => {
|
|
99
|
+
expect(isRequestableScope("vault:default:admin")).toBe(false);
|
|
100
|
+
expect(isRequestableScope("vault:work:admin")).toBe(false);
|
|
101
|
+
expect(isRequestableScope("vault:my-techne_2:admin")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("vault:<name>:read|write stays requestable (only :admin is locked down)", () => {
|
|
105
|
+
expect(isRequestableScope("vault:default:read")).toBe(true);
|
|
106
|
+
expect(isRequestableScope("vault:work:write")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|