diff --git a/cli/README.md b/cli/README.md index 57ac89d..606cc2c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -352,6 +352,12 @@ MANTIS_BASE_URL=https://mantis.example.com MANTIS_API_KEY=mantis_live_… mantis The keychain layout means **the same API key works across profiles that share a base URL** — useful when you have prod + a prod-with-different-CF-Access-mode profile pointing at the same server. +`logout --profile ` and `profile rm --yes` remove that profile. +Shared server credentials stay available until the last profile for the URL is +removed. To remove every server profile and its credentials, use `logout --all`. +Logout removes local CLI credentials; it does not revoke the server API key or +clear dashboard browser sessions. + ### Where it's stored - Config file: `$XDG_CONFIG_HOME/mantis/config.json` (or `~/.config/mantis/config.json`), mode `0600` @@ -503,7 +509,7 @@ Remove-StoredCredential -Target "mantis-cli/https://mantis.example.com" | Item | Owner | Where | |---|---|---| | Cloudflare Access SSO JWTs | `cloudflared` binary | `~/.cloudflared/` — short-lived (24h default); mantis shells out to `cloudflared access token` to read | -| Browser cookies for the dashboard | Your browser | Per-profile cookie store; cleared by `mantis logout` only on the server side | +| Browser cookies for the dashboard | Your browser | Per-profile cookie store; use the dashboard's sign-out action to clear its session | | `npx wrangler dev` `MANTIS_EDGE_KEY` | `wrangler` | `mantis-edge/.dev.vars` — plaintext, gitignored; only present on dev machines | | Git config / SSH keys | git / OpenSSH | Untouched | diff --git a/cli/src/commands/bulk-create.ts b/cli/src/commands/bulk-create.ts index 220ace3..103ae59 100644 --- a/cli/src/commands/bulk-create.ts +++ b/cli/src/commands/bulk-create.ts @@ -1,6 +1,7 @@ -import { writeFileSync } from "node:fs"; -import { readFile, stat, writeFile } from "node:fs/promises"; -import { basename, resolve } from "node:path"; +import { appendFileSync, closeSync, fsyncSync, openSync } from "node:fs"; +import { mkdtemp, open, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; import type { Key, KeyWithDestinationResults, @@ -90,68 +91,93 @@ export async function bulkCreateCmd(opts: BulkCreateOpts): Promise { } await withClient(opts, async (client) => { + // Verify the requested output before sending a non-idempotent POST. Append + // mode leaves an existing result file intact until the completed write. + const outPath = resolve(opts.out!); + const output = await open(outPath, "a"); + await output.close(); + const recoveryDir = await mkdtemp(join(tmpdir(), "mantis-bulk-recovery-")); + const recoveryPath = join(recoveryDir, "completed.csv"); + const journal = openSync(recoveryPath, "wx", 0o600); + appendFileSync(journal, writeCsv(loaded.outputHeaders, [])); + fsyncSync(journal); + process.stderr.write(`Recovery CSV: ${recoveryPath} (completed mappings until output is saved).\n`); const total = loaded.rows.length; const onProgress = makeProgressReporter(total); - // Results land here as they complete (out of order under concurrency). The - // SIGINT handler reads it so an interrupt still flushes a valid id↔URL - // mapping — without it, keys created server-side would be unrecoverable - // locally, and a re-run would duplicate them (createKey has no idempotency - // key). Rows that never ran are marked so the operator sees what to retry. + // Each completed row is synced independently of the requested output path. + // A failed final write or interrupt can therefore retain confirmed mappings. const sink: (RowResult | undefined)[] = new Array(total); const finalize = (uncreatedNote: string): RowResult[] => loaded.rows.map((row, i) => sink[i] ?? rowError(row, uncreatedNote)); let interrupted = false; + let journalError: unknown; + const shouldStop = () => interrupted || journalError !== undefined; + const record = (result: RowResult) => { + try { + appendFileSync(journal, writeCsvRow(loaded.outputHeaders, result.row) + "\n"); + fsyncSync(journal); + } catch (err) { + journalError ??= err; + } + onProgress?.(result); + }; const onSigint = () => { if (interrupted) return; interrupted = true; - const results = finalize("interrupted before creation"); - const outPath = resolve(opts.out!); - try { - writeFileSync( - outPath, - writeCsv(loaded.outputHeaders, results.map((r) => r.row)), - "utf8", - ); - } catch { - /* best effort on the way out */ - } - const created = results.filter((r) => r.created).length; process.stderr.write( - `\n${c.yellow("interrupted")} — wrote ${created}/${total} created so far to ${outPath}. ` + - `Those keys exist on the server; a re-run will create duplicates.\n`, + `\n${c.yellow("stopping")} — waiting for in-flight requests; completed mappings are saved at ${recoveryPath}.\n`, ); - process.exit(130); }; process.on("SIGINT", onSigint); try { - if (opts.failFast) { - await createSequentially( - client, - loaded.rows, - opts, - globalDestinations, - sink, - onProgress, - ); - } else { - await mapLimit( - loaded.rows, - concurrency, - (row) => createOne(client, row, opts, globalDestinations), - sink, - onProgress, - ); + try { + if (opts.failFast) { + await createSequentially( + client, + loaded.rows, + opts, + globalDestinations, + sink, + record, + shouldStop, + ); + } else { + await mapLimit( + loaded.rows, + concurrency, + (row) => createOne(client, row, opts, globalDestinations), + sink, + record, + shouldStop, + ); + } + } finally { + closeSync(journal); + } + + const results = finalize(interrupted ? "interrupted before creation; no request sent" : "not created"); + if (journalError !== undefined) { + // If even the recovery destination failed, retain the full completed + // mappings in the command log as the last available recovery surface. + process.stderr.write("Completed mappings (CSV):\n" + writeCsv(loaded.outputHeaders, results.filter((r) => r.created).map((r) => r.row))); + throw new Error(`stopped because recovery CSV could not be saved. Confirmed mappings are printed above; do not re-run the original CSV. ${String(journalError)}`); + } + try { + await writeResults(outPath, loaded.outputHeaders, results); + } catch (err) { + throw new Error(`keys may already exist on the server. Completed mappings are saved at ${recoveryPath}; do not re-run the original CSV. Could not write ${outPath}: ${err instanceof Error ? err.message : String(err)}`); } + await rm(recoveryDir, { recursive: true, force: true }); + emitSummary(opts.out!, results, false); + if (interrupted) { + process.stderr.write(`Stopped; confirmed mappings are saved at ${outPath}. Retry only rows marked interrupted before creation. Requests that failed without returning a key may already have completed; inspect the server before retrying them.\n`); + process.exitCode = 130; + } else if (results.some((result) => result.failed)) process.exitCode = 1; } finally { process.removeListener("SIGINT", onSigint); } - - const results = finalize("not created"); - await writeResults(opts.out!, loaded.outputHeaders, results); - emitSummary(opts.out!, results, false); - if (results.some((result) => result.failed)) process.exitCode = 1; }); } @@ -298,8 +324,10 @@ async function createSequentially( globalDestinations: DestinationInput[], sink: (RowResult | undefined)[], onProgress?: (result: RowResult) => void, + shouldStop: () => boolean = () => false, ): Promise { for (let i = 0; i < rows.length; i++) { + if (shouldStop()) return; const result = await createOne(client, rows[i]!, opts, globalDestinations); sink[i] = result; onProgress?.(result); @@ -623,12 +651,14 @@ async function mapLimit( fn: (item: T, index: number) => Promise, sink: Array, onProgress?: (result: R) => void, + shouldStop: () => boolean = () => false, ): Promise { let next = 0; const workers = Array.from( { length: Math.min(limit, items.length) }, async () => { for (;;) { + if (shouldStop()) return; const index = next; next += 1; if (index >= items.length) return; @@ -713,13 +743,15 @@ function parseCsv(raw: string): string[][] { function writeCsv(headers: string[], rows: CsvRecord[]): string { const lines = [ headers.map(quoteCsvField).join(","), - ...rows.map((row) => - headers.map((header) => quoteCsvField(row[header] ?? "")).join(","), - ), + ...rows.map((row) => writeCsvRow(headers, row)), ]; return lines.join("\n") + "\n"; } +function writeCsvRow(headers: string[], row: CsvRecord): string { + return headers.map((header) => quoteCsvField(row[header] ?? "")).join(","); +} + // Cell starts that Excel/Sheets/Numbers evaluate as a formula. Prefix `'` // so the cell renders as text instead — OWASP CSV-injection mitigation. const FORMULA_PREFIX = /^[=+\-@\t\r]/; diff --git a/cli/src/commands/device.ts b/cli/src/commands/device.ts index d282004..72f03ad 100644 --- a/cli/src/commands/device.ts +++ b/cli/src/commands/device.ts @@ -38,6 +38,7 @@ export async function deviceNewCmd(opts: DeviceNewOpts): Promise { const profile = resolveProfile(profiles, opts.os, Boolean(opts.install)); const device = resolveDeviceName(opts); const vectors = resolveVectors(profile, opts); + if (opts.install && !opts.dryRun) assertBundleInstallableHere(profile.os); if (opts.dryRun) { emit( @@ -60,37 +61,52 @@ export async function deviceNewCmd(opts: DeviceNewOpts): Promise { // returns its existing keys instead of minting a duplicate set. const minted: Array<{ id: string; slug: string; memo: string; url: string }> = []; - for (const v of vectors) { - const memo = `${device} — ${v.label}`; - const key = await client.createKey({ - memo, - external_id: externalId(device, profile.os, v.slug), - response_kind: v.response_kind, - dedupe_window_seconds: v.dedupe_window_seconds, - }); - minted.push({ id: key.id, slug: v.slug, memo, url: key.url }); - } - let bundlePath: string | null = null; - if (opts.bundle) { - const { data } = await client.downloadDeviceBundle({ - device, - os: profile.os, - vectors: minted.map((m) => ({ id: m.id, slug: m.slug })), - }); - bundlePath = resolve(opts.bundle); - await mkdir(dirname(bundlePath), { recursive: true }); - await writeFile(bundlePath, data); - } - let installed = false; - if (opts.install) { - installed = await runLocalInstall(client, { - device, - os: profile.os, - vectors: minted.map((m) => ({ id: m.id, slug: m.slug })), - assumeYes: Boolean(opts.yes), - }); + try { + for (const v of vectors) { + const memo = `${device} — ${v.label}`; + const key = await client.createKey({ + memo, + external_id: externalId(device, profile.os, v.slug), + response_kind: v.response_kind, + dedupe_window_seconds: v.dedupe_window_seconds, + }); + minted.push({ id: key.id, slug: v.slug, memo, url: key.url }); + } + + if (opts.bundle) { + const { data } = await client.downloadDeviceBundle({ + device, + os: profile.os, + vectors: minted.map((m) => ({ id: m.id, slug: m.slug })), + }); + bundlePath = resolve(opts.bundle); + await mkdir(dirname(bundlePath), { recursive: true }); + await writeFile(bundlePath, data); + } + + if (opts.install) { + installed = await runLocalInstall(client, { + device, + os: profile.os, + vectors: minted.map((m) => ({ id: m.id, slug: m.slug })), + assumeYes: Boolean(opts.yes), + }); + } + } catch (err) { + const confirmed = minted.map((m) => ` ${m.slug}: ${m.id} ${m.url}`).join("\n"); + const quote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`; + const target = client.profile ? `--profile ${quote(client.profile)}` : `--base-url ${quote(client.baseUrl)}`; + const resume = `mantis ${target} device new --name ${quote(device)} --os ${profile.os} --vectors ${quote(vectors.map((v) => v.slug).join(","))}` + + (opts.bundle ? ` --bundle ${quote(opts.bundle)}` : "") + + (opts.install ? " --install" : ""); + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `device setup did not finish: ${reason}. ${minted.length} key(s) confirmed on the server` + + (confirmed ? `:\n${confirmed}` : ".") + + `\nResume with ${resume}, using the same authentication flags or environment. The same device/vector identities reuse existing keys, including a request that completed without returning its response.`, + ); } emit( diff --git a/cli/src/commands/list.ts b/cli/src/commands/list.ts index 7f8f91e..345beb2 100644 --- a/cli/src/commands/list.ts +++ b/cli/src/commands/list.ts @@ -23,7 +23,7 @@ export async function listCmd(opts: ListOpts): Promise { } await withClient(opts, async (client) => { const limit = parseLimit(opts.limit); - const items = await collect(client.listKeys.bind(client), opts.all ? 1000 : limit); + const items = await collect(client.listKeys.bind(client), opts.all ? Infinity : limit); emit( () => { if (items.length === 0) { diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index e878a26..6fdf127 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -4,6 +4,7 @@ import { DEFAULT_PROFILE, getCurrentProfileName, getProfile, + resolveCloudflareAuth, setKey, setProfile, useProfile, @@ -78,7 +79,14 @@ export async function loginCmd(opts: { ); } - const client = new MantisClient({ baseUrl: url, key }); + // Access credentials belong to the stored server, not the profile name. + // Changing --url must not forward the old server's JWT to another host. + const accessProfile = existing && sameServerUrl(existing.baseUrl, url) ? existing : null; + const client = new MantisClient({ + baseUrl: url, + key, + cloudflare: resolveCloudflareAuth(url, accessProfile), + }); try { await client.ping(); } catch (err) { @@ -91,8 +99,8 @@ export async function loginCmd(opts: { await setProfile(profileName, { baseUrl: url, keyPrefix: key.slice(0, 18), - cloudflareAccessAppUrl: existing?.cloudflareAccessAppUrl, - cloudflareAccessMode: existing?.cloudflareAccessMode, + cloudflareAccessAppUrl: accessProfile?.cloudflareAccessAppUrl, + cloudflareAccessMode: accessProfile?.cloudflareAccessMode, edgeWorkerUrl: existing?.edgeWorkerUrl, }); if (!opts.noSwitch) { @@ -109,3 +117,13 @@ export async function loginCmd(opts: { rl?.close(); } } + +function sameServerUrl(left: string, right: string): boolean { + try { + const a = new URL(left); + const b = new URL(right); + return a.origin === b.origin && a.pathname.replace(/\/+$/, "") === b.pathname.replace(/\/+$/, ""); + } catch { + return false; + } +} diff --git a/cli/src/commands/logout.ts b/cli/src/commands/logout.ts index e6e5b84..bf37eaf 100644 --- a/cli/src/commands/logout.ts +++ b/cli/src/commands/logout.ts @@ -30,16 +30,12 @@ export async function logoutCmd(opts: { const result = await removeProfile(target); if (!result.removed) return fail(`profile '${target}' not found`); - if (result.baseUrl) { - deleteKey(result.baseUrl); - deleteCloudflareServiceAuth(result.baseUrl); - } const tail = result.wasCurrent ? result.newCurrent ? c.dim(` (current → ${result.newCurrent})`) : "" : ""; process.stderr.write( - `${c.green("✓")} logged out of profile ${c.bold(target)}${tail}\n`, + `${c.green("✓")} removed profile ${c.bold(target)}${tail}${result.credentialsRetained ? c.dim(" (shared server credentials retained)") : ""}\n`, ); } diff --git a/cli/src/commands/profile.ts b/cli/src/commands/profile.ts index ef18df6..64c12ef 100644 --- a/cli/src/commands/profile.ts +++ b/cli/src/commands/profile.ts @@ -1,6 +1,4 @@ import { - deleteCloudflareServiceAuth, - deleteKey, getKey, listProfiles, patchProfile, @@ -89,17 +87,13 @@ export async function profileRmCmd( if (!result.removed) { return fail(`profile '${name}' not found`); } - if (result.baseUrl) { - deleteKey(result.baseUrl); - deleteCloudflareServiceAuth(result.baseUrl); - } const tail = result.wasCurrent ? result.newCurrent ? c.dim(` (current → ${result.newCurrent})`) : c.dim(" (was current; no profiles remain)") : ""; process.stderr.write( - `${c.green("✓")} removed profile ${c.bold(name)}${tail}\n`, + `${c.green("✓")} removed profile ${c.bold(name)}${tail}${result.credentialsRetained ? c.dim(" (shared server credentials retained)") : ""}\n`, ); } diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts index 8cea46c..dbe0f57 100644 --- a/cli/src/commands/status.ts +++ b/cli/src/commands/status.ts @@ -96,6 +96,7 @@ async function detail( ): Promise { const key = await client.getKey(id); const status = await fetchStatusSafe(client, key.public_id); + if (status.status === "error") process.exitCode = 1; // Pull the hit slice we need to explain the trip. const hitLimit = parseLimit(opts.limit); @@ -220,8 +221,10 @@ async function listAll(client: MantisClient, opts: StatusOpts): Promise { })), ); + const failed = statuses.filter(({ status }) => status.status === "error"); + if (failed.length > 0) process.exitCode = 1; const filtered = opts.trippedOnly - ? statuses.filter(({ status }) => status.status === "tripped") + ? statuses.filter(({ status }) => status.status === "tripped" || status.status === "error") : statuses; if (filtered.length === 0 && opts.trippedOnly) { @@ -262,6 +265,9 @@ async function listAll(client: MantisClient, opts: StatusOpts): Promise { if (notes.length > 0) { process.stderr.write(c.dim(`\n(${notes.join("; ")})\n`)); } + if (failed.length > 0) { + process.stderr.write(c.yellow(`\n${failed.length} monitor state(s) unavailable; check connectivity and retry.\n`)); + } }, { keys: filtered.map(({ key, status }) => ({ diff --git a/cli/src/index.ts b/cli/src/index.ts index 55bf900..42888cb 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -243,7 +243,7 @@ program program .command("logout") - .description("clear stored credentials for a profile (default: current)") + .description("remove a profile; clear server credentials when no profiles share its URL") .option("--all", "clear all profiles and the config file") .action(async (opts, cmd: Command) => { const globals = cmd.parent!.opts(); diff --git a/cli/src/lib/api.ts b/cli/src/lib/api.ts index 86007d2..d8979a8 100644 --- a/cli/src/lib/api.ts +++ b/cli/src/lib/api.ts @@ -381,11 +381,19 @@ export class MantisClient { `/status/${encodeURIComponent(publicId)}`, this.auth.baseUrl, ); - return this.fetchWithPolicy(url).then(async (res) => { + return this.fetchWithPolicy(url, { headers: this.authHeaders(null) }).then(async (res) => { if (res.status === 404) { throw new ApiError(404, null, "not monitored"); } const body = await res.json(); + const recognized = body && typeof body === "object" && "status" in body && (body.status === "ok" || body.status === "tripped"); + // A tripped monitor deliberately returns 503 for uptime tools. + if (!res.ok && !(res.status === 503 && recognized && body.status === "tripped")) { + throw new ApiError(res.status, body, `monitor status failed (HTTP ${res.status})`); + } + if (!recognized) { + throw new Error("monitor status response was not recognized"); + } return body as { status: "ok" | "tripped"; tripped_at?: string }; }); } diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 0c39e48..ea4c6a1 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -267,11 +267,19 @@ export async function resolveAuth(opts: { ); } - let cloudflare: ResolvedCloudflareAuth | undefined; + const cloudflare = resolveCloudflareAuth(baseUrl, profileEntry); + return { baseUrl, key, profile: profileName, cloudflare }; +} + +/** Resolve Access credentials without requiring an existing Mantis API key. */ +export function resolveCloudflareAuth( + baseUrl: string, + profileEntry: ProfileEntry | null | undefined, +): ResolvedCloudflareAuth | undefined { if (profileEntry?.cloudflareAccessMode === "service-auth") { const sa = getCloudflareServiceAuth(baseUrl); if (sa) { - cloudflare = { + return { mode: "service-auth", clientId: sa.client_id, clientSecret: sa.client_secret, @@ -281,10 +289,8 @@ export async function resolveAuth(opts: { profileEntry?.cloudflareAccessMode === "sso" && profileEntry.cloudflareAccessAppUrl ) { - cloudflare = { mode: "sso", appUrl: profileEntry.cloudflareAccessAppUrl }; + return { mode: "sso", appUrl: profileEntry.cloudflareAccessAppUrl }; } - - return { baseUrl, key, profile: profileName, cloudflare }; } /** Returns the profile name selected by env/current — used by login / profile / edge commands. */ @@ -349,6 +355,7 @@ export async function removeProfile(name: string): Promise<{ baseUrl?: string; wasCurrent: boolean; newCurrent?: string; + credentialsRetained?: boolean; }> { const stored = await readConfig(); if (!stored || !stored.profiles[name]) { @@ -366,11 +373,18 @@ export async function removeProfile(name: string): Promise<{ } else { // No profiles left — drop config entirely await clearConfig(); - return { removed: true, baseUrl: removed.baseUrl, wasCurrent }; + deleteKey(removed.baseUrl); + deleteCloudflareServiceAuth(removed.baseUrl); + return { removed: true, baseUrl: removed.baseUrl, wasCurrent, credentialsRetained: false }; } } await writeConfig(stored); - return { removed: true, baseUrl: removed.baseUrl, wasCurrent, newCurrent }; + const credentialsRetained = Object.values(stored.profiles).some((p) => p.baseUrl === removed.baseUrl); + if (!credentialsRetained) { + deleteKey(removed.baseUrl); + deleteCloudflareServiceAuth(removed.baseUrl); + } + return { removed: true, baseUrl: removed.baseUrl, wasCurrent, newCurrent, credentialsRetained }; } export async function useProfile(name: string): Promise { diff --git a/cli/src/lib/device-install.ts b/cli/src/lib/device-install.ts index 504b6e9..172d333 100644 --- a/cli/src/lib/device-install.ts +++ b/cli/src/lib/device-install.ts @@ -111,9 +111,8 @@ export async function applyBundleLocally( // here, so don't ask twice. const code = await run(script, dir); if (code !== 0) { - fail( + throw new Error( `installer exited with code ${code}. Files are at ${dir} for inspection.`, - ExitCode.Generic, ); } return true; diff --git a/cli/tests/bulk-recovery.test.ts b/cli/tests/bulk-recovery.test.ts new file mode 100644 index 0000000..d43f142 --- /dev/null +++ b/cli/tests/bulk-recovery.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { bulkCreateCmd } from "../src/commands/bulk-create.js"; +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeFile: vi.fn(actual.writeFile) }; +}); +const auth = { baseUrl: "https://mantis.example.com", key: "fake" }; +let dir: string; +let errors: string[]; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "mantis-bulk-recovery-test-")); + errors = []; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation((x) => { errors.push(String(x)); return true; }); + vi.spyOn(process, "exit").mockImplementation((code) => { throw new Error(`exit ${code}`); }); + await writeFile(join(dir, "in.csv"), "memo\nfirst canary\nsecond canary\nthird canary\n"); +}); +afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllGlobals(); process.exitCode = undefined; await rm(dir, { recursive: true, force: true }); }); +function created(n: number) { + return Response.json({ id: `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`, public_id: `pub${n}`, url: `https://mantis.example.com/c/pub${n}`, created_at: "2026-10-01T00:00:00Z", destinations: [] }, { status: 201 }); +} + +it("rejects an unavailable output directory before any remote creation", async () => { + const fetch = vi.fn(async () => created(1)); + vi.stubGlobal("fetch", fetch); + await expect(bulkCreateCmd({ ...auth, csv: join(dir, "in.csv"), out: join(dir, "missing", "out.csv") })).rejects.toThrow("exit 1"); + expect(fetch).not.toHaveBeenCalled(); +}); + +it("retains a recovery CSV when the output destination disappears after creation", async () => { + const outputDir = join(dir, "output"); + await mkdir(outputDir); + let requests = 0; + vi.stubGlobal("fetch", async () => { + requests += 1; + if (requests === 1) await rm(outputDir, { recursive: true }); + return created(requests); + }); + await expect(bulkCreateCmd({ ...auth, csv: join(dir, "in.csv"), out: join(outputDir, "out.csv"), concurrency: "1" })).rejects.toThrow("exit 1"); + const recovery = /Completed mappings are saved at ([^;]+);/.exec(errors.join(""))?.[1]; + expect(recovery).toBeTruthy(); + const csv = await readFile(recovery!, "utf8"); + expect(requests).toBe(3); + expect(csv).toContain("first canary"); + expect(csv).toContain("https://mantis.example.com/c/pub3"); + expect(errors.join("")).not.toContain("wrote 3/3"); + await rm(dirname(recovery!), { recursive: true }); +}); + +it("drains in-flight creation on Ctrl-C and leaves unsent rows safe to identify", async () => { + let requests = 0; + vi.stubGlobal("fetch", async () => { + requests += 1; + process.emit("SIGINT"); + return created(requests); + }); + const out = join(dir, "out.csv"); + await bulkCreateCmd({ ...auth, csv: join(dir, "in.csv"), out, concurrency: "1" }); + expect(requests).toBe(1); + expect(process.exitCode).toBe(130); + const csv = await readFile(out, "utf8"); + expect(csv).toContain("https://mantis.example.com/c/pub1"); + expect(csv).toContain("second canary"); + expect(csv).toContain("interrupted before creation; no request sent"); +}); + +it("keeps interruption recovery active while the final output is being flushed", async () => { + const actual = await vi.importActual("node:fs/promises"); + vi.stubGlobal("fetch", async () => created(1)); + const out = join(dir, "out.csv"); + vi.mocked(writeFile).mockImplementationOnce(async (path, data, options) => { + expect(process.listenerCount("SIGINT")).toBeGreaterThan(0); + process.emit("SIGINT"); + await new Promise((resolve) => setTimeout(resolve, 10)); + await actual.writeFile(path, data, options); + }); + const before = process.listenerCount("SIGINT"); + await bulkCreateCmd({ ...auth, csv: join(dir, "in.csv"), out, concurrency: "1" }); + expect(process.exitCode).toBe(130); + expect(await readFile(out, "utf8")).toContain("https://mantis.example.com/c/pub1"); + expect(errors.join("")).toContain("Recovery CSV:"); + expect(errors.join("")).toContain("confirmed mappings are saved at"); + expect(process.listenerCount("SIGINT")).toBe(before); +}); diff --git a/cli/tests/device-recovery.test.ts b/cli/tests/device-recovery.test.ts new file mode 100644 index 0000000..1b05275 --- /dev/null +++ b/cli/tests/device-recovery.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +vi.mock("../src/lib/device-install.js", () => ({ assertBundleInstallableHere: vi.fn(), applyBundleLocally: vi.fn(async () => true) })); +import { deviceNewCmd } from "../src/commands/device.js"; +import { applyBundleLocally } from "../src/lib/device-install.js"; +const profile = { os: "linux", label: "Linux", defaults: ["login", "boot"], vectors: ["login", "boot"].map((slug) => ({ slug, label: slug, response_kind: "empty", dedupe_window_seconds: 0 })) }; +const auth = { baseUrl: "https://mantis.example.com", key: "fake", retries: "0", os: "linux", name: "web01" }; +let errors: string[]; +beforeEach(() => { + errors = []; + vi.spyOn(process.stderr, "write").mockImplementation((x) => { errors.push(String(x)); return true; }); + vi.spyOn(process, "exit").mockImplementation((code) => { throw new Error(`exit ${code}`); }); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +it("reports confirmed IDs and an idempotent rerun command after partial device creation", async () => { + let posts = 0; + vi.stubGlobal("fetch", async (url: URL) => { + if (url.pathname === "/api/device-profiles") return Response.json({ profiles: [profile] }); + posts += 1; + return posts === 1 ? Response.json({ id: "confirmed-key", url: "https://mantis.example.com/c/confirmed" }, { status: 201 }) : Response.json({ error: "database unavailable" }, { status: 503 }); + }); + await expect(deviceNewCmd(auth)).rejects.toThrow("exit 1"); + const error = errors.join(""); + expect(error).toContain("1 key(s) confirmed"); + expect(error).toContain("confirmed-key https://mantis.example.com/c/confirmed"); + expect(error).toContain("--base-url 'https://mantis.example.com' device new --name 'web01' --os linux --vectors 'login,boot'"); + expect(error).toContain("reuse existing keys"); + expect(error).not.toContain("armed"); +}); + +it("retains minted IDs when a local installer fails", async () => { + vi.mocked(applyBundleLocally).mockRejectedValueOnce(new Error("installer exited with code 1; files staged at /tmp/device")); + vi.stubGlobal("fetch", async (url: URL) => { + if (url.pathname === "/api/device-profiles") return Response.json({ profiles: [{ ...profile, defaults: ["login"] }] }); + if (url.pathname === "/api/keys/device-bundle") return Response.json({ files: {}, installScript: "install.sh", uninstallScript: "uninstall.sh" }); + return Response.json({ id: "confirmed-key", url: "https://mantis.example.com/c/confirmed" }, { status: 201 }); + }); + await expect(deviceNewCmd({ ...auth, install: true, yes: true })).rejects.toThrow("exit 1"); + expect(errors.join("")).toContain("confirmed-key"); + expect(errors.join("")).toContain("--install"); + expect(errors.join("")).toContain("/tmp/device"); +}); diff --git a/cli/tests/list-all.test.ts b/cli/tests/list-all.test.ts new file mode 100644 index 0000000..d79d91f --- /dev/null +++ b/cli/tests/list-all.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { listCmd } from "../src/commands/list.js"; +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +it("--all drains more than 1000 keys without duplicates", async () => { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((x) => { output.push(String(x)); return true; }); + vi.stubGlobal("fetch", async (url: URL) => { + const start = Number(url.searchParams.get("cursor") ?? "0"); + const count = Math.min(Number(url.searchParams.get("limit")), 1201 - start); + return Response.json({ data: Array.from({ length: count }, (_, i) => ({ id: `key-${start + i}` })), next_cursor: start + count < 1201 ? String(start + count) : null }); + }); + await listCmd({ baseUrl: "https://mantis.example.com", key: "fake", all: true, idOnly: true }); + const ids = output.join("").trim().split("\n"); + expect(ids).toHaveLength(1201); + expect(new Set(ids).size).toBe(1201); +}); diff --git a/cli/tests/profile-recovery.test.ts b/cli/tests/profile-recovery.test.ts new file mode 100644 index 0000000..f3095dc --- /dev/null +++ b/cli/tests/profile-recovery.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +const keychain = vi.hoisted(() => new Map()); +vi.mock("@napi-rs/keyring", () => ({ Entry: class { + id: string; + constructor(service: string, account: string) { this.id = `${service}:${account}`; } + setPassword(value: string) { keychain.set(this.id, value); } + getPassword() { return keychain.get(this.id) ?? null; } + deletePassword() { keychain.delete(this.id); } +} })); +vi.mock("../src/lib/keychain-notice.js", () => ({ maybeEmitKeychainNotice: () => {} })); +let dir: string; +const saved = { ...process.env }; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "mantis-profile-recovery-")); + process.env.XDG_CONFIG_HOME = dir; + delete process.env.MANTIS_API_KEY; + delete process.env.MANTIS_PROFILE; + delete process.env.MANTIS_BASE_URL; + keychain.clear(); + vi.resetModules(); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); +}); +afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllGlobals(); process.env = { ...saved }; await rm(dir, { recursive: true, force: true }); }); +const baseUrl = "https://mantis.example.com"; +async function sharedProfiles() { + const cfg = await import("../src/lib/config.js"); + cfg.setKey(baseUrl, "mantis_live_shared"); + cfg.setCloudflareServiceAuth(baseUrl, { client_id: "client.access", client_secret: "fake-secret" }); + await cfg.setProfile("prod", { baseUrl, cloudflareAccessMode: "service-auth" }); + await cfg.setProfile("alternate", { baseUrl }); + return cfg; +} + +it.each(["remove", "logout"])("%s keeps shared credentials until the last profile is removed", async (action) => { + const cfg = await sharedProfiles(); + if (action === "remove") { + const { profileRmCmd } = await import("../src/commands/profile.js"); + await profileRmCmd("alternate", { yes: true }); + } else { + const { logoutCmd } = await import("../src/commands/logout.js"); + await logoutCmd({ profile: "alternate" }); + } + expect(await cfg.resolveAuth({ profile: "prod" })).toMatchObject({ key: "mantis_live_shared", cloudflare: { mode: "service-auth" } }); + await cfg.removeProfile("prod"); + expect(cfg.getKey(baseUrl)).toBeNull(); + expect(cfg.getCloudflareServiceAuth(baseUrl)).toBeNull(); +}); + +it("reauthenticates through the profile's configured Access service credentials", async () => { + const cfg = await sharedProfiles(); + const { loginCmd } = await import("../src/commands/login.js"); + const requests: RequestInit[] = []; + vi.stubGlobal("fetch", async (_url: URL, init: RequestInit) => { + requests.push(init); + return Response.json({ data: [], next_cursor: null }); + }); + await loginCmd({ profile: "prod", url: baseUrl, key: "mantis_live_replacement" }); + expect(requests[0]?.headers).toMatchObject({ Authorization: "Bearer mantis_live_replacement", "CF-Access-Client-Id": "client.access", "CF-Access-Client-Secret": "fake-secret" }); + expect(cfg.getKey(baseUrl)).toBe("mantis_live_replacement"); +}); + +it("uses configured Access credentials for monitor status on the private API host", async () => { + const cfg = await sharedProfiles(); + const { MantisClient } = await import("../src/lib/api.js"); + const requests: RequestInit[] = []; + vi.stubGlobal("fetch", async (_url: URL, init: RequestInit) => { + requests.push(init); + return Response.json({ status: "ok" }); + }); + const client = new MantisClient(await cfg.resolveAuth({ profile: "prod" })); + expect(await client.fetchStatus("pub")).toEqual({ status: "ok" }); + expect(requests[0]?.headers).toMatchObject({ "CF-Access-Client-Id": "client.access", "CF-Access-Client-Secret": "fake-secret" }); +}); + +it("does not forward old Access credentials or metadata when login changes the server", async () => { + const cfg = await import("../src/lib/config.js"); + const { loginCmd } = await import("../src/commands/login.js"); + await cfg.setProfile("prod", { baseUrl, cloudflareAccessMode: "sso", cloudflareAccessAppUrl: baseUrl, edgeWorkerUrl: "https://edge.example.com" }); + const requests: RequestInit[] = []; + vi.stubGlobal("fetch", async (_url: URL, init: RequestInit) => { requests.push(init); return Response.json({ data: [], next_cursor: null }); }); + await loginCmd({ profile: "prod", url: "https://new-server.example.com", key: "mantis_live_replacement" }); + expect(requests[0]?.headers).toEqual({ Authorization: "Bearer mantis_live_replacement" }); + expect(await cfg.getProfile("prod")).toMatchObject({ baseUrl: "https://new-server.example.com", edgeWorkerUrl: "https://edge.example.com" }); + expect((await cfg.getProfile("prod"))?.cloudflareAccessMode).toBeUndefined(); + expect((await cfg.getProfile("prod"))?.cloudflareAccessAppUrl).toBeUndefined(); +}); diff --git a/cli/tests/status-recovery.test.ts b/cli/tests/status-recovery.test.ts new file mode 100644 index 0000000..26ac930 --- /dev/null +++ b/cli/tests/status-recovery.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { statusCmd } from "../src/commands/status.js"; +import { setJsonMode } from "../src/lib/out.js"; + +const auth = { baseUrl: "https://mantis.example.com", key: "fake", retries: "0" }; +const key = { id: "00000000-0000-4000-8000-000000000001", public_id: "pub", monitor_mode: "latch", memo: "production" }; +let output: string[]; +beforeEach(() => { + output = []; + vi.spyOn(process.stdout, "write").mockImplementation((x) => { output.push(String(x)); return true; }); + vi.spyOn(process.stderr, "write").mockImplementation((x) => { output.push(String(x)); return true; }); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); process.exitCode = undefined; setJsonMode(false); }); + +describe("monitor failure recovery", () => { + it.each(["network", "http", "invalid"])("does not hide %s failures behind --tripped-only", async (failure) => { + vi.stubGlobal("fetch", async (url: URL) => { + if (url.pathname === "/api/keys") return Response.json({ data: [key], next_cursor: null }); + if (failure === "network") throw new Error("fetch failed"); + if (failure === "http") return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json({ wrong: "shape" }); + }); + await statusCmd(undefined, { ...auth, trippedOnly: true }); + expect(output.join("")).not.toContain("no monitored keys currently tripped"); + expect(output.join("")).toContain("production"); + expect(output.join("")).toContain("unavailable"); + expect(process.exitCode).toBe(1); + }); + + it("keeps an explicit error in JSON output while hiding known ok keys", async () => { + setJsonMode(true); + vi.stubGlobal("fetch", async (url: URL) => { + if (url.pathname === "/api/keys") return Response.json({ data: [key, { ...key, id: "other", public_id: "healthy" }], next_cursor: null }); + if (url.pathname.endsWith("healthy")) return Response.json({ status: "ok" }); + throw new Error("fetch failed"); + }); + await statusCmd(undefined, { ...auth, trippedOnly: true }); + const result = JSON.parse(output.join("")); + expect(result.keys).toHaveLength(1); + expect(result.keys[0].state.status).toBe("error"); + expect(process.exitCode).toBe(1); + }); + + it("still shows an all-clear when every status was verified ok", async () => { + vi.stubGlobal("fetch", async (url: URL) => url.pathname === "/api/keys" + ? Response.json({ data: [key], next_cursor: null }) : Response.json({ status: "ok" })); + await statusCmd(undefined, { ...auth, trippedOnly: true }); + expect(output.join("")).toContain("no monitored keys currently tripped"); + expect(process.exitCode ?? 0).toBe(0); + }); + + it("recognizes the intentional HTTP 503 response for a tripped monitor", async () => { + vi.stubGlobal("fetch", async (url: URL) => url.pathname === "/api/keys" + ? Response.json({ data: [key], next_cursor: null }) + : Response.json({ status: "tripped", tripped_at: "2026-10-01T00:00:00Z" }, { status: 503 })); + await statusCmd(undefined, { ...auth, trippedOnly: true }); + expect(output.join("")).toContain("tripped"); + expect(output.join("")).not.toContain("unavailable"); + expect(process.exitCode ?? 0).toBe(0); + }); +});