@openparachute/hub 0.7.4-rc.1 → 0.7.4-rc.10
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 +4 -11
- package/src/__tests__/admin-vaults.test.ts +164 -1
- package/src/__tests__/api-account-2fa.test.ts +381 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/clients.test.ts +28 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +488 -0
- package/src/__tests__/oauth-ui.test.ts +55 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/status-supervisor.test.ts +152 -3
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-vaults.ts +7 -12
- package/src/api-account-2fa.ts +373 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/cli.ts +27 -5
- package/src/clients.ts +14 -0
- package/src/commands/init.ts +108 -0
- package/src/commands/status.ts +108 -5
- package/src/help.ts +12 -1
- package/src/hub-server.ts +72 -7
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +98 -6
- package/src/oauth-ui.ts +123 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
- package/web/ui/dist/index.html +2 -2
|
@@ -151,7 +151,10 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
151
151
|
const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
|
|
152
152
|
|
|
153
153
|
test("expands a loopback-rooted URI onto every other hub origin", () => {
|
|
154
|
-
const out = expandRedirectUrisForHubOrigins(
|
|
154
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
155
|
+
[`${LOOPBACK}/surface/notes/oauth/callback`],
|
|
156
|
+
hubOrigins,
|
|
157
|
+
);
|
|
155
158
|
// Original is preserved + the public + localhost variants are added.
|
|
156
159
|
expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
157
160
|
expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
@@ -188,10 +191,7 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
188
191
|
});
|
|
189
192
|
|
|
190
193
|
test("single known hub origin → no expansion (submitted set returned as-is)", () => {
|
|
191
|
-
const out = expandRedirectUrisForHubOrigins(
|
|
192
|
-
[`${LOOPBACK}/surface/notes/`],
|
|
193
|
-
[LOOPBACK],
|
|
194
|
-
);
|
|
194
|
+
const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/`], [LOOPBACK]);
|
|
195
195
|
expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
|
|
196
196
|
});
|
|
197
197
|
|
|
@@ -222,9 +222,9 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
222
222
|
const r = registerClient(db, { redirectUris: expanded });
|
|
223
223
|
// The public-origin variant now matches exactly at authorize time — the
|
|
224
224
|
// off-localhost sign-in that surface#118 broke.
|
|
225
|
-
expect(
|
|
226
|
-
|
|
227
|
-
)
|
|
225
|
+
expect(requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`)).toBe(
|
|
226
|
+
`${PUBLIC}/surface/notes/oauth/callback`,
|
|
227
|
+
);
|
|
228
228
|
// A truly-unregistered URI is still rejected — strict match unchanged.
|
|
229
229
|
expect(() =>
|
|
230
230
|
requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
|
|
@@ -352,4 +352,24 @@ describe("isValidRedirectUri", () => {
|
|
|
352
352
|
expect(isValidRedirectUri("/relative")).toBe(false);
|
|
353
353
|
expect(isValidRedirectUri("not a url")).toBe(false);
|
|
354
354
|
});
|
|
355
|
+
// hub#663: spec-forbidden shapes that the protocol allowlist alone passed.
|
|
356
|
+
test("rejects userinfo-bearing redirect URIs (hub#663)", () => {
|
|
357
|
+
expect(isValidRedirectUri("https://x@evil.com/cb")).toBe(false);
|
|
358
|
+
expect(isValidRedirectUri("https://user:pass@evil.com/cb")).toBe(false);
|
|
359
|
+
expect(isValidRedirectUri("http://attacker@127.0.0.1:3000/cb")).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
test("rejects control chars in the raw input (hub#663)", () => {
|
|
362
|
+
// Control chars must be caught on the RAW string — URL parsing would
|
|
363
|
+
// otherwise strip a trailing \r\n and the smuggled value would pass.
|
|
364
|
+
expect(isValidRedirectUri("https://example.com/cb\r\nSet-Cookie: x")).toBe(false);
|
|
365
|
+
expect(isValidRedirectUri("https://example.com/\x00cb")).toBe(false);
|
|
366
|
+
expect(isValidRedirectUri("https://example.com/cb\x7f")).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
test("still accepts clean http(s) with ports, paths, and queries (regression guard)", () => {
|
|
369
|
+
// Legitimate clients (hub modules, self-built surfaces, Notes, Claude DCR)
|
|
370
|
+
// all register clean URIs — these must keep passing.
|
|
371
|
+
expect(isValidRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true);
|
|
372
|
+
expect(isValidRedirectUri("http://localhost:1939/admin/oauth/callback")).toBe(true);
|
|
373
|
+
expect(isValidRedirectUri("https://my-surface.github.io/cb?x=1")).toBe(true);
|
|
374
|
+
});
|
|
355
375
|
});
|
|
@@ -258,8 +258,10 @@ describe("installConnectorService — Linux systemd", () => {
|
|
|
258
258
|
platform: "linux",
|
|
259
259
|
getuid: () => 1000,
|
|
260
260
|
userName: () => "op",
|
|
261
|
-
//
|
|
261
|
+
// #528 probe: show-user → Linger=no (off, so we proceed to enable);
|
|
262
|
+
// then enable-linger FAIL, daemon-reload OK, enable --now OK.
|
|
262
263
|
runResults: [
|
|
264
|
+
{ code: 0, stdout: "Linger=no\n", stderr: "" },
|
|
263
265
|
{ code: 1, stdout: "", stderr: "Failed to enable linger" },
|
|
264
266
|
{ code: 0, stdout: "", stderr: "" },
|
|
265
267
|
{ code: 0, stdout: "", stderr: "" },
|
|
@@ -3,10 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import {
|
|
7
|
-
_resetBootstrapTokenForTests,
|
|
8
|
-
generateBootstrapToken,
|
|
9
|
-
} from "../bootstrap-token.ts";
|
|
6
|
+
import { _resetBootstrapTokenForTests, generateBootstrapToken } from "../bootstrap-token.ts";
|
|
10
7
|
import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
11
8
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
12
9
|
import { createDbHolder } from "../hub-db-liveness.ts";
|
|
@@ -3715,7 +3712,9 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
|
|
|
3715
3712
|
// flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
|
|
3716
3713
|
test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
|
|
3717
3714
|
expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
|
|
3718
|
-
expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
|
|
3715
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
|
|
3716
|
+
"public",
|
|
3717
|
+
);
|
|
3719
3718
|
});
|
|
3720
3719
|
|
|
3721
3720
|
// The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
|
|
@@ -6090,3 +6089,126 @@ describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + C
|
|
|
6090
6089
|
}
|
|
6091
6090
|
});
|
|
6092
6091
|
});
|
|
6092
|
+
|
|
6093
|
+
// hub#643 (Tier-1): non-script security headers on proxied module/surface
|
|
6094
|
+
// text/html pages. The vault content proxy and the generic services-mount
|
|
6095
|
+
// proxy both flow through `decorateWithChrome`, so the headers land on both.
|
|
6096
|
+
// DELIBERATELY no `script-src` — a strict script-src would white-screen
|
|
6097
|
+
// self-built GitHub-hosted surfaces + inline-script module pages (that's the
|
|
6098
|
+
// deferred Tier-2). Header-only: non-HTML proxied responses are NOT decorated.
|
|
6099
|
+
describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
|
|
6100
|
+
const TIER1_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
|
|
6101
|
+
|
|
6102
|
+
// Live upstream that echoes a fixed content-type + body so the test can
|
|
6103
|
+
// exercise both the text/html (decorated) and JSON (untouched) branches.
|
|
6104
|
+
function startUpstream(contentType: string, body: string): { port: number; stop: () => void } {
|
|
6105
|
+
const server = Bun.serve({
|
|
6106
|
+
port: 0,
|
|
6107
|
+
hostname: "127.0.0.1",
|
|
6108
|
+
fetch: () => new Response(body, { status: 200, headers: { "content-type": contentType } }),
|
|
6109
|
+
});
|
|
6110
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
6111
|
+
}
|
|
6112
|
+
|
|
6113
|
+
test("decorates a proxied text/html generic-mount page with nosniff + the Tier-1 CSP", async () => {
|
|
6114
|
+
const h = makeHarness();
|
|
6115
|
+
const upstream = startUpstream(
|
|
6116
|
+
"text/html; charset=utf-8",
|
|
6117
|
+
"<html><body><h1>my surface</h1></body></html>",
|
|
6118
|
+
);
|
|
6119
|
+
try {
|
|
6120
|
+
writeManifest(
|
|
6121
|
+
{
|
|
6122
|
+
services: [
|
|
6123
|
+
{
|
|
6124
|
+
name: "parachute-surface",
|
|
6125
|
+
port: upstream.port,
|
|
6126
|
+
paths: ["/surface"],
|
|
6127
|
+
health: "/surface/health",
|
|
6128
|
+
version: "0.2.0",
|
|
6129
|
+
},
|
|
6130
|
+
],
|
|
6131
|
+
},
|
|
6132
|
+
h.manifestPath,
|
|
6133
|
+
);
|
|
6134
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/foo"));
|
|
6135
|
+
expect(res.status).toBe(200);
|
|
6136
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
6137
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
6138
|
+
const csp = res.headers.get("content-security-policy");
|
|
6139
|
+
expect(csp).toBe(TIER1_CSP);
|
|
6140
|
+
// The critical Tier-1/Tier-2 boundary: NO script-src — self-built
|
|
6141
|
+
// GitHub-hosted surfaces + inline-script module pages must stay
|
|
6142
|
+
// unrestricted. A strict script-src is the deferred Tier-2.
|
|
6143
|
+
expect(csp).not.toContain("script-src");
|
|
6144
|
+
} finally {
|
|
6145
|
+
upstream.stop();
|
|
6146
|
+
h.cleanup();
|
|
6147
|
+
}
|
|
6148
|
+
});
|
|
6149
|
+
|
|
6150
|
+
test("decorates a proxied text/html per-vault page (the Notes-PWA path) with the same headers", async () => {
|
|
6151
|
+
const h = makeHarness();
|
|
6152
|
+
const upstream = startUpstream("text/html; charset=utf-8", "<html><body>notes</body></html>");
|
|
6153
|
+
try {
|
|
6154
|
+
writeManifest(
|
|
6155
|
+
{
|
|
6156
|
+
services: [
|
|
6157
|
+
{
|
|
6158
|
+
name: "parachute-vault",
|
|
6159
|
+
port: upstream.port,
|
|
6160
|
+
paths: ["/vault/default"],
|
|
6161
|
+
health: "/vault/default/health",
|
|
6162
|
+
version: "0.4.0",
|
|
6163
|
+
},
|
|
6164
|
+
],
|
|
6165
|
+
},
|
|
6166
|
+
h.manifestPath,
|
|
6167
|
+
);
|
|
6168
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
6169
|
+
req("/vault/default/some-page"),
|
|
6170
|
+
);
|
|
6171
|
+
expect(res.status).toBe(200);
|
|
6172
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
6173
|
+
expect(res.headers.get("content-security-policy")).toBe(TIER1_CSP);
|
|
6174
|
+
expect(res.headers.get("content-security-policy")).not.toContain("script-src");
|
|
6175
|
+
} finally {
|
|
6176
|
+
upstream.stop();
|
|
6177
|
+
h.cleanup();
|
|
6178
|
+
}
|
|
6179
|
+
});
|
|
6180
|
+
|
|
6181
|
+
test("leaves a proxied NON-HTML response (JSON) undecorated", async () => {
|
|
6182
|
+
const h = makeHarness();
|
|
6183
|
+
const upstream = startUpstream("application/json", JSON.stringify({ ok: true }));
|
|
6184
|
+
try {
|
|
6185
|
+
writeManifest(
|
|
6186
|
+
{
|
|
6187
|
+
services: [
|
|
6188
|
+
{
|
|
6189
|
+
name: "parachute-surface",
|
|
6190
|
+
port: upstream.port,
|
|
6191
|
+
paths: ["/surface"],
|
|
6192
|
+
health: "/surface/health",
|
|
6193
|
+
version: "0.2.0",
|
|
6194
|
+
},
|
|
6195
|
+
],
|
|
6196
|
+
},
|
|
6197
|
+
h.manifestPath,
|
|
6198
|
+
);
|
|
6199
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/api/data"));
|
|
6200
|
+
expect(res.status).toBe(200);
|
|
6201
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
6202
|
+
// No HTML CSP on a JSON API response (proves the header is gated on
|
|
6203
|
+
// content-type, so a `.js` asset proxied through the same path is also
|
|
6204
|
+
// left alone).
|
|
6205
|
+
expect(res.headers.get("content-security-policy")).toBeNull();
|
|
6206
|
+
expect(res.headers.get("x-content-type-options")).toBeNull();
|
|
6207
|
+
const body = (await res.json()) as { ok: boolean };
|
|
6208
|
+
expect(body.ok).toBe(true);
|
|
6209
|
+
} finally {
|
|
6210
|
+
upstream.stop();
|
|
6211
|
+
h.cleanup();
|
|
6212
|
+
}
|
|
6213
|
+
});
|
|
6214
|
+
});
|
|
@@ -2218,5 +2218,158 @@ describe("resolveInitChannel (hub#694 bug 2)", () => {
|
|
|
2218
2218
|
});
|
|
2219
2219
|
});
|
|
2220
2220
|
|
|
2221
|
+
// ---------------------------------------------------------------------------
|
|
2222
|
+
// #478 Part 2 — `parachute init --vault-name <name>` creates the first vault
|
|
2223
|
+
// ---------------------------------------------------------------------------
|
|
2224
|
+
|
|
2225
|
+
describe("init --vault-name (#478 Part 2)", () => {
|
|
2226
|
+
/** Minimal stub that satisfies every init seam except the ones under test. */
|
|
2227
|
+
function baseOpts(
|
|
2228
|
+
h: Harness,
|
|
2229
|
+
overrides: Parameters<typeof init>[0] = {},
|
|
2230
|
+
): Parameters<typeof init>[0] {
|
|
2231
|
+
return {
|
|
2232
|
+
configDir: h.configDir,
|
|
2233
|
+
manifestPath: h.manifestPath,
|
|
2234
|
+
log: () => {},
|
|
2235
|
+
alive: () => false,
|
|
2236
|
+
ensureHubVersion: async () => ({
|
|
2237
|
+
outcome: "match" as const,
|
|
2238
|
+
installedVersion: "test",
|
|
2239
|
+
messages: [],
|
|
2240
|
+
}),
|
|
2241
|
+
ensureHub: async () => {
|
|
2242
|
+
writeHubPort(1939, h.configDir);
|
|
2243
|
+
return { pid: 0, port: 1939, started: true };
|
|
2244
|
+
},
|
|
2245
|
+
readExposeStateFn: () => undefined,
|
|
2246
|
+
isTty: false,
|
|
2247
|
+
platform: "linux" as const,
|
|
2248
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
2249
|
+
noBrowser: true,
|
|
2250
|
+
noExposePrompt: true,
|
|
2251
|
+
noWizardPrompt: true,
|
|
2252
|
+
...overrides,
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
test("(a) with vaultName set: invokes createFirstVaultImpl with the name", async () => {
|
|
2257
|
+
const h = makeHarness();
|
|
2258
|
+
try {
|
|
2259
|
+
const createCalls: string[] = [];
|
|
2260
|
+
const logs: string[] = [];
|
|
2261
|
+
const code = await init(
|
|
2262
|
+
baseOpts(h, {
|
|
2263
|
+
vaultName: "myvault",
|
|
2264
|
+
log: (l) => logs.push(l),
|
|
2265
|
+
createFirstVaultImpl: async (name) => {
|
|
2266
|
+
createCalls.push(name);
|
|
2267
|
+
return 0;
|
|
2268
|
+
},
|
|
2269
|
+
}),
|
|
2270
|
+
);
|
|
2271
|
+
expect(code).toBe(0);
|
|
2272
|
+
expect(createCalls).toEqual(["myvault"]);
|
|
2273
|
+
expect(logs.join("\n")).toContain('Creating vault "myvault"');
|
|
2274
|
+
expect(logs.join("\n")).toContain('Vault "myvault" created');
|
|
2275
|
+
} finally {
|
|
2276
|
+
h.cleanup();
|
|
2277
|
+
}
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
test("(b) without vaultName: does NOT invoke createFirstVaultImpl", async () => {
|
|
2281
|
+
const h = makeHarness();
|
|
2282
|
+
try {
|
|
2283
|
+
let createCalled = false;
|
|
2284
|
+
const code = await init(
|
|
2285
|
+
baseOpts(h, {
|
|
2286
|
+
// no vaultName set
|
|
2287
|
+
createFirstVaultImpl: async () => {
|
|
2288
|
+
createCalled = true;
|
|
2289
|
+
return 0;
|
|
2290
|
+
},
|
|
2291
|
+
}),
|
|
2292
|
+
);
|
|
2293
|
+
expect(code).toBe(0);
|
|
2294
|
+
expect(createCalled).toBe(false);
|
|
2295
|
+
} finally {
|
|
2296
|
+
h.cleanup();
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
test("(c) invalid vault name via the CLI seam: validateVaultName rejects it", () => {
|
|
2301
|
+
// The CLI validates before calling init; test the validator directly
|
|
2302
|
+
// so the unit test doesn't need to drive argv parsing. The validator
|
|
2303
|
+
// is the same one the CLI uses (imported in cli.ts).
|
|
2304
|
+
const { validateVaultName } = require("../vault-name.ts");
|
|
2305
|
+
const result = validateVaultName("My Vault!");
|
|
2306
|
+
expect(result.ok).toBe(false);
|
|
2307
|
+
expect(result.error).toMatch(/lowercase alphanumeric/);
|
|
2308
|
+
});
|
|
2309
|
+
|
|
2310
|
+
test("(d) a seeded services.json vault MODULE row does NOT suppress the create", async () => {
|
|
2311
|
+
// REGRESSION (the rc.7 verification bug): Step 0.5's `install("vault",
|
|
2312
|
+
// { noCreate: true })` seeds a `parachute-vault` services.json row via
|
|
2313
|
+
// `spec.seedEntry` on EVERY fresh install. The OLD Step 1.6 keyed
|
|
2314
|
+
// idempotency off that row, so on the exact fresh-box path this feature
|
|
2315
|
+
// targets it saw the row + silently no-op'd the create — the headline
|
|
2316
|
+
// feature never fired. The row marks "module installed", not "instance
|
|
2317
|
+
// exists". Idempotency must live in `parachute-vault create`'s own exit
|
|
2318
|
+
// (which errors "already exists" on a real re-run), NOT a row precheck.
|
|
2319
|
+
// So: a seeded module row must NOT prevent the create from being attempted.
|
|
2320
|
+
const h = makeHarness();
|
|
2321
|
+
try {
|
|
2322
|
+
// Seed services.json with the module row (as Step 0.5 always does).
|
|
2323
|
+
seedVault(h.manifestPath);
|
|
2324
|
+
const createCalls: string[] = [];
|
|
2325
|
+
const logs: string[] = [];
|
|
2326
|
+
const code = await init(
|
|
2327
|
+
baseOpts(h, {
|
|
2328
|
+
vaultName: "myvault",
|
|
2329
|
+
log: (l) => logs.push(l),
|
|
2330
|
+
createFirstVaultImpl: async (name) => {
|
|
2331
|
+
createCalls.push(name);
|
|
2332
|
+
return 0;
|
|
2333
|
+
},
|
|
2334
|
+
}),
|
|
2335
|
+
);
|
|
2336
|
+
expect(code).toBe(0);
|
|
2337
|
+
// The create WAS attempted despite the seeded module row.
|
|
2338
|
+
expect(createCalls).toEqual(["myvault"]);
|
|
2339
|
+
expect(logs.join("\n")).toContain('Creating vault "myvault"');
|
|
2340
|
+
expect(logs.join("\n")).toContain('Vault "myvault" created');
|
|
2341
|
+
// No "already configured / ignored" no-op message.
|
|
2342
|
+
expect(logs.join("\n")).not.toContain("already configured");
|
|
2343
|
+
} finally {
|
|
2344
|
+
h.cleanup();
|
|
2345
|
+
}
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
test("non-zero exit from create (e.g. vault already exists): warns but init still exits 0", async () => {
|
|
2349
|
+
// A non-zero exit covers both "vault already exists" (a benign re-run, where
|
|
2350
|
+
// `parachute-vault create` errors + exits 1) and a genuine creation failure.
|
|
2351
|
+
// Either way init is non-fatal — the operator can re-run / check status.
|
|
2352
|
+
const h = makeHarness();
|
|
2353
|
+
try {
|
|
2354
|
+
const logs: string[] = [];
|
|
2355
|
+
const code = await init(
|
|
2356
|
+
baseOpts(h, {
|
|
2357
|
+
vaultName: "myvault",
|
|
2358
|
+
log: (l) => logs.push(l),
|
|
2359
|
+
createFirstVaultImpl: async () => 1,
|
|
2360
|
+
}),
|
|
2361
|
+
);
|
|
2362
|
+
// Init is non-fatal on create failure — operator can retry.
|
|
2363
|
+
expect(code).toBe(0);
|
|
2364
|
+
const joined = logs.join("\n");
|
|
2365
|
+
expect(joined).toContain("exited 1");
|
|
2366
|
+
expect(joined).toContain("may already exist, or creation failed");
|
|
2367
|
+
expect(joined).toContain("parachute vault create myvault");
|
|
2368
|
+
} finally {
|
|
2369
|
+
h.cleanup();
|
|
2370
|
+
}
|
|
2371
|
+
});
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2221
2374
|
// Type alias used only inside this test file for the heuristic test.
|
|
2222
2375
|
type ExposeChoice = "none" | "tailnet" | "cloudflare";
|
|
@@ -398,6 +398,68 @@ describe("installManagedUnit — start:boolean (§7.1)", () => {
|
|
|
398
398
|
expect(f.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]);
|
|
399
399
|
expect(f.calls.some((c) => c.includes("enable"))).toBe(false);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
// #528: a per-command fake `run` so the linger probe + enable-linger can return
|
|
403
|
+
// distinct results. Non-linger commands (systemctl daemon-reload / enable) all
|
|
404
|
+
// succeed; only the linger sequence is scripted via `linger`.
|
|
405
|
+
function lingerDeps(linger: {
|
|
406
|
+
probe?: ServiceCommandResult;
|
|
407
|
+
enable?: ServiceCommandResult;
|
|
408
|
+
}): FakeDepsState {
|
|
409
|
+
const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
|
|
410
|
+
return fakeDeps({
|
|
411
|
+
platform: "linux",
|
|
412
|
+
getuid: () => 1000,
|
|
413
|
+
userName: () => "op",
|
|
414
|
+
run: ((cmd: readonly string[]) => {
|
|
415
|
+
// `calls` is recorded by the default run; here we record into a closure
|
|
416
|
+
// list returned alongside via the returned FakeDepsState — but fakeDeps
|
|
417
|
+
// only records in its OWN default run. So push into a shared array.
|
|
418
|
+
recorded.push([...cmd]);
|
|
419
|
+
if (cmd[0] === "loginctl" && cmd[1] === "show-user") return linger.probe ?? ok;
|
|
420
|
+
if (cmd[0] === "loginctl" && cmd[1] === "enable-linger") return linger.enable ?? ok;
|
|
421
|
+
return ok;
|
|
422
|
+
}) as ManagedUnitDeps["run"],
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Shared recorder for the per-command run above (fakeDeps's own `calls` array
|
|
426
|
+
// isn't populated when we override `run`).
|
|
427
|
+
let recorded: string[][] = [];
|
|
428
|
+
|
|
429
|
+
test("#528: linger ALREADY on → no enable attempt, no warning (false-alarm fix)", () => {
|
|
430
|
+
recorded = [];
|
|
431
|
+
const f = lingerDeps({ probe: { code: 0, stdout: "Linger=yes\n", stderr: "" } });
|
|
432
|
+
const result = installManagedUnit({
|
|
433
|
+
unit: hubUnit(f.deps),
|
|
434
|
+
deps: f.deps,
|
|
435
|
+
messages: HUB_MESSAGES,
|
|
436
|
+
start: false,
|
|
437
|
+
});
|
|
438
|
+
// Probed current state...
|
|
439
|
+
expect(recorded).toContainEqual(["loginctl", "show-user", "op", "--property=Linger"]);
|
|
440
|
+
// ...and because it's already on, did NOT try to enable it.
|
|
441
|
+
expect(recorded.some((c) => c[0] === "loginctl" && c[1] === "enable-linger")).toBe(false);
|
|
442
|
+
// ...and emitted NO scary linger warning.
|
|
443
|
+
expect(result.messages).not.toContain(HUB_MESSAGES.lingerWarning);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("#528: linger OFF + enable-linger fails → warning surfaces", () => {
|
|
447
|
+
recorded = [];
|
|
448
|
+
const f = lingerDeps({
|
|
449
|
+
probe: { code: 0, stdout: "Linger=no\n", stderr: "" },
|
|
450
|
+
enable: { code: 1, stdout: "", stderr: "operation not permitted" },
|
|
451
|
+
});
|
|
452
|
+
const result = installManagedUnit({
|
|
453
|
+
unit: hubUnit(f.deps),
|
|
454
|
+
deps: f.deps,
|
|
455
|
+
messages: HUB_MESSAGES,
|
|
456
|
+
start: false,
|
|
457
|
+
});
|
|
458
|
+
// Off → did attempt to enable...
|
|
459
|
+
expect(recorded).toContainEqual(["loginctl", "enable-linger", "op"]);
|
|
460
|
+
// ...and the genuine failure warns.
|
|
461
|
+
expect(result.messages).toContain(HUB_MESSAGES.lingerWarning);
|
|
462
|
+
});
|
|
401
463
|
});
|
|
402
464
|
|
|
403
465
|
// ---------------------------------------------------------------------------
|