import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { readConfig } from "./config"; /** * End-to-end: the CLI is spawned as a subprocess (so TTY-dependent and * process.exit paths are exercised for real) against a throwaway HOME and a * fake local server — nothing touches the real config or the real API. * The spawn is async on purpose: the fake server lives in this process, and a * spawnSync would block the event loop it needs to answer the child. */ const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url)); const root = mkdtempSync(join(tmpdir(), "taleseal-cli-")); afterAll(() => rmSync(root, { recursive: true, force: true })); let caseId = 0; function freshHome(): string { caseId += 1; const home = join(root, `home-${caseId}`); mkdirSync(home, { recursive: true }); return home; } interface CliResult { status: number | null; stdout: string; stderr: string; } function runCli(args: string[], home: string, extra: Record = {}): Promise { const env: NodeJS.ProcessEnv = { ...process.env, HOME: home }; delete env.TALESEAL_API_KEY; delete env.TALESEAL_URL; delete env.XDG_CONFIG_HOME; Object.assign(env, extra); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [cliPath, ...args], { env, stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { stdout += chunk; }); child.stderr.on("data", (chunk: string) => { stderr += chunk; }); child.on("error", reject); child.on("close", (status) => resolve({ status, stdout, stderr })); }); } describe("command dispatch", () => { test("an unknown command prints the usage and exits 1", async () => { const res = await runCli(["seal"], freshHome()); expect(res.status).toBe(1); expect(res.stderr).toContain('unknown command "seal"'); expect(res.stderr).toContain("Usage: taleseal"); }); test("bare invocation prints the usage", async () => { const res = await runCli([], freshHome()); expect(res.status).toBe(1); expect(res.stderr).toContain("Usage: taleseal"); expect(res.stderr).toContain("draft "); }); test("--help prints the usage on stdout and exits 0", async () => { const res = await runCli(["--help"], freshHome()); expect(res.status).toBe(0); expect(res.stdout).toContain("Usage: taleseal"); expect(res.stdout).toContain("set-envelope"); expect(res.stdout).toContain("taleseal login"); }); }); describe("login / logout", () => { let server: Server; let url = ""; beforeAll(async () => { server = createServer((req, res) => { if (req.method !== "POST" || req.url !== "/v1/tales") { res.statusCode = 404; res.end(); return; } // tk_valid authenticates (the empty body then fails validation → 400); anything else is 401 res.statusCode = req.headers.authorization === "Bearer tk_valid" ? 400 : 401; res.end("{}"); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(() => server.close()); test("login --key stores a validated key (0600) and suggests the next command", async () => { const home = freshHome(); const res = await runCli(["login", "--key", "tk_valid"], home, { TALESEAL_URL: url }); expect(res.status).toBe(0); const path = join(home, ".config", "taleseal", "config.json"); expect(res.stdout).toContain(path); expect(res.stdout).toContain("taleseal validate tale.json"); expect(statSync(path).mode & 0o777).toBe(0o600); expect(readConfig({ HOME: home })).toEqual({ apiKey: "tk_valid" }); }); test("a 401 refuses to store the key", async () => { const home = freshHome(); const res = await runCli(["login", "--key", "tk_wrong"], home, { TALESEAL_URL: url }); expect(res.status).toBe(1); expect(res.stderr).toContain("rejected that key"); expect(res.stderr).toContain("nothing stored"); expect(existsSync(join(home, ".config", "taleseal", "config.json"))).toBe(false); }); test("an unreachable server stores the key with a warning", async () => { const home = freshHome(); const res = await runCli(["login", "--key", "tk_offline"], home, { TALESEAL_URL: "http://127.0.0.1:1" }); expect(res.status).toBe(0); expect(res.stderr).toContain("could not confirm the key"); expect(res.stderr).toContain("unreachable"); expect(readConfig({ HOME: home })).toEqual({ apiKey: "tk_offline" }); }); test("bare login without a TTY errors with guidance — the browser flow needs a human", async () => { const res = await runCli(["login"], freshHome(), { TALESEAL_URL: url }); expect(res.status).toBe(1); expect(res.stderr).toContain("not a terminal"); expect(res.stderr).toContain("--key"); }); test("logout removes the stored key", async () => { const home = freshHome(); expect((await runCli(["login", "--key", "tk_valid"], home, { TALESEAL_URL: url })).status).toBe(0); const res = await runCli(["logout"], home); expect(res.status).toBe(0); expect(res.stdout).toContain("removed"); expect(existsSync(join(home, ".config", "taleseal", "config.json"))).toBe(false); const again = await runCli(["logout"], home); expect(again.status).toBe(0); expect(again.stdout).toContain("nothing to remove"); }); });