@openparachute/hub 0.7.3 → 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__/cli.test.ts +22 -0
- 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 +507 -1
- 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 +49 -5
- package/src/clients.ts +14 -0
- package/src/commands/init.ts +224 -37
- package/src/commands/status.ts +108 -5
- package/src/help.ts +17 -0
- 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
|
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
hasNoDisplay,
|
|
7
|
+
init,
|
|
8
|
+
looksLikeServer,
|
|
9
|
+
resolveAdminUrl,
|
|
10
|
+
resolveInitChannel,
|
|
11
|
+
} from "../commands/init.ts";
|
|
6
12
|
import type { ExposeState } from "../expose-state.ts";
|
|
7
13
|
import { writeHubPort } from "../hub-control.ts";
|
|
8
14
|
import { writePid } from "../process-state.ts";
|
|
@@ -1865,5 +1871,505 @@ describe("init exposure chain", () => {
|
|
|
1865
1871
|
});
|
|
1866
1872
|
});
|
|
1867
1873
|
|
|
1874
|
+
// ---------------------------------------------------------------------------
|
|
1875
|
+
// hub#694 — first-run robustness.
|
|
1876
|
+
//
|
|
1877
|
+
// Bug 1 (the spawn race): the supervisor scans services.json EXACTLY ONCE at
|
|
1878
|
+
// hub-unit boot. So the vault module MUST be seeded into services.json BEFORE
|
|
1879
|
+
// the hub unit starts (ensureHub) — otherwise the boot scan finds no vault row
|
|
1880
|
+
// and vault never spawns until a manual `parachute restart`. These tests pin the
|
|
1881
|
+
// ordering: vault-install precedes ensureHub on both the laptop (no --hub-origin)
|
|
1882
|
+
// and the DO (--hub-origin) path, and Step-0 origin-persist stays first.
|
|
1883
|
+
//
|
|
1884
|
+
// Bug 2 (channel): init must install the vault module from the chosen channel
|
|
1885
|
+
// (`--channel rc` / PARACHUTE_CHANNEL=rc), not always @latest — otherwise an rc
|
|
1886
|
+
// box downgrades vault below the rc-tracking hub.
|
|
1887
|
+
// ---------------------------------------------------------------------------
|
|
1888
|
+
|
|
1889
|
+
describe("init hub#694 — vault seeded before the supervisor boot scan (bug 1)", () => {
|
|
1890
|
+
test("laptop (no --hub-origin): installs+seeds vault BEFORE ensureHub", async () => {
|
|
1891
|
+
const h = makeHarness();
|
|
1892
|
+
try {
|
|
1893
|
+
const order: string[] = [];
|
|
1894
|
+
const code = await init({
|
|
1895
|
+
configDir: h.configDir,
|
|
1896
|
+
ensureHubVersion: async () => ({
|
|
1897
|
+
outcome: "match" as const,
|
|
1898
|
+
installedVersion: "test",
|
|
1899
|
+
messages: [],
|
|
1900
|
+
}),
|
|
1901
|
+
manifestPath: h.manifestPath,
|
|
1902
|
+
log: () => {},
|
|
1903
|
+
alive: () => false,
|
|
1904
|
+
// The load-bearing assertion: the vault module install (which seeds the
|
|
1905
|
+
// services.json row) MUST happen before the hub unit boots, so the
|
|
1906
|
+
// supervisor's one-time boot scan finds + spawns vault on the first pass.
|
|
1907
|
+
installVaultModuleImpl: async () => {
|
|
1908
|
+
order.push("install-vault");
|
|
1909
|
+
return 0;
|
|
1910
|
+
},
|
|
1911
|
+
ensureHub: async () => {
|
|
1912
|
+
order.push("ensureHub");
|
|
1913
|
+
writeHubPort(1939, h.configDir);
|
|
1914
|
+
return { pid: 0, port: 1939, started: true };
|
|
1915
|
+
},
|
|
1916
|
+
readExposeStateFn: () => undefined,
|
|
1917
|
+
isTty: false,
|
|
1918
|
+
platform: "linux",
|
|
1919
|
+
});
|
|
1920
|
+
expect(code).toBe(0);
|
|
1921
|
+
expect(order).toEqual(["install-vault", "ensureHub"]);
|
|
1922
|
+
} finally {
|
|
1923
|
+
h.cleanup();
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
test("--hub-origin (DO path): order is set-origin → install vault → ensureHub", async () => {
|
|
1928
|
+
const h = makeHarness();
|
|
1929
|
+
try {
|
|
1930
|
+
const order: string[] = [];
|
|
1931
|
+
const code = await init({
|
|
1932
|
+
configDir: h.configDir,
|
|
1933
|
+
ensureHubVersion: async () => ({
|
|
1934
|
+
outcome: "match" as const,
|
|
1935
|
+
installedVersion: "test",
|
|
1936
|
+
messages: [],
|
|
1937
|
+
}),
|
|
1938
|
+
manifestPath: h.manifestPath,
|
|
1939
|
+
log: () => {},
|
|
1940
|
+
alive: () => false,
|
|
1941
|
+
hubOrigin: "https://box.sslip.io",
|
|
1942
|
+
// hub#693 Step-0 origin persist must remain FIRST (boot issuer + child
|
|
1943
|
+
// env read it); the hub#694 vault seed slots in AFTER it but BEFORE the
|
|
1944
|
+
// hub unit boots, so the freshly-spawned vault comes up with the public
|
|
1945
|
+
// origin in its accepted-iss set AND is present in the boot scan — in one
|
|
1946
|
+
// pass, no restart.
|
|
1947
|
+
setHubOriginImpl: (_dir, origin) => {
|
|
1948
|
+
order.push(`set-origin:${origin}`);
|
|
1949
|
+
},
|
|
1950
|
+
installVaultModuleImpl: async () => {
|
|
1951
|
+
order.push("install-vault");
|
|
1952
|
+
return 0;
|
|
1953
|
+
},
|
|
1954
|
+
ensureHub: async () => {
|
|
1955
|
+
order.push("ensureHub");
|
|
1956
|
+
writeHubPort(1939, h.configDir);
|
|
1957
|
+
return { pid: 0, port: 1939, started: true };
|
|
1958
|
+
},
|
|
1959
|
+
readExposeStateFn: () => undefined,
|
|
1960
|
+
isTty: false,
|
|
1961
|
+
platform: "linux",
|
|
1962
|
+
});
|
|
1963
|
+
expect(code).toBe(0);
|
|
1964
|
+
expect(order).toEqual(["set-origin:https://box.sslip.io", "install-vault", "ensureHub"]);
|
|
1965
|
+
} finally {
|
|
1966
|
+
h.cleanup();
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
test("real seed: vault row is in services.json by the time ensureHub runs", async () => {
|
|
1971
|
+
// End-to-end-ish: drive the REAL defaultInstallVaultModule (bun-linked vault
|
|
1972
|
+
// short-circuits the bun-add) and assert the services.json row exists when
|
|
1973
|
+
// ensureHub is invoked — i.e. the boot scan would find it.
|
|
1974
|
+
const h = makeHarness();
|
|
1975
|
+
try {
|
|
1976
|
+
let vaultRowAtEnsure: boolean | undefined;
|
|
1977
|
+
const code = await init({
|
|
1978
|
+
configDir: h.configDir,
|
|
1979
|
+
ensureHubVersion: async () => ({
|
|
1980
|
+
outcome: "match" as const,
|
|
1981
|
+
installedVersion: "test",
|
|
1982
|
+
messages: [],
|
|
1983
|
+
}),
|
|
1984
|
+
manifestPath: h.manifestPath,
|
|
1985
|
+
log: () => {},
|
|
1986
|
+
alive: () => false,
|
|
1987
|
+
ensureHub: async () => {
|
|
1988
|
+
// At hub-unit-boot time the services.json must already carry vault.
|
|
1989
|
+
const { findService } = await import("../services-manifest.ts");
|
|
1990
|
+
vaultRowAtEnsure = findService("parachute-vault", h.manifestPath) !== undefined;
|
|
1991
|
+
writeHubPort(1939, h.configDir);
|
|
1992
|
+
return { pid: 0, port: 1939, started: true };
|
|
1993
|
+
},
|
|
1994
|
+
readExposeStateFn: () => undefined,
|
|
1995
|
+
isTty: false,
|
|
1996
|
+
platform: "linux",
|
|
1997
|
+
// NOTE: no installVaultModuleImpl override — exercise the real install
|
|
1998
|
+
// so we prove the seed write lands before ensureHub, not just the stub.
|
|
1999
|
+
});
|
|
2000
|
+
expect(code).toBe(0);
|
|
2001
|
+
expect(vaultRowAtEnsure).toBe(true);
|
|
2002
|
+
} finally {
|
|
2003
|
+
h.cleanup();
|
|
2004
|
+
}
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
test("already-seeded vault: install is skipped, ensureHub still runs", async () => {
|
|
2008
|
+
const h = makeHarness();
|
|
2009
|
+
try {
|
|
2010
|
+
seedVault(h.manifestPath);
|
|
2011
|
+
const order: string[] = [];
|
|
2012
|
+
const code = await init({
|
|
2013
|
+
configDir: h.configDir,
|
|
2014
|
+
ensureHubVersion: async () => ({
|
|
2015
|
+
outcome: "match" as const,
|
|
2016
|
+
installedVersion: "test",
|
|
2017
|
+
messages: [],
|
|
2018
|
+
}),
|
|
2019
|
+
manifestPath: h.manifestPath,
|
|
2020
|
+
log: () => {},
|
|
2021
|
+
alive: () => false,
|
|
2022
|
+
installVaultModuleImpl: async () => {
|
|
2023
|
+
order.push("install-vault");
|
|
2024
|
+
return 0;
|
|
2025
|
+
},
|
|
2026
|
+
ensureHub: async () => {
|
|
2027
|
+
order.push("ensureHub");
|
|
2028
|
+
writeHubPort(1939, h.configDir);
|
|
2029
|
+
return { pid: 0, port: 1939, started: true };
|
|
2030
|
+
},
|
|
2031
|
+
readExposeStateFn: () => undefined,
|
|
2032
|
+
isTty: false,
|
|
2033
|
+
platform: "linux",
|
|
2034
|
+
});
|
|
2035
|
+
expect(code).toBe(0);
|
|
2036
|
+
// Vault already present → install short-circuits; only ensureHub fires.
|
|
2037
|
+
expect(order).toEqual(["ensureHub"]);
|
|
2038
|
+
} finally {
|
|
2039
|
+
h.cleanup();
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
describe("init hub#694 — install channel (bug 2)", () => {
|
|
2045
|
+
test("--channel rc propagates to the vault module install", async () => {
|
|
2046
|
+
const h = makeHarness();
|
|
2047
|
+
try {
|
|
2048
|
+
const channels: (string | undefined)[] = [];
|
|
2049
|
+
const code = await init({
|
|
2050
|
+
configDir: h.configDir,
|
|
2051
|
+
ensureHubVersion: async () => ({
|
|
2052
|
+
outcome: "match" as const,
|
|
2053
|
+
installedVersion: "test",
|
|
2054
|
+
messages: [],
|
|
2055
|
+
}),
|
|
2056
|
+
manifestPath: h.manifestPath,
|
|
2057
|
+
log: () => {},
|
|
2058
|
+
alive: () => false,
|
|
2059
|
+
channel: "rc",
|
|
2060
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2061
|
+
channels.push(channel);
|
|
2062
|
+
return 0;
|
|
2063
|
+
},
|
|
2064
|
+
ensureHub: async () => {
|
|
2065
|
+
writeHubPort(1939, h.configDir);
|
|
2066
|
+
return { pid: 0, port: 1939, started: true };
|
|
2067
|
+
},
|
|
2068
|
+
readExposeStateFn: () => undefined,
|
|
2069
|
+
isTty: false,
|
|
2070
|
+
platform: "linux",
|
|
2071
|
+
});
|
|
2072
|
+
expect(code).toBe(0);
|
|
2073
|
+
expect(channels).toEqual(["rc"]);
|
|
2074
|
+
} finally {
|
|
2075
|
+
h.cleanup();
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
test("PARACHUTE_CHANNEL=rc env propagates when no --channel flag", async () => {
|
|
2080
|
+
const h = makeHarness();
|
|
2081
|
+
try {
|
|
2082
|
+
const channels: (string | undefined)[] = [];
|
|
2083
|
+
const code = await init({
|
|
2084
|
+
configDir: h.configDir,
|
|
2085
|
+
ensureHubVersion: async () => ({
|
|
2086
|
+
outcome: "match" as const,
|
|
2087
|
+
installedVersion: "test",
|
|
2088
|
+
messages: [],
|
|
2089
|
+
}),
|
|
2090
|
+
manifestPath: h.manifestPath,
|
|
2091
|
+
log: () => {},
|
|
2092
|
+
alive: () => false,
|
|
2093
|
+
// No `channel` opt — exercise the env fallback the DO cloud-init script
|
|
2094
|
+
// relies on (it exports PARACHUTE_CHANNEL but passes no --channel flag).
|
|
2095
|
+
env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
|
|
2096
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2097
|
+
channels.push(channel);
|
|
2098
|
+
return 0;
|
|
2099
|
+
},
|
|
2100
|
+
ensureHub: async () => {
|
|
2101
|
+
writeHubPort(1939, h.configDir);
|
|
2102
|
+
return { pid: 0, port: 1939, started: true };
|
|
2103
|
+
},
|
|
2104
|
+
readExposeStateFn: () => undefined,
|
|
2105
|
+
isTty: false,
|
|
2106
|
+
platform: "linux",
|
|
2107
|
+
});
|
|
2108
|
+
expect(code).toBe(0);
|
|
2109
|
+
expect(channels).toEqual(["rc"]);
|
|
2110
|
+
} finally {
|
|
2111
|
+
h.cleanup();
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
test("default (no flag, no env): vault install gets undefined → install resolves latest", async () => {
|
|
2116
|
+
const h = makeHarness();
|
|
2117
|
+
try {
|
|
2118
|
+
const channels: (string | undefined)[] = [];
|
|
2119
|
+
const code = await init({
|
|
2120
|
+
configDir: h.configDir,
|
|
2121
|
+
ensureHubVersion: async () => ({
|
|
2122
|
+
outcome: "match" as const,
|
|
2123
|
+
installedVersion: "test",
|
|
2124
|
+
messages: [],
|
|
2125
|
+
}),
|
|
2126
|
+
manifestPath: h.manifestPath,
|
|
2127
|
+
log: () => {},
|
|
2128
|
+
alive: () => false,
|
|
2129
|
+
env: {} as NodeJS.ProcessEnv,
|
|
2130
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2131
|
+
channels.push(channel);
|
|
2132
|
+
return 0;
|
|
2133
|
+
},
|
|
2134
|
+
ensureHub: async () => {
|
|
2135
|
+
writeHubPort(1939, h.configDir);
|
|
2136
|
+
return { pid: 0, port: 1939, started: true };
|
|
2137
|
+
},
|
|
2138
|
+
readExposeStateFn: () => undefined,
|
|
2139
|
+
isTty: false,
|
|
2140
|
+
platform: "linux",
|
|
2141
|
+
});
|
|
2142
|
+
expect(code).toBe(0);
|
|
2143
|
+
expect(channels).toEqual([undefined]);
|
|
2144
|
+
} finally {
|
|
2145
|
+
h.cleanup();
|
|
2146
|
+
}
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
test("--channel flag overrides PARACHUTE_CHANNEL env", async () => {
|
|
2150
|
+
const h = makeHarness();
|
|
2151
|
+
try {
|
|
2152
|
+
const channels: (string | undefined)[] = [];
|
|
2153
|
+
const code = await init({
|
|
2154
|
+
configDir: h.configDir,
|
|
2155
|
+
ensureHubVersion: async () => ({
|
|
2156
|
+
outcome: "match" as const,
|
|
2157
|
+
installedVersion: "test",
|
|
2158
|
+
messages: [],
|
|
2159
|
+
}),
|
|
2160
|
+
manifestPath: h.manifestPath,
|
|
2161
|
+
log: () => {},
|
|
2162
|
+
alive: () => false,
|
|
2163
|
+
channel: "latest",
|
|
2164
|
+
env: { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv,
|
|
2165
|
+
installVaultModuleImpl: async (_d, _m, channel) => {
|
|
2166
|
+
channels.push(channel);
|
|
2167
|
+
return 0;
|
|
2168
|
+
},
|
|
2169
|
+
ensureHub: async () => {
|
|
2170
|
+
writeHubPort(1939, h.configDir);
|
|
2171
|
+
return { pid: 0, port: 1939, started: true };
|
|
2172
|
+
},
|
|
2173
|
+
readExposeStateFn: () => undefined,
|
|
2174
|
+
isTty: false,
|
|
2175
|
+
platform: "linux",
|
|
2176
|
+
});
|
|
2177
|
+
expect(code).toBe(0);
|
|
2178
|
+
expect(channels).toEqual(["latest"]);
|
|
2179
|
+
} finally {
|
|
2180
|
+
h.cleanup();
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
describe("resolveInitChannel (hub#694 bug 2)", () => {
|
|
2186
|
+
const empty = {} as NodeJS.ProcessEnv;
|
|
2187
|
+
test("explicit rc/latest wins", () => {
|
|
2188
|
+
expect(resolveInitChannel("rc", empty)).toBe("rc");
|
|
2189
|
+
expect(resolveInitChannel("latest", { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
|
|
2190
|
+
"latest",
|
|
2191
|
+
);
|
|
2192
|
+
});
|
|
2193
|
+
test("PARACHUTE_CHANNEL env when no explicit", () => {
|
|
2194
|
+
expect(resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "rc" } as NodeJS.ProcessEnv)).toBe(
|
|
2195
|
+
"rc",
|
|
2196
|
+
);
|
|
2197
|
+
});
|
|
2198
|
+
test("PARACHUTE_INSTALL_CHANNEL env when no explicit + no PARACHUTE_CHANNEL", () => {
|
|
2199
|
+
expect(
|
|
2200
|
+
resolveInitChannel(undefined, { PARACHUTE_INSTALL_CHANNEL: "rc" } as NodeJS.ProcessEnv),
|
|
2201
|
+
).toBe("rc");
|
|
2202
|
+
});
|
|
2203
|
+
test("PARACHUTE_CHANNEL wins over PARACHUTE_INSTALL_CHANNEL", () => {
|
|
2204
|
+
expect(
|
|
2205
|
+
resolveInitChannel(undefined, {
|
|
2206
|
+
PARACHUTE_CHANNEL: "latest",
|
|
2207
|
+
PARACHUTE_INSTALL_CHANNEL: "rc",
|
|
2208
|
+
} as NodeJS.ProcessEnv),
|
|
2209
|
+
).toBe("latest");
|
|
2210
|
+
});
|
|
2211
|
+
test("garbage env → undefined (install falls back to latest)", () => {
|
|
2212
|
+
expect(
|
|
2213
|
+
resolveInitChannel(undefined, { PARACHUTE_CHANNEL: "banana" } as NodeJS.ProcessEnv),
|
|
2214
|
+
).toBe(undefined);
|
|
2215
|
+
});
|
|
2216
|
+
test("nothing set → undefined", () => {
|
|
2217
|
+
expect(resolveInitChannel(undefined, empty)).toBe(undefined);
|
|
2218
|
+
});
|
|
2219
|
+
});
|
|
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
|
+
|
|
1868
2374
|
// Type alias used only inside this test file for the heuristic test.
|
|
1869
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
|
// ---------------------------------------------------------------------------
|