From 409584c8199e189b77dd9e779cbd246a0a107f28 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 16 Jul 2026 23:20:22 -0400 Subject: [PATCH] test: raise unit coverage to ~99.9% and enforce coverage thresholds Add unit tests across the CLI (commands, core, generators, prompts, utils, and the entry dispatcher), bringing line coverage from 23% to 99.9%. Tests are co-located *.test.ts, follow the repo's dependency-injection style, and mock external boundaries (@clack/prompts, fs, child_process, network, keyring) without changing any source. Add Vitest coverage thresholds (lines/statements 99, functions 99, branches 95) as a regression floor. The remaining uncovered lines are unreachable branches (a private multiline-env helper in the docker generator). --- resources/coverage-badge.svg | 10 +- src/commands/adminShared.test.ts | 70 +++ src/commands/bootstrapAdmin.test.ts | 306 ++++++++++++ src/commands/check.test.ts | 144 ++++++ src/commands/config.test.ts | 463 +++++++++++++++++ src/commands/help.test.ts | 29 ++ src/commands/init.test.ts | 710 +++++++++++++++++++++++++++ src/commands/login.test.ts | 391 +++++++++++++++ src/commands/logout.test.ts | 181 +++++++ src/commands/org.test.ts | 421 ++++++++++++++++ src/commands/profile.test.ts | 297 +++++++++++ src/commands/sessions.test.ts | 329 +++++++++++++ src/commands/users.test.ts | 324 ++++++++++++ src/commands/verify.test.ts | 386 +++++++++++++++ src/commands/whoami.test.ts | 153 ++++++ src/core/admin.test.ts | 77 +++ src/core/args.test.ts | 34 ++ src/core/authClient.test.ts | 123 +++++ src/core/bootstrapSecret.test.ts | 89 ++++ src/core/config.test.ts | 48 ++ src/core/env.test.ts | 114 +++++ src/core/exec.test.ts | 58 +++ src/core/fetch.test.ts | 46 ++ src/core/images.test.ts | 33 ++ src/core/inspect.test.ts | 46 ++ src/core/jwks.test.ts | 20 + src/core/keychain.test.ts | 86 +++- src/core/loginFlow.test.ts | 200 ++++++++ src/core/oauthProviders.test.ts | 136 +++++ src/core/output.test.ts | 201 ++++++++ src/core/packageManager.test.ts | 37 ++ src/core/paths.test.ts | 13 + src/core/portal.test.ts | 12 + src/core/secrets.test.ts | 29 ++ src/core/systemConfig.test.ts | 39 ++ src/core/templates.test.ts | 470 ++++++++++++++++++ src/generators/admin/admin.test.ts | 12 + src/generators/auth/auth.test.ts | 120 +++++ src/generators/config/config.test.ts | 153 ++++++ src/generators/docker/docker.test.ts | 267 ++++++++++ src/index.test.ts | 136 +++++ src/prompts/appSelect.test.ts | 52 +- src/prompts/oauthSetup.test.ts | 97 ++++ src/prompts/projectSetup.test.ts | 246 ++++++++++ src/utils/writeEnv.test.ts | 35 ++ vitest.config.ts | 9 + 46 files changed, 7245 insertions(+), 7 deletions(-) create mode 100644 src/commands/adminShared.test.ts create mode 100644 src/commands/bootstrapAdmin.test.ts create mode 100644 src/commands/check.test.ts create mode 100644 src/commands/config.test.ts create mode 100644 src/commands/help.test.ts create mode 100644 src/commands/init.test.ts create mode 100644 src/commands/login.test.ts create mode 100644 src/commands/logout.test.ts create mode 100644 src/commands/org.test.ts create mode 100644 src/commands/profile.test.ts create mode 100644 src/commands/sessions.test.ts create mode 100644 src/commands/users.test.ts create mode 100644 src/commands/verify.test.ts create mode 100644 src/commands/whoami.test.ts create mode 100644 src/core/args.test.ts create mode 100644 src/core/bootstrapSecret.test.ts create mode 100644 src/core/env.test.ts create mode 100644 src/core/exec.test.ts create mode 100644 src/core/fetch.test.ts create mode 100644 src/core/images.test.ts create mode 100644 src/core/inspect.test.ts create mode 100644 src/core/jwks.test.ts create mode 100644 src/core/oauthProviders.test.ts create mode 100644 src/core/output.test.ts create mode 100644 src/core/packageManager.test.ts create mode 100644 src/core/paths.test.ts create mode 100644 src/core/secrets.test.ts create mode 100644 src/core/templates.test.ts create mode 100644 src/generators/admin/admin.test.ts create mode 100644 src/generators/auth/auth.test.ts create mode 100644 src/generators/config/config.test.ts create mode 100644 src/generators/docker/docker.test.ts create mode 100644 src/index.test.ts create mode 100644 src/prompts/oauthSetup.test.ts create mode 100644 src/prompts/projectSetup.test.ts create mode 100644 src/utils/writeEnv.test.ts diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 3fc3f95..5e05f14 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 23.2% + + coverage: 99.9% @@ -11,13 +11,13 @@ - + coverage coverage - 23.2% - 23.2% + 99.9% + 99.9% diff --git a/src/commands/adminShared.test.ts b/src/commands/adminShared.test.ts new file mode 100644 index 0000000..2e899ed --- /dev/null +++ b/src/commands/adminShared.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ReauthRequiredError } from "../core/authClient.js"; +import { AdminApiError, PermissionError } from "../core/admin.js"; +import { parseList, reportAdminError } from "./adminShared.js"; + +describe("reportAdminError", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("logs a yellow message and exits 1 on a reauth error", () => { + const err = new ReauthRequiredError("Run: seamless login."); + expect(() => reportAdminError(err)).toThrow("exit:1"); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Run: seamless login.")); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("logs a red error and exits 1 on a permission error", () => { + const err = new PermissionError(); + expect(() => reportAdminError(err)).toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("requires an admin role"), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("logs a red error and exits 1 on an admin api error", () => { + const err = new AdminApiError("Could not list users (500)."); + expect(() => reportAdminError(err)).toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not list users (500)."), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rethrows unrecognized errors", () => { + const err = new Error("boom"); + expect(() => reportAdminError(err)).toThrow("boom"); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); + +describe("parseList", () => { + it("returns undefined when no value is given", () => { + expect(parseList(undefined)).toBeUndefined(); + }); + + it("splits, trims, and drops empty entries", () => { + expect(parseList("a, b ,c")).toEqual(["a", "b", "c"]); + }); + + it("returns an empty array for a blank string", () => { + expect(parseList("")).toEqual([]); + expect(parseList(" , , ")).toEqual([]); + }); +}); diff --git a/src/commands/bootstrapAdmin.test.ts b/src/commands/bootstrapAdmin.test.ts new file mode 100644 index 0000000..1804e75 --- /dev/null +++ b/src/commands/bootstrapAdmin.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; + +import { resolveBootstrapSecret } from "../core/bootstrapSecret.js"; +import { runBootstrapAdmin } from "./bootstrapAdmin.js"; + +vi.mock("fs", () => { + const fns = { + existsSync: vi.fn(), + readFileSync: vi.fn(), + }; + return { default: fns, ...fns }; +}); + +vi.mock("../core/bootstrapSecret.js", () => ({ + resolveBootstrapSecret: vi.fn(), +})); + +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + text: vi.fn(), + confirm: vi.fn(), + spinner: vi.fn(), +})); + +import { intro, outro, text, confirm, spinner } from "@clack/prompts"; + +const CONFIG_JSON = JSON.stringify({ services: { auth: { mode: "local" } } }); + +let logs: string[]; +let errors: string[]; +let exitSpy: ReturnType; +let spinnerStart: ReturnType; +let spinnerStop: ReturnType; +const SAVED_ENV = { ...process.env }; + +beforeEach(() => { + logs = []; + errors = []; + + vi.mocked(fs.existsSync).mockReset(); + vi.mocked(fs.readFileSync).mockReset(); + vi.mocked(resolveBootstrapSecret).mockReset(); + vi.mocked(intro).mockReset(); + vi.mocked(outro).mockReset(); + vi.mocked(text).mockReset(); + vi.mocked(confirm).mockReset(); + vi.mocked(spinner).mockReset(); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(CONFIG_JSON); + vi.mocked(confirm).mockResolvedValue(true); + + spinnerStart = vi.fn(); + spinnerStop = vi.fn(); + vi.mocked(spinner).mockReturnValue({ + start: spinnerStart, + stop: spinnerStop, + } as never); + + vi.stubGlobal("fetch", vi.fn()); + + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + logs.push(String(msg ?? "")); + }); + vi.spyOn(console, "error").mockImplementation((msg?: unknown) => { + errors.push(String(msg ?? "")); + }); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + + process.env = { ...SAVED_ENV }; + delete process.env.SEAMLESS_API_URL; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + process.env = { ...SAVED_ENV }; +}); + +function output(): string { + return logs.join("\n"); +} + +function errOutput(): string { + return errors.join("\n"); +} + +describe("runBootstrapAdmin — config loading", () => { + it("throws when seamless.config.json is missing", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect(runBootstrapAdmin("admin@example.com")).rejects.toThrow( + "No seamless.config.json found. Run init first.", + ); + expect(intro).toHaveBeenCalledWith("Seamless Auth Bootstrap"); + }); +}); + +describe("runBootstrapAdmin — email prompt", () => { + it("uses the provided email argument without prompting", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue("auto-secret"); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + expect(text).not.toHaveBeenCalled(); + expect(output()).toContain("Invite sent to admin@example.com"); + }); + + it("prompts for an email when none is given", async () => { + vi.mocked(text).mockResolvedValue("prompted@example.com" as never); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin(); + + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Admin email address" }), + ); + expect(output()).toContain("Invite sent to prompted@example.com"); + }); + + it("validates the prompted email address", async () => { + vi.mocked(text).mockResolvedValue("prompted@example.com" as never); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin(); + + const call = vi.mocked(text).mock.calls[0][0] as { + validate: (v?: string) => string | undefined; + }; + expect(call.validate("")).toBe("Enter a valid email address"); + expect(call.validate("not-an-email")).toBe("Enter a valid email address"); + expect(call.validate("ok@example.com")).toBeUndefined(); + }); +}); + +describe("runBootstrapAdmin — confirm cancellation", () => { + it("outputs Cancelled and returns without calling fetch", async () => { + vi.mocked(confirm).mockResolvedValue(false); + + await runBootstrapAdmin("admin@example.com"); + + expect(outro).toHaveBeenCalledWith("Cancelled."); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("runBootstrapAdmin — API URL resolution", () => { + it("defaults to localhost:3000 when SEAMLESS_API_URL is unset", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + expect(fetch).toHaveBeenCalledWith( + "http://localhost:3000/auth/internal/bootstrap/admin-invite", + expect.anything(), + ); + }); + + it("uses SEAMLESS_API_URL when set", async () => { + process.env.SEAMLESS_API_URL = "https://api.example.com"; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + expect(fetch).toHaveBeenCalledWith( + "https://api.example.com/auth/internal/bootstrap/admin-invite", + expect.anything(), + ); + }); +}); + +describe("runBootstrapAdmin — bootstrap secret resolution", () => { + it("uses an automatically resolved secret without prompting", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue("auto-secret"); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + expect(text).not.toHaveBeenCalled(); + expect(output()).toContain("Using bootstrap secret from local environment"); + const [, init] = vi.mocked(fetch).mock.calls[0]; + expect((init as RequestInit).headers).toMatchObject({ + Authorization: "Bearer auto-secret", + }); + }); + + it("prompts for the secret when none is resolved automatically", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue(null); + vi.mocked(text).mockResolvedValue("typed-secret" as never); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Bootstrap secret" }), + ); + expect(output()).toContain("No bootstrap secret detected automatically."); + const [, init] = vi.mocked(fetch).mock.calls[0]; + expect((init as RequestInit).headers).toMatchObject({ + Authorization: "Bearer typed-secret", + }); + }); + + it("validates the prompted bootstrap secret", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue(null); + vi.mocked(text).mockResolvedValue("typed-secret" as never); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + const call = vi.mocked(text).mock.calls[0][0] as { + validate: (v?: string) => string | undefined; + }; + expect(call.validate("")).toBe("Bootstrap secret is required"); + expect(call.validate("something")).toBeUndefined(); + }); +}); + +describe("runBootstrapAdmin — request outcomes", () => { + it("prints the registration URL when the response includes one", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue("secret"); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: { url: "https://auth.example.com/register/abc" } }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + expect(spinnerStart).toHaveBeenCalledWith("Creating bootstrap invite..."); + expect(spinnerStop).toHaveBeenCalledWith("Done"); + expect(output()).toContain("Registration URL"); + expect(output()).toContain("https://auth.example.com/register/abc"); + expect(outro).toHaveBeenCalledWith("Bootstrap complete."); + }); + + it("prints an invite-sent message when there is no registration URL", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue("secret"); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin("admin@example.com"); + + expect(output()).toContain("Invite sent to admin@example.com"); + }); + + it("prints the error body and exits 1 on a non-ok response", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue("secret"); + vi.mocked(fetch).mockResolvedValue({ + ok: false, + json: async () => ({ error: "already bootstrapped" }), + } as Response); + + await expect(runBootstrapAdmin("admin@example.com")).rejects.toThrow( + "process.exit(1)", + ); + + expect(spinnerStop).toHaveBeenCalledWith("Failed"); + expect(errOutput()).toContain("Error creating bootstrap invite"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints an unexpected-error message and exits 1 when fetch throws", async () => { + vi.mocked(resolveBootstrapSecret).mockReturnValue("secret"); + vi.mocked(fetch).mockRejectedValue(new Error("network down")); + + await expect(runBootstrapAdmin("admin@example.com")).rejects.toThrow( + "process.exit(1)", + ); + + expect(spinnerStop).toHaveBeenCalledWith("Failed"); + expect(errOutput()).toContain("Unexpected error"); + expect(errOutput()).toContain("network down"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/commands/check.test.ts b/src/commands/check.test.ts new file mode 100644 index 0000000..1a39026 --- /dev/null +++ b/src/commands/check.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; +import { execSync } from "child_process"; + +import { runCheck } from "./check.js"; + +vi.mock("fs", () => { + const fns = { + existsSync: vi.fn(), + readFileSync: vi.fn(), + }; + return { default: fns, ...fns }; +}); + +vi.mock("child_process", () => ({ + execSync: vi.fn(), +})); + +const CONFIG = { + services: { + web: { path: "web" }, + api: { path: "api" }, + }, + docker: { composeFile: "docker-compose.yml" }, +}; + +let logs: string[]; + +beforeEach(() => { + logs = []; + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + logs.push(String(msg ?? "")); + }); + vi.restoreAllMocks; + vi.mocked(fs.existsSync).mockReset(); + vi.mocked(fs.readFileSync).mockReset(); + vi.mocked(execSync).mockReset(); + // Default global fetch to something the test overrides per-case. + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function output(): string { + return logs.join("\n"); +} + +describe("runCheck", () => { + it("stops early and points at init when the config file is missing", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await runCheck(); + + expect(output()).toContain("seamless.config.json not found"); + expect(output()).toContain("seamless init"); + // No further checks should have run. + expect(execSync).not.toHaveBeenCalled(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + + it("runs every check on a healthy stack", async () => { + // config present, web/api dirs present, compose file present. + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); + // docker --version and docker ps both succeed; containers include "api". + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (String(cmd).includes("docker ps")) { + return Buffer.from("web\napi\nauth\n"); + } + return Buffer.from(""); + }); + vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as Response); + + await runCheck(); + + const out = output(); + expect(out).toContain("Config file found"); + expect(out).toContain("Web project detected"); + expect(out).toContain("API project detected"); + expect(out).toContain("Docker is installed"); + expect(out).toContain("Docker Compose file found"); + expect(out).toContain("Containers running"); + expect(out).toContain("API is healthy"); + expect(out).toContain("Auth is healthy"); + expect(out).toContain("Admin is healthy"); + expect(out).toContain("Check complete."); + }); + + it("reports the unhealthy branches when everything is missing or down", async () => { + // config present so we proceed, but web/api/compose paths missing. + vi.mocked(fs.existsSync).mockImplementation((p: string) => { + return String(p).endsWith("seamless.config.json"); + }); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); + // docker --version throws (not installed); docker ps returns names without "api". + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (String(cmd).includes("docker --version")) { + throw new Error("no docker"); + } + if (String(cmd).includes("docker ps")) { + return Buffer.from("web\nauth\n"); + } + return Buffer.from(""); + }); + // API returns a non-ok status; others reject. + vi.mocked(fetch).mockImplementation(async (url: unknown) => { + if (String(url).includes(":3000")) { + return { ok: false, status: 503 } as Response; + } + throw new Error("connection refused"); + }); + + await runCheck(); + + const out = output(); + expect(out).toContain("Web project missing"); + expect(out).toContain("API project missing"); + expect(out).toContain("Docker not found"); + expect(out).toContain("docker-compose.yml missing"); + expect(out).toContain("API container not running"); + expect(out).toContain("API returned 503"); + expect(out).toContain("Auth not reachable"); + expect(out).toContain("Admin not reachable"); + }); + + it("reports a container check failure when docker ps throws", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (String(cmd).includes("docker ps")) { + throw new Error("daemon down"); + } + return Buffer.from(""); + }); + vi.mocked(fetch).mockRejectedValue(new Error("down")); + + await runCheck(); + + expect(output()).toContain("Failed to check containers"); + }); +}); diff --git a/src/commands/config.test.ts b/src/commands/config.test.ts new file mode 100644 index 0000000..92f8d0f --- /dev/null +++ b/src/commands/config.test.ts @@ -0,0 +1,463 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; + +import type { AuthClient } from "../core/authClient.js"; +import type { ApiResponse } from "../core/http.js"; +import { createAuthClient, ReauthRequiredError } from "../core/authClient.js"; +import { runConfig } from "./config.js"; + +vi.mock("fs", () => { + const fns = { + existsSync: vi.fn(), + readFileSync: vi.fn(), + }; + return { default: fns, ...fns }; +}); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: (value: unknown) => value === CANCEL_SYMBOL, +})); + +import { confirm } from "@clack/prompts"; + +const CANCEL_SYMBOL = Symbol("cancel"); + +function response(status: number, data: T | null): ApiResponse { + return { ok: status >= 200 && status < 300, status, data, headers: new Headers() }; +} + +interface Recorded { + method: string; + path: string; + body?: unknown; +} + +function fakeClient( + handler: (rec: Recorded) => ApiResponse, +): { client: AuthClient; calls: Recorded[] } { + const calls: Recorded[] = []; + const record = (method: string, path: string, init?: RequestInit): ApiResponse => { + const rec: Recorded = { + method, + path, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }; + calls.push(rec); + return handler(rec); + }; + return { + calls, + client: { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: async (path) => record("GET", path) as never, + post: async (path, body) => + record("POST", path, { body: JSON.stringify(body) }) as never, + request: async (path, init) => + record((init?.method ?? "GET").toUpperCase(), path, init) as never, + }, + }; +} + +let logs: string[]; +let errors: string[]; +let exitSpy: ReturnType; + +beforeEach(() => { + logs = []; + errors = []; + vi.mocked(fs.existsSync).mockReset(); + vi.mocked(fs.readFileSync).mockReset(); + vi.mocked(createAuthClient).mockReset(); + vi.mocked(confirm).mockReset(); + + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + logs.push(String(msg ?? "")); + }); + vi.spyOn(console, "error").mockImplementation((msg?: unknown) => { + errors.push(String(msg ?? "")); + }); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function output(): string { + return logs.join("\n"); +} + +function errOutput(): string { + return errors.join("\n"); +} + +describe("runConfig — dispatch", () => { + it("prints usage and exits 1 for an unknown subcommand", async () => { + vi.mocked(createAuthClient).mockResolvedValue(fakeClient(() => response(200, {})).client); + + await expect(runConfig(["bogus"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Unknown config subcommand: bogus"); + expect(output()).toContain("Usage: seamless config"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints (none) for a missing subcommand", async () => { + vi.mocked(createAuthClient).mockResolvedValue(fakeClient(() => response(200, {})).client); + await expect(runConfig([])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Unknown config subcommand: (none)"); + }); + + it("prints a yellow message and exits 1 on ReauthRequiredError", async () => { + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("No active profile is configured."), + ); + await expect(runConfig(["get"])).rejects.toThrow("process.exit(1)"); + expect(output()).toContain("No active profile is configured."); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints red message and exits 1 on PermissionError from a subcommand", async () => { + const { client } = fakeClient(() => response(403, { error: "Forbidden" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + await expect(runConfig(["get"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain( + "You do not have permission for this action. It requires an admin role on the instance.", + ); + }); + + it("prints red message and exits 1 on ConfigApiError from a subcommand", async () => { + const { client } = fakeClient(() => response(500, null)); + vi.mocked(createAuthClient).mockResolvedValue(client); + await expect(runConfig(["get"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Could not read system config (500)."); + }); + + it("rethrows unexpected errors", async () => { + vi.mocked(createAuthClient).mockRejectedValue(new Error("boom")); + await expect(runConfig(["get"])).rejects.toThrow("boom"); + }); + + it("passes the --profile flag through to createAuthClient", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + await runConfig(["get", "--profile", "staging", "--json"]); + expect(createAuthClient).toHaveBeenCalledWith({ profileFlag: "staging" }); + }); +}); + +describe("runConfig get", () => { + it("prints the whole config as a table when no key or --json", async () => { + const { client } = fakeClient(() => + response(200, { app_name: "Acme", rate_limit: 100 }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["get"]); + + expect(output()).toContain("app_name"); + expect(output()).toContain("Acme"); + expect(output()).toContain("rate_limit"); + expect(output()).toContain("100"); + }); + + it("prints the whole config as JSON with --json", async () => { + const { client } = fakeClient(() => response(200, { app_name: "Acme" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["get", "--json"]); + + expect(output()).toContain(JSON.stringify({ app_name: "Acme" }, null, 2)); + }); + + it("prints a string value raw when a key is given without --json", async () => { + const { client } = fakeClient(() => response(200, { app_name: "Acme" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["get", "app_name"]); + + expect(output()).toBe("Acme"); + }); + + it("prints a non-string value as JSON when a key is given without --json", async () => { + const { client } = fakeClient(() => response(200, { rate_limit: 100 })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["get", "rate_limit"]); + + expect(output()).toBe("100"); + }); + + it("prints a key's value as JSON with --json", async () => { + const { client } = fakeClient(() => response(200, { app_name: "Acme" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["get", "app_name", "--json"]); + + expect(output()).toBe('"Acme"'); + }); + + it("errors and exits 1 for an unknown key", async () => { + const { client } = fakeClient(() => response(200, { app_name: "Acme" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runConfig(["get", "nope"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("No such config key: nope"); + }); +}); + +describe("runConfig set", () => { + it("errors and exits 1 when the key is missing", async () => { + const { client } = fakeClient(() => response(200, { updatedKeys: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runConfig(["set"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Usage: seamless config set "); + }); + + it("errors and exits 1 when the value is missing", async () => { + const { client } = fakeClient(() => response(200, { updatedKeys: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runConfig(["set", "app_name"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Usage: seamless config set "); + }); + + it("patches the config and reports updated keys", async () => { + const { client, calls } = fakeClient(() => + response(200, { success: true, updatedKeys: ["app_name"] }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["set", "app_name", "My", "App"]); + + expect(calls[0]).toMatchObject({ method: "PATCH", path: "/system-config/admin" }); + expect(calls[0].body).toEqual({ app_name: "My App" }); + expect(output()).toContain("Updated: app_name"); + }); + + it("parses a JSON value before patching", async () => { + const { client, calls } = fakeClient(() => + response(200, { success: true, updatedKeys: ["rate_limit"] }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["set", "rate_limit", "100"]); + + expect(calls[0].body).toEqual({ rate_limit: 100 }); + }); + + it("prints 'No changes.' when nothing was updated", async () => { + const { client } = fakeClient(() => response(200, { success: true, updatedKeys: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["set", "app_name", "Same"]); + + expect(output()).toContain("No changes."); + }); +}); + +describe("runConfig roles", () => { + it("prints roles as JSON with --json", async () => { + const { client } = fakeClient(() => response(200, { roles: ["admin", "user"] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["roles", "--json"]); + + expect(output()).toContain(JSON.stringify(["admin", "user"], null, 2)); + }); + + it("prints 'No roles.' when the list is empty", async () => { + const { client } = fakeClient(() => response(200, { roles: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["roles"]); + + expect(output()).toContain("No roles."); + }); + + it("prints each role indented", async () => { + const { client } = fakeClient(() => response(200, { roles: ["admin", "user"] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["roles"]); + + expect(output()).toContain(" admin"); + expect(output()).toContain(" user"); + }); +}); + +describe("runConfig diff", () => { + it("errors and exits 1 when no file is given", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runConfig(["diff"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Usage: seamless config diff "); + }); + + it("propagates a ConfigApiError and exits 1 when the file cannot be read", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + + await expect(runConfig(["diff", "missing.json"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Could not read file: missing.json"); + }); + + it("propagates a ConfigApiError when the file is not valid JSON", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(fs.readFileSync).mockReturnValue("{ not json"); + + await expect(runConfig(["diff", "bad.json"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("bad.json is not valid JSON."); + }); + + it("propagates a ConfigApiError when the file is not a JSON object", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(fs.readFileSync).mockReturnValue("[1, 2]"); + + await expect(runConfig(["diff", "array.json"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("array.json must contain a JSON object."); + }); + + it("prints 'In sync' when there are no differences", async () => { + const { client } = fakeClient(() => response(200, { app_name: "Acme" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ app_name: "Acme" })); + + await runConfig(["diff", "local.json"]); + + expect(output()).toContain("In sync. No differences."); + }); + + it("prints changes when local differs from remote", async () => { + const { client } = fakeClient(() => + response(200, { app_name: "Acme", rate_limit: 100 }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ app_name: "Acme", rate_limit: 250 }), + ); + + await runConfig(["diff", "local.json"]); + + expect(output()).toContain("rate_limit"); + expect(output()).toContain("- 100"); + expect(output()).toContain("+ 250"); + }); +}); + +describe("runConfig apply", () => { + beforeEach(() => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ app_name: "Acme", bogus: 1 }), + ); + }); + + it("errors and exits 1 when no file is given", async () => { + const { client } = fakeClient(() => response(200, {})); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runConfig(["apply"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Usage: seamless config apply [--dry-run]"); + }); + + it("reports dropped read-only or unknown keys", async () => { + const { client } = fakeClient(() => response(200, { app_name: "Old" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(true); + + await runConfig(["apply", "local.json"]); + + expect(output()).toContain("Ignoring read-only or unknown keys: bogus"); + }); + + it("prints 'Already in sync' when there is nothing to apply", async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ app_name: "Acme" })); + const { client } = fakeClient(() => response(200, { app_name: "Acme" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["apply", "local.json"]); + + expect(output()).toContain("Already in sync. Nothing to apply."); + }); + + it("prints changes and skips the patch on --dry-run", async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ app_name: "New" })); + const { client, calls } = fakeClient(() => response(200, { app_name: "Old" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runConfig(["apply", "local.json", "--dry-run"]); + + expect(output()).toContain("app_name"); + expect(output()).toContain("Dry run: no changes applied."); + expect(calls).toHaveLength(1); // only the GET for remote config, no PATCH + }); + + it("cancels when the user declines the confirm prompt", async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ app_name: "New" })); + const { client, calls } = fakeClient(() => response(200, { app_name: "Old" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(false); + + await runConfig(["apply", "local.json"]); + + expect(output()).toContain("Cancelled."); + expect(calls).toHaveLength(1); + }); + + it("cancels when the confirm prompt is aborted (isCancel)", async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ app_name: "New" })); + const { client } = fakeClient(() => response(200, { app_name: "Old" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(CANCEL_SYMBOL as never); + + await runConfig(["apply", "local.json"]); + + expect(output()).toContain("Cancelled."); + }); + + it("applies the patch and reports updated keys on confirm", async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ app_name: "New" })); + const { client, calls } = fakeClient((rec) => { + if (rec.method === "PATCH") { + return response(200, { success: true, updatedKeys: ["app_name"] }); + } + return response(200, { app_name: "Old" }); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(true); + + await runConfig(["apply", "local.json"]); + + expect(calls[1]).toMatchObject({ method: "PATCH", path: "/system-config/admin" }); + expect(calls[1].body).toEqual({ app_name: "New" }); + expect(output()).toContain("Applied. Updated: app_name"); + }); + + it("reports '(none reported)' when the patch response has no updatedKeys", async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ app_name: "New" })); + const { client } = fakeClient((rec) => { + if (rec.method === "PATCH") return response(200, { success: true }); + return response(200, { app_name: "Old" }); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(true); + + await runConfig(["apply", "local.json"]); + + expect(output()).toContain("Applied. Updated: (none reported)"); + }); +}); diff --git a/src/commands/help.test.ts b/src/commands/help.test.ts new file mode 100644 index 0000000..a7ce572 --- /dev/null +++ b/src/commands/help.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// index.ts and help.ts import each other (help.ts reads VERSION from index.ts). +// Loading index.ts first here avoids a circular-import TDZ error that occurs +// when help.ts is the first module to pull index.ts in. +import "../index.js"; +import { printHelp } from "./help.js"; + +describe("printHelp", () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it("prints usage information", () => { + printHelp(); + + expect(logSpy).toHaveBeenCalledTimes(1); + const [output] = logSpy.mock.calls[0]; + expect(output).toContain("seamless v"); + expect(output).toContain("USAGE"); + expect(output).toContain("seamless login"); + expect(output).toContain("https://docs.seamlessauth.com"); + }); +}); diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts new file mode 100644 index 0000000..883db5e --- /dev/null +++ b/src/commands/init.test.ts @@ -0,0 +1,710 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; + +import { confirm, isCancel } from "@clack/prompts"; +import { + runManagedTemplatePrompts, + runProjectSetupPrompts, +} from "../prompts/projectSetup.js"; +import { runOAuthSetupPrompts } from "../prompts/oauthSetup.js"; +import { generateAuthServer } from "../generators/auth/auth.js"; +import { generateDockerCompose } from "../generators/docker/docker.js"; +import { generateSeamlessConfig } from "../generators/config/config.js"; +import { + printManagedSuccessOutput, + printSuccessOutput, +} from "../core/output.js"; +import { + applyTemplateEnv, + assertCliSupports, + openTemplateSource, +} from "../core/templates.js"; +import { createAuthClient, ReauthRequiredError } from "../core/authClient.js"; +import { listApplications, rotateServiceToken } from "../core/portal.js"; +import { selectApplication } from "../prompts/appSelect.js"; +import { parseEnv, writeEnv } from "../core/env.js"; + +import { runCLI } from "./init.js"; + +// Defensive: templates.ts transitively imports ../index.js (which runs main() at +// import time). It is mocked below, but stub index.js too so nothing runs it. +vi.mock("../index.js", () => ({ VERSION: "0.0.0-test" })); + +vi.mock("fs", () => { + const fns = { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), + }; + return { default: fns, ...fns }; +}); + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(() => false), +})); + +vi.mock("../prompts/projectSetup.js", () => ({ + runManagedTemplatePrompts: vi.fn(), + runProjectSetupPrompts: vi.fn(), +})); +vi.mock("../prompts/oauthSetup.js", () => ({ + runOAuthSetupPrompts: vi.fn(), +})); +vi.mock("../prompts/appSelect.js", () => ({ + selectApplication: vi.fn(), +})); +vi.mock("../generators/auth/auth.js", () => ({ + generateAuthServer: vi.fn(), +})); +vi.mock("../generators/docker/docker.js", () => ({ + generateDockerCompose: vi.fn(), +})); +vi.mock("../generators/config/config.js", () => ({ + generateSeamlessConfig: vi.fn(), +})); +vi.mock("../core/output.js", () => ({ + printManagedSuccessOutput: vi.fn(), + printSuccessOutput: vi.fn(), +})); +vi.mock("../core/templates.js", () => ({ + openTemplateSource: vi.fn(), + applyTemplateEnv: vi.fn(), + assertCliSupports: vi.fn(), +})); +vi.mock("../core/authClient.js", () => { + class ReauthRequiredError extends Error {} + return { createAuthClient: vi.fn(), ReauthRequiredError }; +}); +vi.mock("../core/portal.js", () => ({ + listApplications: vi.fn(), + rotateServiceToken: vi.fn(), +})); +vi.mock("../core/config.js", () => ({ + normalizeInstanceUrl: vi.fn((u: string) => `norm:${u}`), +})); +vi.mock("../core/env.js", () => ({ + parseEnv: vi.fn(() => ({})), + writeEnv: vi.fn(), +})); +vi.mock("../core/secrets.js", () => ({ + generateSecret: vi.fn(() => "generated-secret"), +})); + +const CWD = "/work"; + +// A registry with a web and an api template, plus a coming-soon web entry to +// exercise the alias filtering. The `oauth` alias is on a stable web template. +function registry() { + return { + schemaVersion: 1, + templates: [ + { + id: "web-oauth", + kind: "web", + framework: "react", + label: "React OAuth", + alias: "oauth", + status: "stable", + path: "web-oauth", + }, + { + id: "web-basic", + kind: "web", + framework: "vue", + label: "Vue Basic", + status: "stable", + path: "web-basic", + }, + { + id: "api-express", + kind: "api", + framework: "express", + label: "Express", + alias: "express", + status: "stable", + path: "api-express", + }, + { + id: "api-soon", + kind: "api", + framework: "go", + label: "Go", + alias: "go", + status: "coming-soon", + path: "api-soon", + }, + ], + }; +} + +// A template source whose manifests are keyed by template id, defaulting to a +// simple manifest with a targetDir matching the kind. +function makeSource(manifests: Record = {}) { + const reg = registry(); + return { + registry: reg, + readManifest: vi.fn(async (entry: any) => { + if (manifests[entry.id]) return manifests[entry.id]; + return { + id: entry.id, + targetDir: entry.kind === "web" ? "web" : "api", + }; + }), + copyInto: vi.fn(async () => {}), + }; +} + +function app(over: Record = {}) { + return { + id: "app-1", + name: "Acme", + domain: "https://acme.example.com", + hasServiceToken: false, + ...over, + }; +} + +let logs: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + logs = []; + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + logs.push(String(msg ?? "")); + }); + vi.spyOn(process, "cwd").mockReturnValue(CWD); + vi.mocked(isCancel).mockReturnValue(false); + // Empty directory by default (fresh scaffold). + vi.mocked(fs.readdirSync).mockReturnValue([] as never); + vi.mocked(fs.existsSync).mockReturnValue(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function out(): string { + return logs.join("\n"); +} + +describe("runCLI directory handling", () => { + it("throws when the named project directory already exists", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + await expect(runCLI("myapp")).rejects.toThrow(/already exists: myapp/); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + + it("creates the project directory and logs it", async () => { + // New named project: does not exist, then scaffold local runs. + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("no session"), + ); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "image", + includeAdmin: true, + useDocker: true, + } as never); + vi.mocked(generateDockerCompose).mockResolvedValue({ + apiToken: "tok", + kid: "kid", + } as never); + + await runCLI("myapp", []); + + expect(fs.mkdirSync).toHaveBeenCalledWith("/work/myapp"); + expect(out()).toContain("Creating project in /work/myapp"); + }); +}); + +describe("resolveManagedClient", () => { + it("falls back to the local stack when no session exists", async () => { + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("no session"), + ); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "image", + includeAdmin: true, + useDocker: true, + } as never); + vi.mocked(generateDockerCompose).mockResolvedValue({} as never); + + await runCLI(); + + // Local scaffold ran (managed prompts never used). + expect(runProjectSetupPrompts).toHaveBeenCalled(); + expect(runManagedTemplatePrompts).not.toHaveBeenCalled(); + }); + + it("rethrows non-reauth errors from the auth client", async () => { + vi.mocked(createAuthClient).mockRejectedValue(new Error("boom")); + await expect(runCLI()).rejects.toThrow(/boom/); + }); + + it("skips the auth client entirely with --local", async () => { + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "image", + includeAdmin: true, + useDocker: true, + } as never); + vi.mocked(generateDockerCompose).mockResolvedValue({} as never); + + await runCLI(undefined, [], { local: true }); + + expect(createAuthClient).not.toHaveBeenCalled(); + expect(runProjectSetupPrompts).toHaveBeenCalled(); + }); +}); + +describe("scaffoldLocal", () => { + beforeEach(() => { + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("no session"), + ); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + }); + + it("wires the docker-provided shared config when auth runs in docker", async () => { + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "image", + includeAdmin: true, + useDocker: true, + } as never); + vi.mocked(generateDockerCompose).mockResolvedValue({ + apiToken: "docker-token", + kid: "docker-kid", + } as never); + + await runCLI(undefined, []); + + expect(generateAuthServer).not.toHaveBeenCalled(); + expect(generateDockerCompose).toHaveBeenCalledWith("/work", { + authMode: "docker", + adminMode: "image", + includeAdmin: true, + oauth: [], + }); + // env applied with the docker-provided token/kid for both templates. + expect(applyTemplateEnv).toHaveBeenCalledTimes(2); + expect(applyTemplateEnv).toHaveBeenLastCalledWith( + "/work/api", + expect.anything(), + expect.objectContaining({ + apiToken: "docker-token", + jwksKid: "docker-kid", + }), + ); + expect(generateSeamlessConfig).toHaveBeenCalledWith( + "/work", + expect.objectContaining({ + authMode: "docker", + webFramework: "vue", + apiFramework: "express", + }), + ); + expect(printSuccessOutput).toHaveBeenCalled(); + }); + + it("runs the local auth generator and collects OAuth when the web template opts in", async () => { + const source = makeSource({ + "web-oauth": { + id: "web-oauth", + targetDir: "web", + setup: { oauth: true }, + }, + }); + vi.mocked(openTemplateSource).mockResolvedValue(source as never); + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-oauth", + apiTemplateId: "api-express", + authMode: "local", + adminMode: "image", + includeAdmin: true, + useDocker: false, + } as never); + vi.mocked(runOAuthSetupPrompts).mockResolvedValue([ + { + catalog: { label: "Google" }, + clientId: "id", + clientSecret: "secret", + }, + { + catalog: { label: "GitHub" }, + clientId: "", + clientSecret: "", + }, + ] as never); + vi.mocked(generateAuthServer).mockResolvedValue({ + apiToken: "local-token", + kid: "local-kid", + } as never); + + await runCLI(undefined, []); + + expect(runOAuthSetupPrompts).toHaveBeenCalled(); + expect(generateAuthServer).toHaveBeenCalledWith( + { root: "/work" }, + "local", + expect.arrayContaining([ + expect.objectContaining({ catalog: { label: "Google" } }), + ]), + ); + // Docker not requested, so the compose generator is untouched. + expect(generateDockerCompose).not.toHaveBeenCalled(); + // OAuth next-steps summary lists ready and pending providers. + expect(out()).toContain("Enabled: Google"); + expect(out()).toContain("Needs credentials before use: GitHub"); + expect(out()).toContain("Register this redirect URI"); + }); + + it("does not prompt for OAuth when the web template does not opt in", async () => { + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "local", + adminMode: "image", + includeAdmin: false, + useDocker: false, + } as never); + vi.mocked(generateAuthServer).mockResolvedValue({} as never); + + await runCLI(undefined, []); + + expect(runOAuthSetupPrompts).not.toHaveBeenCalled(); + // No providers, so no OAuth summary is printed. + expect(out()).not.toContain("OAuth providers"); + }); +}); + +describe("template alias resolution", () => { + beforeEach(() => { + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("no session"), + ); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-oauth", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "image", + includeAdmin: true, + useDocker: true, + } as never); + vi.mocked(generateDockerCompose).mockResolvedValue({} as never); + }); + + it("preselects a template from a matching alias flag", async () => { + await runCLI(undefined, ["oauth"]); + expect(runProjectSetupPrompts).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ webTemplateId: "web-oauth" }), + ); + }); + + it("rejects an unknown alias flag", async () => { + await expect(runCLI(undefined, ["nope"])).rejects.toThrow( + /Unknown option "--nope"/, + ); + }); + + it("rejects a coming-soon alias flag", async () => { + await expect(runCLI(undefined, ["go"])).rejects.toThrow( + /Unknown option "--go"/, + ); + }); + + it("reports (none) available when no template exposes an alias", async () => { + const src = makeSource(); + // Strip every alias so the error's available-flags list is empty. + for (const t of src.registry.templates) delete (t as any).alias; + vi.mocked(openTemplateSource).mockResolvedValue(src as never); + + await expect(runCLI(undefined, ["nope"])).rejects.toThrow( + /Available template flags: \(none\)/, + ); + }); + + it("rejects conflicting web alias flags", async () => { + // Add a second stable web alias so two web flags conflict. + const src = makeSource(); + src.registry.templates.push({ + id: "web-other", + kind: "web", + framework: "svelte", + label: "Svelte", + alias: "svelte", + status: "stable", + path: "web-other", + } as never); + vi.mocked(openTemplateSource).mockResolvedValue(src as never); + + await expect(runCLI(undefined, ["oauth", "svelte"])).rejects.toThrow( + /Conflicting web template flags/, + ); + }); + + it("rejects conflicting api alias flags", async () => { + const src = makeSource(); + // Make the coming-soon go template stable so it becomes a usable api alias. + src.registry.templates[3].status = "stable"; + vi.mocked(openTemplateSource).mockResolvedValue(src as never); + + await expect(runCLI(undefined, ["express", "go"])).rejects.toThrow( + /Conflicting api template flags/, + ); + }); +}); + +describe("findEntry", () => { + it("throws when a selected template id is not in the registry", async () => { + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("no session"), + ); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "does-not-exist", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "image", + includeAdmin: true, + useDocker: true, + } as never); + + await expect(runCLI(undefined, [])).rejects.toThrow( + /"does-not-exist" is not in the registry/, + ); + }); +}); + +describe("scaffoldManaged", () => { + function loggedIn() { + vi.mocked(createAuthClient).mockResolvedValue({ + profile: { name: "default", instanceUrl: "https://auth" }, + } as never); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(runManagedTemplatePrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + } as never); + } + + it("scaffolds against the selected managed application and issues a token", async () => { + loggedIn(); + vi.mocked(listApplications).mockResolvedValue([app()] as never); + vi.mocked(selectApplication).mockResolvedValue(app() as never); + vi.mocked(rotateServiceToken).mockResolvedValue("svc-token"); + + await runCLI(undefined, [], { appId: "app-1" }); + + expect(rotateServiceToken).toHaveBeenCalledWith( + expect.anything(), + "app-1", + ); + expect(applyTemplateEnv).toHaveBeenCalledTimes(2); + expect(applyTemplateEnv).toHaveBeenCalledWith( + "/work/api", + expect.anything(), + expect.objectContaining({ + apiToken: "svc-token", + authServerUrl: "norm:https://acme.example.com", + jwksKid: "dev-main", + }), + ); + expect(generateSeamlessConfig).toHaveBeenCalledWith( + "/work", + expect.objectContaining({ + authMode: "managed", + adminMode: "image", + managed: expect.objectContaining({ + applicationId: "app-1", + applicationName: "Acme", + }), + }), + ); + expect(printManagedSuccessOutput).toHaveBeenCalled(); + // No confirm needed when the app has no existing token. + expect(confirm).not.toHaveBeenCalled(); + }); + + it("confirms before rotating when the app already has a service token", async () => { + loggedIn(); + const existing = app({ hasServiceToken: true }); + vi.mocked(listApplications).mockResolvedValue([existing] as never); + vi.mocked(selectApplication).mockResolvedValue(existing as never); + vi.mocked(confirm).mockResolvedValue(true as never); + vi.mocked(rotateServiceToken).mockResolvedValue("new-token"); + + await runCLI(undefined, []); + + expect(confirm).toHaveBeenCalled(); + expect(rotateServiceToken).toHaveBeenCalled(); + expect(printManagedSuccessOutput).toHaveBeenCalled(); + }); + + it("aborts the scaffold when the developer declines rotation", async () => { + loggedIn(); + const existing = app({ hasServiceToken: true }); + vi.mocked(listApplications).mockResolvedValue([existing] as never); + vi.mocked(selectApplication).mockResolvedValue(existing as never); + vi.mocked(confirm).mockResolvedValue(false as never); + + await runCLI(undefined, []); + + expect(rotateServiceToken).not.toHaveBeenCalled(); + expect(out()).toContain("Cancelled. No token was issued."); + expect(printManagedSuccessOutput).not.toHaveBeenCalled(); + }); + + it("aborts when the confirm prompt is cancelled", async () => { + loggedIn(); + const existing = app({ hasServiceToken: true }); + vi.mocked(listApplications).mockResolvedValue([existing] as never); + vi.mocked(selectApplication).mockResolvedValue(existing as never); + vi.mocked(confirm).mockResolvedValue(Symbol("cancel") as never); + vi.mocked(isCancel).mockReturnValue(true); + + await runCLI(undefined, []); + + expect(rotateServiceToken).not.toHaveBeenCalled(); + expect(out()).toContain("Cancelled. No token was issued."); + }); + + it("returns early when no application is selected", async () => { + loggedIn(); + vi.mocked(listApplications).mockResolvedValue([] as never); + vi.mocked(selectApplication).mockResolvedValue(undefined as never); + + await runCLI(undefined, []); + + expect(rotateServiceToken).not.toHaveBeenCalled(); + expect(printManagedSuccessOutput).not.toHaveBeenCalled(); + }); +}); + +describe("integrateExistingProject", () => { + beforeEach(() => { + // Non-empty directory triggers the existing-project path. + vi.mocked(fs.readdirSync).mockReturnValue(["package.json"] as never); + }); + + it("prints login guidance when there is no session", async () => { + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("no session"), + ); + + await runCLI(undefined, []); + + expect(out()).toContain("Existing project detected."); + expect(out()).toContain("seamless login"); + }); + + it("updates api/.env when an api directory exists", async () => { + vi.mocked(createAuthClient).mockResolvedValue({ + profile: { name: "default", instanceUrl: "https://auth" }, + } as never); + vi.mocked(listApplications).mockResolvedValue([app()] as never); + vi.mocked(selectApplication).mockResolvedValue(app() as never); + vi.mocked(rotateServiceToken).mockResolvedValue("svc-token"); + // api dir exists, but api/.env does not (so parseEnv is skipped). + vi.mocked(fs.existsSync).mockImplementation((p: string) => + String(p) === "/work/api", + ); + + await runCLI(undefined, []); + + expect(writeEnv).toHaveBeenCalledWith( + "/work/api/.env", + expect.objectContaining({ + AUTH_SERVER_URL: "norm:https://acme.example.com", + API_SERVICE_TOKEN: "svc-token", + JWKS_KID: "dev-main", + COOKIE_SIGNING_KEY: "generated-secret", + }), + ); + expect(out()).toContain("Updated"); + }); + + it("preserves existing api/.env values when the file is present", async () => { + vi.mocked(createAuthClient).mockResolvedValue({ + profile: { name: "default", instanceUrl: "https://auth" }, + } as never); + vi.mocked(listApplications).mockResolvedValue([app()] as never); + vi.mocked(selectApplication).mockResolvedValue(app() as never); + vi.mocked(rotateServiceToken).mockResolvedValue("svc-token"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(parseEnv).mockReturnValue({ + JWKS_KID: "custom-kid", + COOKIE_SIGNING_KEY: "existing-key", + } as never); + + await runCLI(undefined, []); + + expect(parseEnv).toHaveBeenCalledWith("/work/api/.env"); + expect(writeEnv).toHaveBeenCalledWith( + "/work/api/.env", + expect.objectContaining({ + JWKS_KID: "custom-kid", + COOKIE_SIGNING_KEY: "existing-key", + }), + ); + }); + + it("prints the managed values to paste when there is no api directory", async () => { + vi.mocked(createAuthClient).mockResolvedValue({ + profile: { name: "default", instanceUrl: "https://auth" }, + } as never); + vi.mocked(listApplications).mockResolvedValue([app()] as never); + vi.mocked(selectApplication).mockResolvedValue(app() as never); + vi.mocked(rotateServiceToken).mockResolvedValue("svc-token"); + vi.mocked(fs.existsSync).mockReturnValue(false); + + await runCLI(undefined, []); + + expect(writeEnv).not.toHaveBeenCalled(); + expect(out()).toContain("Managed connection values:"); + expect(out()).toContain("svc-token"); + }); + + it("returns early when no application is selected", async () => { + vi.mocked(createAuthClient).mockResolvedValue({ + profile: { name: "default", instanceUrl: "https://auth" }, + } as never); + vi.mocked(listApplications).mockResolvedValue([] as never); + vi.mocked(selectApplication).mockResolvedValue(undefined as never); + + await runCLI(undefined, []); + + expect(rotateServiceToken).not.toHaveBeenCalled(); + expect(writeEnv).not.toHaveBeenCalled(); + }); + + it("returns early when token issuance is declined", async () => { + vi.mocked(createAuthClient).mockResolvedValue({ + profile: { name: "default", instanceUrl: "https://auth" }, + } as never); + const existing = app({ hasServiceToken: true }); + vi.mocked(listApplications).mockResolvedValue([existing] as never); + vi.mocked(selectApplication).mockResolvedValue(existing as never); + vi.mocked(confirm).mockResolvedValue(false as never); + + await runCLI(undefined, []); + + expect(rotateServiceToken).not.toHaveBeenCalled(); + expect(writeEnv).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts new file mode 100644 index 0000000..9d7deb9 --- /dev/null +++ b/src/commands/login.test.ts @@ -0,0 +1,391 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@clack/prompts", () => { + const CANCEL = Symbol("cancel"); + return { + CANCEL, + intro: vi.fn(), + outro: vi.fn(), + text: vi.fn(), + isCancel: (value: unknown) => value === CANCEL, + cancel: vi.fn(), + }; +}); + +import { CANCEL, cancel, intro, outro, text } from "@clack/prompts"; +import { loadConfig, upsertProfile } from "../core/config.js"; +import { + KeychainUnavailableError, + getTokens, + setBackendForTesting, + type KeychainBackend, +} from "../core/keychain.js"; +import { runLogin } from "./login.js"; + +function fakeBackend(): KeychainBackend { + const store = new Map(); + return { + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +interface Call { + url: string; + init: RequestInit; +} + +function mockRouter(routes: Record Response>>): Call[] { + const calls: Call[] = []; + const queues = new Map Response>>(Object.entries(routes)); + + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + const key = Object.keys(routes).find((p) => url.endsWith(p)); + if (!key) throw new Error(`No route for ${url}`); + const queue = queues.get(key)!; + const responder = queue.length > 1 ? queue.shift()! : queue[0]; + return responder(); + }), + ); + + return calls; +} + +const profile = { name: "default", instanceUrl: "https://auth.example.com" }; + +let configHome: string; +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + configHome = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-login-")); + process.env.XDG_CONFIG_HOME = configHome; + delete process.env.SEAMLESS_PROFILE; + delete process.env.SEAMLESS_REFRESH_TOKEN; + + setBackendForTesting(fakeBackend()); + + vi.mocked(text).mockReset(); + vi.mocked(intro).mockClear(); + vi.mocked(outro).mockClear(); + vi.mocked(cancel).mockClear(); + + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + setBackendForTesting(null); + fs.rmSync(configHome, { recursive: true, force: true }); + delete process.env.XDG_CONFIG_HOME; +}); + +function logs(): string[] { + return logSpy.mock.calls.map((c) => c[0] as string); +} + +describe("runLogin: no active profile", () => { + it("errors and exits 1", async () => { + await expect(runLogin([])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("No active profile is configured."), + ); + expect(logs().some((l) => l.includes("seamless profile add"))).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("runLogin: identifier prompt", () => { + beforeEach(() => { + upsertProfile(profile); + }); + + it("cancels when the identifier prompt is cancelled", async () => { + vi.mocked(text).mockResolvedValueOnce(CANCEL); + + await runLogin([]); + + expect(cancel).toHaveBeenCalledWith("Cancelled."); + }); + + it("uses the --identifier flag without prompting", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [ + () => json({ token: "a", refreshToken: "r", sub: "user-1", email: "dev@example.com" }), + ], + }); + vi.mocked(text).mockResolvedValueOnce("123456"); + + await runLogin(["--identifier", "dev@example.com"]); + + // Only the code prompt should have run, not the identifier prompt. + expect(vi.mocked(text)).toHaveBeenCalledTimes(1); + }); + + it("uses a positional identifier without prompting", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("123456"); + + await runLogin(["dev@example.com"]); + + expect(vi.mocked(text)).toHaveBeenCalledTimes(1); + }); +}); + +describe("runLogin: success", () => { + beforeEach(() => { + upsertProfile(profile); + }); + + it("logs in, saves tokens, and updates the profile", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [ + () => + json({ + token: "access-1", + refreshToken: "refresh-1", + sub: "user-1", + email: "dev@example.com", + }), + ], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("123456"); + + await runLogin([]); + + const stored = await getTokens(profile); + expect(stored?.accessToken).toBe("access-1"); + expect(stored?.refreshToken).toBe("refresh-1"); + + const updated = loadConfig().profiles.default; + expect(updated.sub).toBe("user-1"); + expect(updated.email).toBe("dev@example.com"); + expect(updated.identifierType).toBe("email"); + + expect(outro).toHaveBeenCalledWith(expect.stringContaining("Logged in as dev@example.com.")); + }); + + it("falls back to the identifier in the outro message when the response omits email", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("123456"); + + await runLogin([]); + + expect(outro).toHaveBeenCalledWith(expect.stringContaining("Logged in as dev@example.com.")); + }); + + it("cancels when the code prompt is cancelled", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce(CANCEL); + + await runLogin([]); + + expect(cancel).toHaveBeenCalledWith("Cancelled."); + }); + + it("notifies on an incorrect code with correct pluralization, then succeeds", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [ + () => json({ error: "Not allowed" }, 401), + () => json({ error: "Not allowed" }, 401), + () => json({ token: "a", refreshToken: "r" }), + ], + }); + vi.mocked(text) + .mockResolvedValueOnce("dev@example.com") + .mockResolvedValueOnce("000000") + .mockResolvedValueOnce("000000") + .mockResolvedValueOnce("123456"); + + await runLogin([]); + + const out = logs(); + expect(out.some((l) => l.includes("2 attempts left"))).toBe(true); + expect(out.some((l) => l.includes("1 attempt left"))).toBe(true); + expect(out.some((l) => l.includes("A code was sent to dev@example.com."))).toBe(true); + }); + + it("validates the code prompt input", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("123456"); + + await runLogin([]); + + const codeCall = vi.mocked(text).mock.calls[1][0] as { + validate: (v: string) => string | undefined; + }; + expect(codeCall.validate("123456")).toBeUndefined(); + expect(codeCall.validate("abc")).toMatch(/numeric code/); + }); + + it("validates the identifier prompt input", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("123456"); + + await runLogin([]); + + const idCall = vi.mocked(text).mock.calls[0][0] as { + validate: (v: string) => string | undefined; + }; + expect(idCall.validate("")).toBe("An identifier is required"); + expect(idCall.validate("dev@example.com")).toBeUndefined(); + }); + + it("notifies when the ephemeral code window expires and a new code is sent", async () => { + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + () => json({ token: "e2", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + + let askedCode = false; + vi.mocked(text).mockImplementation(async (opts: unknown) => { + const message = (opts as { message: string }).message; + if (message === "Email or phone") return "dev@example.com"; + if (!askedCode) { + askedCode = true; + now += 6 * 60 * 1000; + return "111111"; + } + return "654321"; + }); + + await runLogin([]); + + expect(logs().some((l) => l.includes("Your previous code expired"))).toBe(true); + }); +}); + +describe("runLogin: failure handling", () => { + beforeEach(() => { + upsertProfile(profile); + }); + + it("exits 1 with a red message on a LoginError", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + vi.mocked(text).mockResolvedValueOnce("dev@example.com"); + + await expect(runLogin([])).rejects.toThrow("exit:1"); + expect(outro).toHaveBeenCalledWith(expect.stringContaining("Could not reach")); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("exits 1 with a red message when the keychain is unavailable", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("123456"); + + setBackendForTesting({ + get: () => null, + set: () => { + throw new KeychainUnavailableError(); + }, + delete: () => false, + }); + + await expect(runLogin([])).rejects.toThrow("exit:1"); + expect(outro).toHaveBeenCalledWith(expect.stringContaining("No OS keychain is available")); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("propagates unexpected errors", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("123456"); + + setBackendForTesting({ + get: () => null, + set: () => { + throw new Error("disk on fire"); + }, + delete: () => false, + }); + + await expect(runLogin([])).rejects.toThrow("disk on fire"); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/logout.test.ts b/src/commands/logout.test.ts new file mode 100644 index 0000000..18e4f41 --- /dev/null +++ b/src/commands/logout.test.ts @@ -0,0 +1,181 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertProfile } from "../core/config.js"; +import { getTokens, saveTokens, setBackendForTesting, type KeychainBackend } from "../core/keychain.js"; +import { runLogout } from "./logout.js"; + +function fakeBackend(): KeychainBackend { + const store = new Map(); + return { + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const profile = { name: "default", instanceUrl: "https://auth.example.com" }; + +let configHome: string; +let logSpy: ReturnType; + +beforeEach(() => { + configHome = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-logout-")); + process.env.XDG_CONFIG_HOME = configHome; + delete process.env.SEAMLESS_PROFILE; + delete process.env.SEAMLESS_REFRESH_TOKEN; + + setBackendForTesting(fakeBackend()); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + setBackendForTesting(null); + fs.rmSync(configHome, { recursive: true, force: true }); + delete process.env.XDG_CONFIG_HOME; +}); + +function logs(): string[] { + return logSpy.mock.calls.map((c) => c[0] as string); +} + +describe("runLogout", () => { + it("does nothing when there is no active profile", async () => { + await runLogout([]); + expect(logs().some((l) => l.includes("No active profile. Nothing to log out of."))).toBe( + true, + ); + }); + + it("clears the local session and reports already logged out on reauth failure", async () => { + upsertProfile(profile); + // No tokens saved, so createAuthClient throws ReauthRequiredError. + await runLogout([]); + expect(logs().some((l) => l.includes("You are already logged out."))).toBe(true); + }); + + it("rethrows unexpected errors while creating the auth client", async () => { + upsertProfile(profile); + setBackendForTesting({ + get: () => { + throw new Error("disk read failed"); + }, + set: () => {}, + delete: () => false, + }); + + await expect(runLogout([])).rejects.toThrow("disk read failed"); + }); + + it("revokes the current session and clears local tokens", async () => { + upsertProfile(profile); + await saveTokens(profile, { accessToken: "access", refreshToken: "refresh" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ message: "ok" })), + ); + + await runLogout([]); + + expect(logs().some((l) => l.includes("Logged out."))).toBe(true); + expect(await getTokens(profile)).toBeNull(); + }); + + it("revokes every session with --all", async () => { + upsertProfile(profile); + await saveTokens(profile, { accessToken: "access", refreshToken: "refresh" }); + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + return json({ message: "ok" }); + }), + ); + + await runLogout(["--all"]); + + expect(calls[0]).toBe("https://auth.example.com/logout/all"); + expect(logs().some((l) => l.includes("Logged out of all sessions."))).toBe(true); + }); + + it("honors --profile alongside --all", async () => { + const other = { name: "other", instanceUrl: "https://other.example.com" }; + upsertProfile(profile); + upsertProfile(other); + await saveTokens(other, { accessToken: "a2", refreshToken: "r2" }); + + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + return json({ message: "ok" }); + }), + ); + + await runLogout(["--all", "--profile", "other"]); + + expect(calls[0]).toBe("https://other.example.com/logout/all"); + }); + + it("treats a reauth error during revoke as already revoked", async () => { + upsertProfile(profile); + await saveTokens(profile, { accessToken: "old-access", refreshToken: "old-refresh" }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => + // Both the initial call and the refresh attempt fail, so revokeSession + // surfaces a ReauthRequiredError from within the authed client. + new Response(null, { status: 401 }), + ), + ); + + await runLogout([]); + + expect(logs().some((l) => l.includes("Logged out."))).toBe(true); + }); + + it("reports a problem revoking when the instance responds with a non-ok status", async () => { + upsertProfile(profile); + await saveTokens(profile, { accessToken: "access", refreshToken: "refresh" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ error: "nope" }, 500)), + ); + + await runLogout([]); + + expect( + logs().some((l) => + l.includes("Cleared the local session. The instance reported a problem revoking the session."), + ), + ).toBe(true); + }); + + it("rethrows unexpected errors during revoke", async () => { + upsertProfile(profile); + await saveTokens(profile, { accessToken: "access", refreshToken: "refresh" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + + await expect(runLogout([])).rejects.toThrow("network down"); + }); +}); diff --git a/src/commands/org.test.ts b/src/commands/org.test.ts new file mode 100644 index 0000000..fab726b --- /dev/null +++ b/src/commands/org.test.ts @@ -0,0 +1,421 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + addMember, + createOrg, + getOrg, + listMembers, + listOrgs, + removeMember, + updateMember, + updateOrg, +} from "../core/admin.js"; +import { runOrg } from "./org.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listOrgs: vi.fn(), + createOrg: vi.fn(), + getOrg: vi.fn(), + updateOrg: vi.fn(), + listMembers: vi.fn(), + addMember: vi.fn(), + updateMember: vi.fn(), + removeMember: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let exitSpy: ReturnType; +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +describe("runOrg — top-level routing", () => { + it("prints usage and exits 1 on an unknown subcommand", async () => { + await expect(runOrg(["bogus"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown org subcommand: bogus")); + expect(logs()).toContain("Usage: seamless org "); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints usage and exits 1 when no subcommand is given", async () => { + await expect(runOrg([])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("(none)")); + }); + + it("reports a ReauthRequiredError from createAuthClient and exits 1", async () => { + vi.mocked(createAuthClient).mockRejectedValue( + Object.assign(new Error("No active profile"), { name: "ReauthRequiredError" }), + ); + const { ReauthRequiredError } = await import("../core/authClient.js"); + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("No active profile is configured."), + ); + + await expect(runOrg(["list"])).rejects.toBeInstanceOf(ExitError); + expect(logs()).toContain("No active profile is configured."); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rethrows unrecognized errors from createAuthClient", async () => { + vi.mocked(createAuthClient).mockRejectedValue(new Error("boom")); + await expect(runOrg(["list"])).rejects.toThrow("boom"); + }); + + it("reports a PermissionError from an admin call and exits 1", async () => { + const { PermissionError } = await import("../core/admin.js"); + vi.mocked(listOrgs).mockRejectedValue(new PermissionError()); + await expect(runOrg(["list"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("reports an AdminApiError from an admin call and exits 1", async () => { + const { AdminApiError } = await import("../core/admin.js"); + vi.mocked(listOrgs).mockRejectedValue(new AdminApiError("nope")); + await expect(runOrg(["list"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("nope")); + }); +}); + +describe("runOrg list", () => { + it("prints JSON when --json is passed", async () => { + vi.mocked(listOrgs).mockResolvedValue({ + organizations: [{ id: "o1", name: "Acme" }], + total: 1, + }); + await runOrg(["list", "--json"]); + expect(vi.mocked(listOrgs)).toHaveBeenCalledWith(fakeClient); + expect(logs()).toContain(JSON.stringify([{ id: "o1", name: "Acme" }], null, 2)); + }); + + it("prints an empty message when there are no organizations", async () => { + vi.mocked(listOrgs).mockResolvedValue({ organizations: [], total: 0 }); + await runOrg(["list"]); + expect(logs()).toContain("No organizations."); + }); + + it("prints each org row and a singular summary for one organization", async () => { + vi.mocked(listOrgs).mockResolvedValue({ + organizations: [{ id: "o1", name: "Acme", slug: "acme" }], + total: 1, + }); + await runOrg(["list"]); + expect(logs()).toContain("Acme"); + expect(logs()).toContain("o1"); + expect(logs()).toContain("(acme)"); + expect(logs()).toContain("1 organization."); + }); + + it("prints a plural summary and handles missing fields for multiple organizations", async () => { + vi.mocked(listOrgs).mockResolvedValue({ + organizations: [{}, { id: "o2", name: "Beta" }], + total: 2, + }); + await runOrg(["list"]); + expect(logs()).toContain("(no id)"); + expect(logs()).toContain("(no name)"); + expect(logs()).toContain("2 organizations."); + }); +}); + +describe("runOrg create", () => { + it("prints usage and exits 1 when no name is given", async () => { + await expect(runOrg(["create"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless org create"), + ); + }); + + it("creates an org from a positional name and --slug flag", async () => { + vi.mocked(createOrg).mockResolvedValue({ id: "o1", name: "Acme", slug: "acme" }); + await runOrg(["create", "Acme", "--slug", "acme"]); + expect(vi.mocked(createOrg)).toHaveBeenCalledWith(fakeClient, { + name: "Acme", + slug: "acme", + }); + expect(logs()).toContain("Created organization o1."); + expect(logs()).toContain("Acme"); + }); + + it("creates an org from a --name flag without a slug", async () => { + vi.mocked(createOrg).mockResolvedValue({ id: "o2", name: "Beta" }); + await runOrg(["create", "--name", "Beta"]); + expect(vi.mocked(createOrg)).toHaveBeenCalledWith(fakeClient, { name: "Beta" }); + }); + + it("prints '(unknown)' fallbacks when the created org lacks fields", async () => { + vi.mocked(createOrg).mockResolvedValue({}); + await runOrg(["create", "Acme"]); + expect(logs()).toContain("(unknown)"); + expect(logs()).toContain("(none)"); + }); +}); + +describe("runOrg get", () => { + it("prints usage and exits 1 when no id is given", async () => { + await expect(runOrg(["get"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless org get "), + ); + }); + + it("prints JSON when --json is passed", async () => { + vi.mocked(getOrg).mockResolvedValue({ id: "o1", name: "Acme" }); + await runOrg(["get", "o1", "--json"]); + expect(vi.mocked(getOrg)).toHaveBeenCalledWith(fakeClient, "o1"); + expect(logs()).toContain(JSON.stringify({ id: "o1", name: "Acme" }, null, 2)); + }); + + it("prints the org details by default", async () => { + vi.mocked(getOrg).mockResolvedValue({ id: "o1", name: "Acme", slug: "acme" }); + await runOrg(["get", "o1"]); + expect(logs()).toContain("Acme"); + expect(logs()).toContain("acme"); + }); +}); + +describe("runOrg update", () => { + it("prints usage and exits 1 when no id is given", async () => { + await expect(runOrg(["update"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless org update"), + ); + }); + + it("errors when neither --name nor --slug is given", async () => { + await expect(runOrg(["update", "o1"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Nothing to update")); + expect(vi.mocked(updateOrg)).not.toHaveBeenCalled(); + }); + + it("updates the org name and slug", async () => { + vi.mocked(updateOrg).mockResolvedValue({ id: "o1", name: "New", slug: "new" }); + await runOrg(["update", "o1", "--name", "New", "--slug", "new"]); + expect(vi.mocked(updateOrg)).toHaveBeenCalledWith(fakeClient, "o1", { + name: "New", + slug: "new", + }); + expect(logs()).toContain("Updated organization o1."); + }); +}); + +describe("runOrg members — routing", () => { + it("prints usage and exits 1 on an unknown members subcommand", async () => { + await expect(runOrg(["members", "bogus"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Unknown org members subcommand: bogus"), + ); + expect(logs()).toContain("Usage: seamless org members "); + }); + + it("prints usage on no members subcommand", async () => { + await expect(runOrg(["members"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("(none)")); + }); + + it("reports a ReauthRequiredError inside members routing", async () => { + const { ReauthRequiredError } = await import("../core/authClient.js"); + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("No session."), + ); + await expect(runOrg(["members", "list", "o1"])).rejects.toBeInstanceOf(ExitError); + expect(logs()).toContain("No session."); + }); + + it("rethrows unrecognized errors inside members routing", async () => { + vi.mocked(createAuthClient).mockRejectedValue(new Error("kaboom")); + await expect(runOrg(["members", "list", "o1"])).rejects.toThrow("kaboom"); + }); +}); + +describe("runOrg members list", () => { + it("prints usage and exits 1 when no orgId is given", async () => { + await expect(runOrg(["members", "list"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless org members list "), + ); + }); + + it("prints JSON when --json is passed", async () => { + vi.mocked(listMembers).mockResolvedValue({ members: [{ userId: "u1" }], total: 1 }); + await runOrg(["members", "list", "o1", "--json"]); + expect(vi.mocked(listMembers)).toHaveBeenCalledWith(fakeClient, "o1"); + expect(logs()).toContain(JSON.stringify([{ userId: "u1" }], null, 2)); + }); + + it("prints an empty message when there are no members", async () => { + vi.mocked(listMembers).mockResolvedValue({ members: [], total: 0 }); + await runOrg(["members", "list", "o1"]); + expect(logs()).toContain("No members."); + }); + + it("prints member rows with roles and scopes and a plural summary", async () => { + vi.mocked(listMembers).mockResolvedValue({ + members: [ + { userId: "u1", roles: ["admin"], scopes: ["read", "write"] }, + {}, + ], + total: 2, + }); + await runOrg(["members", "list", "o1"]); + expect(logs()).toContain("u1"); + expect(logs()).toContain("roles: admin"); + expect(logs()).toContain("scopes: read, write"); + expect(logs()).toContain("(no user)"); + expect(logs()).toContain("2 members."); + }); + + it("prints a singular summary for one member", async () => { + vi.mocked(listMembers).mockResolvedValue({ members: [{ userId: "u1" }], total: 1 }); + await runOrg(["members", "list", "o1"]); + expect(logs()).toContain("1 member."); + }); +}); + +describe("runOrg members add", () => { + it("prints usage and exits 1 when orgId is missing", async () => { + await expect( + runOrg(["members", "add", "--user", "u1"]), + ).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage: seamless org members add")); + }); + + it("prints usage and exits 1 when neither --user nor --email is given", async () => { + await expect(runOrg(["members", "add", "o1"])).rejects.toBeInstanceOf(ExitError); + expect(vi.mocked(addMember)).not.toHaveBeenCalled(); + }); + + it("adds a member by --user with roles and scopes", async () => { + vi.mocked(addMember).mockResolvedValue({ userId: "u1", roles: ["admin"] }); + await runOrg([ + "members", + "add", + "o1", + "--user", + "u1", + "--roles", + "admin, member", + "--scopes", + "read", + ]); + expect(vi.mocked(addMember)).toHaveBeenCalledWith(fakeClient, "o1", { + userId: "u1", + roles: ["admin", "member"], + scopes: ["read"], + }); + expect(logs()).toContain("Added member."); + }); + + it("adds a member by --email without roles or scopes", async () => { + vi.mocked(addMember).mockResolvedValue({ userId: "u2" }); + await runOrg(["members", "add", "o1", "--email", "x@example.com"]); + expect(vi.mocked(addMember)).toHaveBeenCalledWith(fakeClient, "o1", { + email: "x@example.com", + }); + }); +}); + +describe("runOrg members update", () => { + it("prints usage and exits 1 when orgId or userId is missing", async () => { + await expect(runOrg(["members", "update", "o1"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless org members update"), + ); + }); + + it("errors when neither --roles nor --scopes is given", async () => { + await expect( + runOrg(["members", "update", "o1", "u1"]), + ).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Nothing to update")); + }); + + it("updates a member's roles and scopes", async () => { + vi.mocked(updateMember).mockResolvedValue({ userId: "u1", roles: ["owner"] }); + await runOrg(["members", "update", "o1", "u1", "--roles", "owner", "--scopes", "admin"]); + expect(vi.mocked(updateMember)).toHaveBeenCalledWith(fakeClient, "o1", "u1", { + roles: ["owner"], + scopes: ["admin"], + }); + expect(logs()).toContain("Updated member."); + }); +}); + +describe("runOrg members remove", () => { + it("prints usage and exits 1 when orgId or userId is missing", async () => { + await expect(runOrg(["members", "remove", "o1"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless org members remove"), + ); + }); + + it("cancels when the prompt is cancelled", async () => { + vi.mocked(isCancel).mockReturnValue(true); + await runOrg(["members", "remove", "o1", "u1"]); + expect(logs()).toContain("Cancelled."); + expect(vi.mocked(removeMember)).not.toHaveBeenCalled(); + }); + + it("cancels when the user declines", async () => { + vi.mocked(confirm).mockResolvedValue(false); + await runOrg(["members", "remove", "o1", "u1"]); + expect(logs()).toContain("Cancelled."); + expect(vi.mocked(removeMember)).not.toHaveBeenCalled(); + }); + + it("removes the member on confirmation", async () => { + vi.mocked(confirm).mockResolvedValue(true); + vi.mocked(removeMember).mockResolvedValue(undefined); + await runOrg(["members", "remove", "o1", "u1"]); + expect(vi.mocked(confirm)).toHaveBeenCalledWith({ + message: "Remove user u1 from organization o1?", + initialValue: false, + }); + expect(vi.mocked(removeMember)).toHaveBeenCalledWith(fakeClient, "o1", "u1"); + expect(logs()).toContain("Removed user u1 from o1."); + }); +}); diff --git a/src/commands/profile.test.ts b/src/commands/profile.test.ts new file mode 100644 index 0000000..0c6f3cb --- /dev/null +++ b/src/commands/profile.test.ts @@ -0,0 +1,297 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@clack/prompts", () => { + const CANCEL = Symbol("cancel"); + return { + CANCEL, + intro: vi.fn(), + outro: vi.fn(), + text: vi.fn(), + isCancel: (value: unknown) => value === CANCEL, + cancel: vi.fn(), + }; +}); + +import { CANCEL, cancel, intro, outro, text } from "@clack/prompts"; +import { loadConfig, upsertProfile } from "../core/config.js"; +import { getTokens, saveTokens, setBackendForTesting, KeychainUnavailableError, type KeychainBackend } from "../core/keychain.js"; +import { runProfile } from "./profile.js"; + +function fakeBackend(): KeychainBackend { + const store = new Map(); + return { + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + }; +} + +let configHome: string; +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + configHome = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-profile-")); + process.env.XDG_CONFIG_HOME = configHome; + delete process.env.SEAMLESS_PROFILE; + + setBackendForTesting(fakeBackend()); + + vi.mocked(text).mockReset(); + vi.mocked(intro).mockClear(); + vi.mocked(outro).mockClear(); + vi.mocked(cancel).mockClear(); + + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setBackendForTesting(null); + fs.rmSync(configHome, { recursive: true, force: true }); + delete process.env.XDG_CONFIG_HOME; +}); + +function logs(): string[] { + return logSpy.mock.calls.map((c) => c[0] as string); +} + +describe("runProfile: unknown subcommand", () => { + it("errors and exits 1 for an unrecognized subcommand", async () => { + await expect(runProfile(["bogus"])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Unknown profile subcommand: bogus"), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors and exits 1 when no subcommand is given", async () => { + await expect(runProfile([])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Unknown profile subcommand: (none)"), + ); + }); +}); + +describe("runProfile list", () => { + it("shows a hint when there are no profiles", async () => { + await runProfile(["list"]); + expect(logs().some((l) => l.includes("No profiles yet."))).toBe(true); + }); + + it("marks the active profile and shows the email when present", async () => { + upsertProfile({ name: "prod", instanceUrl: "https://a.example.com", email: "dev@example.com" }); + upsertProfile({ name: "staging", instanceUrl: "https://b.example.com" }); + + await runProfile(["list"]); + + const out = logs(); + expect(out.some((l) => l.includes("*") && l.includes("prod") && l.includes("dev@example.com"))).toBe( + true, + ); + expect(out.some((l) => l.includes("staging") && !l.includes("*"))).toBe(true); + }); +}); + +describe("runProfile add", () => { + it("saves a profile from flags without prompting", async () => { + await runProfile([ + "add", + "prod", + "--instance-url", + "https://auth.example.com", + "--identifier-type", + "phone", + ]); + + expect(text).not.toHaveBeenCalled(); + expect(loadConfig().profiles.prod).toMatchObject({ + name: "prod", + instanceUrl: "https://auth.example.com", + identifierType: "phone", + }); + expect(outro).toHaveBeenCalledWith( + expect.stringContaining('Profile "prod" saved (https://auth.example.com)'), + ); + }); + + it("defaults identifier type to email", async () => { + await runProfile(["add", "prod", "--instance-url", "https://auth.example.com"]); + expect(loadConfig().profiles.prod.identifierType).toBe("email"); + }); + + it("rejects an invalid --identifier-type", async () => { + await expect( + runProfile(["add", "prod", "--instance-url", "https://a.example.com", "--identifier-type", "carrier-pigeon"]), + ).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid --identifier-type "carrier-pigeon"'), + ); + }); + + it("prompts for a name when omitted, defaulting on empty answer", async () => { + vi.mocked(text).mockResolvedValueOnce("").mockResolvedValueOnce("https://auth.example.com"); + + await runProfile(["add"]); + + expect(loadConfig().profiles.default).toBeDefined(); + }); + + it("prompts for a name and uses the given answer", async () => { + vi.mocked(text).mockResolvedValueOnce("prod").mockResolvedValueOnce("https://auth.example.com"); + + await runProfile(["add"]); + + expect(loadConfig().profiles.prod).toBeDefined(); + }); + + it("cancels when the name prompt is cancelled", async () => { + vi.mocked(text).mockResolvedValueOnce(CANCEL); + + await runProfile(["add"]); + + expect(cancel).toHaveBeenCalledWith("Cancelled."); + expect(loadConfig().profiles).toEqual({}); + }); + + it("prompts for the instance URL when omitted", async () => { + vi.mocked(text).mockResolvedValueOnce("https://auth.example.com"); + + await runProfile(["add", "prod"]); + + expect(loadConfig().profiles.prod.instanceUrl).toBe("https://auth.example.com"); + + const call = vi.mocked(text).mock.calls[0][0] as { validate: (v: string) => string | undefined }; + expect(call.validate("")).toBe("Instance URL is required"); + expect(call.validate("not-a-url")).toMatch(/Invalid instance URL/); + expect(call.validate("https://auth.example.com")).toBeUndefined(); + }); + + it("cancels when the instance URL prompt is cancelled", async () => { + vi.mocked(text).mockResolvedValueOnce(CANCEL); + + await runProfile(["add", "prod"]); + + expect(cancel).toHaveBeenCalledWith("Cancelled."); + expect(loadConfig().profiles).toEqual({}); + }); + + it("exits 1 when the flag-provided instance URL fails to normalize", async () => { + await expect( + runProfile(["add", "prod", "--instance-url", "not-a-url"]), + ).rejects.toThrow("exit:1"); + expect(outro).toHaveBeenCalledWith(expect.stringContaining("Invalid instance URL")); + }); + + it("preserves the existing sub/email when updating a profile", async () => { + upsertProfile({ + name: "prod", + instanceUrl: "https://old.example.com", + sub: "user-1", + email: "dev@example.com", + }); + + await runProfile(["add", "prod", "--instance-url", "https://new.example.com"]); + + const updated = loadConfig().profiles.prod; + expect(updated.sub).toBe("user-1"); + expect(updated.email).toBe("dev@example.com"); + expect(updated.instanceUrl).toBe("https://new.example.com"); + }); +}); + +describe("runProfile use", () => { + it("requires a name", async () => { + await expect(runProfile(["use"])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage: seamless profile use")); + }); + + it("errors when the profile does not exist", async () => { + await expect(runProfile(["use", "ghost"])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("does not exist")); + }); + + it("switches the active profile", async () => { + upsertProfile({ name: "prod", instanceUrl: "https://a.example.com" }); + upsertProfile({ name: "staging", instanceUrl: "https://b.example.com" }); + + await runProfile(["use", "staging"]); + + expect(loadConfig().activeProfile).toBe("staging"); + expect(logs().some((l) => l.includes('Active profile set to "staging".'))).toBe(true); + }); +}); + +describe("runProfile remove", () => { + it("requires a name", async () => { + await expect(runProfile(["remove"])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless profile remove"), + ); + }); + + it("errors when the profile does not exist", async () => { + await expect(runProfile(["remove", "ghost"])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("does not exist")); + }); + + it("removes a profile and clears its stored tokens", async () => { + const p = { name: "prod", instanceUrl: "https://a.example.com" }; + upsertProfile(p); + await saveTokens(p, { accessToken: "a", refreshToken: "r" }); + + await runProfile(["remove", "prod"]); + + expect(loadConfig().profiles.prod).toBeUndefined(); + expect(await getTokens(p)).toBeNull(); + expect(logs().some((l) => l.includes('Profile "prod" removed.'))).toBe(true); + }); + + it("reports when there is no keychain available", async () => { + const p = { name: "prod", instanceUrl: "https://a.example.com" }; + upsertProfile(p); + setBackendForTesting({ + get: () => null, + set: () => {}, + delete: () => { + throw new KeychainUnavailableError(); + }, + }); + + await runProfile(["remove", "prod"]); + + expect(logs().some((l) => l.includes("No keychain available; no stored tokens to clear."))).toBe( + true, + ); + expect(logs().some((l) => l.includes('Profile "prod" removed.'))).toBe(true); + }); + + it("reports other errors clearing tokens without failing the removal", async () => { + const p = { name: "prod", instanceUrl: "https://a.example.com" }; + upsertProfile(p); + setBackendForTesting({ + get: () => null, + set: () => {}, + delete: () => { + throw new Error("keyring locked"); + }, + }); + + await runProfile(["remove", "prod"]); + + expect( + logs().some((l) => l.includes("Could not clear stored tokens: keyring locked")), + ).toBe(true); + expect(logs().some((l) => l.includes('Profile "prod" removed.'))).toBe(true); + }); +}); diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts new file mode 100644 index 0000000..77fc78b --- /dev/null +++ b/src/commands/sessions.test.ts @@ -0,0 +1,329 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AuthClient } from "../core/authClient.js"; +import type { ApiResponse } from "../core/http.js"; +import { createAuthClient, ReauthRequiredError } from "../core/authClient.js"; +import { clearLocalSession } from "../core/session.js"; +import { runSessions } from "./sessions.js"; + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/session.js", () => ({ + clearLocalSession: vi.fn(), +})); + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: (value: unknown) => value === CANCEL_SYMBOL, +})); + +import { confirm } from "@clack/prompts"; + +const CANCEL_SYMBOL = Symbol("cancel"); + +function response(status: number, data: T | null): ApiResponse { + return { ok: status >= 200 && status < 300, status, data, headers: new Headers() }; +} + +function fakeClient( + handler: (method: string, path: string) => ApiResponse, +): AuthClient { + return { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: async (path) => handler("GET", path) as never, + post: async (path) => handler("POST", path) as never, + request: async (path, init) => + handler((init?.method ?? "GET").toUpperCase(), path) as never, + }; +} + +let logs: string[]; +let errors: string[]; +let exitSpy: ReturnType; + +beforeEach(() => { + logs = []; + errors = []; + vi.mocked(createAuthClient).mockReset(); + vi.mocked(confirm).mockReset(); + vi.mocked(clearLocalSession).mockReset(); + vi.mocked(clearLocalSession).mockResolvedValue(undefined); + + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + logs.push(String(msg ?? "")); + }); + vi.spyOn(console, "error").mockImplementation((msg?: unknown) => { + errors.push(String(msg ?? "")); + }); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function output(): string { + return logs.join("\n"); +} + +function errOutput(): string { + return errors.join("\n"); +} + +describe("runSessions — dispatch", () => { + it("prints a yellow message and exits 1 on ReauthRequiredError", async () => { + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("No active profile is configured."), + ); + await expect(runSessions([])).rejects.toThrow("process.exit(1)"); + expect(output()).toContain("No active profile is configured."); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints a red message and exits 1 on any other error", async () => { + const client = fakeClient(() => response(500, null)); + vi.mocked(createAuthClient).mockResolvedValue(client); + await expect(runSessions(["revoke"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Usage: seamless sessions revoke"); + }); + + it("passes the --profile flag through to createAuthClient", async () => { + const client = fakeClient(() => response(200, { sessions: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + await runSessions(["list", "--profile", "staging"]); + expect(createAuthClient).toHaveBeenCalledWith({ profileFlag: "staging" }); + }); + + it("defaults to list for an unrecognized subcommand", async () => { + const client = fakeClient(() => response(200, { sessions: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + await runSessions(["bogus"]); + expect(output()).toContain("No active sessions."); + }); +}); + +describe("runSessions list", () => { + it("prints 'No active sessions.' for an empty list", async () => { + const client = fakeClient(() => response(200, { sessions: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runSessions(["list"]); + + expect(output()).toContain("No active sessions."); + }); + + it("prints each session with device, ip, and last-used details", async () => { + const client = fakeClient(() => + response(200, { + sessions: [ + { + id: "s1", + deviceName: "MacBook", + ipAddress: "203.0.113.4", + lastUsedAt: "2026-07-13T10:00:00.000Z", + current: true, + }, + { id: "s2", current: false }, + ], + }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runSessions(["list"]); + + const out = output(); + expect(out).toContain("s1"); + expect(out).toContain("MacBook"); + expect(out).toContain("203.0.113.4"); + expect(out).toContain("2026-07-13 10:00 UTC"); + expect(out).toContain("(current)"); + expect(out).toContain("s2"); + expect(out).toContain("unknown device"); + expect(out).toContain("unknown ip"); + expect(out).toContain("unknown"); + }); + + it("falls back to a shortened user agent when deviceName is absent", async () => { + const longUA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Something Long"; + const client = fakeClient(() => + response(200, { + sessions: [{ id: "s3", userAgent: longUA, current: false }], + }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runSessions(["list"]); + + expect(output()).toContain(`${longUA.slice(0, 45)}...`); + }); + + it("uses the full user agent when it is short", async () => { + const client = fakeClient(() => + response(200, { + sessions: [{ id: "s4", userAgent: "curl/8", current: false }], + }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runSessions(["list"]); + + expect(output()).toContain("curl/8"); + }); + + it("prints the raw ISO string when lastUsedAt is not a valid date", async () => { + const client = fakeClient(() => + response(200, { + sessions: [{ id: "s5", lastUsedAt: "not-a-date", current: false }], + }), + ); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runSessions(["list"]); + + expect(output()).toContain("not-a-date"); + }); +}); + +describe("runSessions revoke --all", () => { + it("cancels when the user declines", async () => { + const client = fakeClient(() => response(200, { message: "ok" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(false); + + await runSessions(["revoke", "--all"]); + + expect(output()).toContain("Cancelled."); + expect(clearLocalSession).not.toHaveBeenCalled(); + }); + + it("cancels when the prompt is aborted", async () => { + const client = fakeClient(() => response(200, { message: "ok" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(CANCEL_SYMBOL as never); + + await runSessions(["revoke", "--all"]); + + expect(output()).toContain("Cancelled."); + }); + + it("errors and exits 1 when revocation fails", async () => { + const client = fakeClient(() => response(500, null)); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(true); + + await expect(runSessions(["revoke", "--all"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Could not revoke sessions (500)."); + expect(clearLocalSession).not.toHaveBeenCalled(); + }); + + it("clears local tokens and reports success", async () => { + const client = fakeClient(() => response(200, { message: "ok" })); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(true); + + await runSessions(["revoke", "--all"]); + + expect(clearLocalSession).toHaveBeenCalledWith(client.profile); + expect(output()).toContain("Revoked all sessions."); + expect(output()).toContain("Run seamless login to sign in again."); + }); +}); + +describe("runSessions revoke ", () => { + it("errors and exits 1 when neither an id nor --all is given", async () => { + const client = fakeClient(() => response(200, { sessions: [] })); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runSessions(["revoke"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain( + "Usage: seamless sessions revoke | seamless sessions revoke --all", + ); + }); + + it("revokes a non-current session without prompting", async () => { + const calls: string[] = []; + const client = fakeClient((method, path) => { + calls.push(`${method} ${path}`); + if (method === "GET") return response(200, { sessions: [{ id: "s1", current: false }] }); + return response(200, { message: "ok" }); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runSessions(["revoke", "s1"]); + + expect(confirm).not.toHaveBeenCalled(); + expect(calls).toContain("DELETE /sessions/s1"); + expect(output()).toContain("Revoked session s1."); + expect(clearLocalSession).not.toHaveBeenCalled(); + }); + + it("reports an unknown session as already revoked on 404", async () => { + const client = fakeClient((method) => { + if (method === "GET") return response(200, { sessions: [] }); + return response(404, { error: "Session not found" }); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await runSessions(["revoke", "gone"]); + + expect(output()).toContain("No active session gone. It may already be revoked."); + }); + + it("errors and exits 1 when revocation fails for a non-404 reason", async () => { + const client = fakeClient((method) => { + if (method === "GET") return response(200, { sessions: [] }); + return response(500, null); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + + await expect(runSessions(["revoke", "s1"])).rejects.toThrow("process.exit(1)"); + expect(errOutput()).toContain("Could not revoke session s1 (500)."); + }); + + it("prompts before revoking the current session and cancels on decline", async () => { + const client = fakeClient((method) => { + if (method === "GET") return response(200, { sessions: [{ id: "s1", current: true }] }); + return response(200, { message: "ok" }); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(false); + + await runSessions(["revoke", "s1"]); + + expect(output()).toContain("Cancelled."); + }); + + it("prompts before revoking the current session and cancels on abort", async () => { + const client = fakeClient((method) => { + if (method === "GET") return response(200, { sessions: [{ id: "s1", current: true }] }); + return response(200, { message: "ok" }); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(CANCEL_SYMBOL as never); + + await runSessions(["revoke", "s1"]); + + expect(output()).toContain("Cancelled."); + }); + + it("revokes the current session, clears local tokens, and reports success", async () => { + const client = fakeClient((method) => { + if (method === "GET") return response(200, { sessions: [{ id: "s1", current: true }] }); + return response(200, { message: "ok" }); + }); + vi.mocked(createAuthClient).mockResolvedValue(client); + vi.mocked(confirm).mockResolvedValue(true); + + await runSessions(["revoke", "s1"]); + + expect(output()).toContain("Revoked session s1."); + expect(clearLocalSession).toHaveBeenCalledWith(client.profile); + expect(output()).toContain("Run seamless login to sign in again."); + }); +}); diff --git a/src/commands/users.test.ts b/src/commands/users.test.ts new file mode 100644 index 0000000..b0eb566 --- /dev/null +++ b/src/commands/users.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + deleteUser, + getUserDetail, + listUsers, + prepareDeviceReplacement, +} from "../core/admin.js"; +import { runUsers } from "./users.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listUsers: vi.fn(), + deleteUser: vi.fn(), + getUserDetail: vi.fn(), + prepareDeviceReplacement: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let exitSpy: ReturnType; +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +describe("runUsers — top-level routing", () => { + it("prints usage and exits 1 on an unknown subcommand", async () => { + await expect(runUsers(["bogus"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Unknown users subcommand: bogus"), + ); + expect(logs()).toContain( + "Usage: seamless users ", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("prints usage and exits 1 when no subcommand is given", async () => { + await expect(runUsers([])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("(none)")); + }); + + it("reports a ReauthRequiredError from createAuthClient and exits 1", async () => { + const { ReauthRequiredError } = await import("../core/authClient.js"); + vi.mocked(createAuthClient).mockRejectedValue( + new ReauthRequiredError("No active profile is configured."), + ); + await expect(runUsers(["list"])).rejects.toBeInstanceOf(ExitError); + expect(logs()).toContain("No active profile is configured."); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rethrows unrecognized errors from createAuthClient", async () => { + vi.mocked(createAuthClient).mockRejectedValue(new Error("boom")); + await expect(runUsers(["list"])).rejects.toThrow("boom"); + }); + + it("reports a PermissionError from an admin call and exits 1", async () => { + const { PermissionError } = await import("../core/admin.js"); + vi.mocked(listUsers).mockRejectedValue(new PermissionError()); + await expect(runUsers(["list"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("reports an AdminApiError from an admin call and exits 1", async () => { + const { AdminApiError } = await import("../core/admin.js"); + vi.mocked(listUsers).mockRejectedValue(new AdminApiError("nope")); + await expect(runUsers(["list"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("nope")); + }); +}); + +describe("runUsers list", () => { + it("prints the full JSON payload when --json is passed, ignoring pagination flags", async () => { + vi.mocked(listUsers).mockResolvedValue({ + users: [{ id: "u1" }, { id: "u2" }], + total: 2, + }); + await runUsers(["list", "--json", "--limit", "1"]); + expect(vi.mocked(listUsers)).toHaveBeenCalledWith(fakeClient); + expect(logs()).toContain(JSON.stringify([{ id: "u1" }, { id: "u2" }], null, 2)); + }); + + it("prints an empty message when the page is empty", async () => { + vi.mocked(listUsers).mockResolvedValue({ users: [], total: 0 }); + await runUsers(["list"]); + expect(logs()).toContain("No users."); + }); + + it("prints user rows, roles, and revoked state, with a plural summary", async () => { + vi.mocked(listUsers).mockResolvedValue({ + users: [ + { id: "u1", email: "a@example.com", roles: ["admin"], revoked: true }, + { id: "u2", email: "b@example.com" }, + ], + total: 2, + }); + await runUsers(["list"]); + expect(logs()).toContain("a@example.com"); + expect(logs()).toContain("[admin]"); + expect(logs()).toContain("revoked"); + expect(logs()).toContain("b@example.com"); + expect(logs()).toContain("Showing 1-2 of 2 users."); + }); + + it("falls back to '(no id)'/'(no email)' for missing fields", async () => { + vi.mocked(listUsers).mockResolvedValue({ users: [{}], total: 1 }); + await runUsers(["list"]); + expect(logs()).toContain("(no id)"); + expect(logs()).toContain("(no email)"); + expect(logs()).toContain("Showing 1-1 of 1 user."); + }); + + it("paginates with --limit and --offset", async () => { + vi.mocked(listUsers).mockResolvedValue({ + users: [{ id: "u1" }, { id: "u2" }, { id: "u3" }], + total: 3, + }); + await runUsers(["list", "--limit", "1", "--offset", "1"]); + expect(logs()).toContain("Showing 2-2 of 3 users."); + expect(logs()).not.toContain("u1"); + expect(logs()).not.toContain("u3"); + }); +}); + +describe("runUsers delete", () => { + it("prints usage and exits 1 when no id is given", async () => { + await expect(runUsers(["delete"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless users delete "), + ); + }); + + it("cancels when the prompt is cancelled", async () => { + vi.mocked(isCancel).mockReturnValue(true); + await runUsers(["delete", "u1"]); + expect(logs()).toContain("Cancelled."); + expect(vi.mocked(deleteUser)).not.toHaveBeenCalled(); + }); + + it("cancels when the user declines", async () => { + vi.mocked(confirm).mockResolvedValue(false); + await runUsers(["delete", "u1"]); + expect(logs()).toContain("Cancelled."); + expect(vi.mocked(deleteUser)).not.toHaveBeenCalled(); + }); + + it("deletes the user on confirmation", async () => { + vi.mocked(deleteUser).mockResolvedValue(undefined); + await runUsers(["delete", "u1"]); + expect(vi.mocked(confirm)).toHaveBeenCalledWith({ + message: "Permanently delete user u1? This cannot be undone.", + initialValue: false, + }); + expect(vi.mocked(deleteUser)).toHaveBeenCalledWith(fakeClient, "u1"); + expect(logs()).toContain("Deleted user u1."); + }); +}); + +describe("runUsers credentials", () => { + it("prints usage and exits 1 when no id is given", async () => { + await expect(runUsers(["credentials"])).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless users credentials "), + ); + }); + + it("prints JSON when --json is passed", async () => { + vi.mocked(getUserDetail).mockResolvedValue({ + user: null, + sessions: [], + events: [], + credentials: [{ id: "c1" }], + }); + await runUsers(["credentials", "u1", "--json"]); + expect(vi.mocked(getUserDetail)).toHaveBeenCalledWith(fakeClient, "u1"); + expect(logs()).toContain(JSON.stringify([{ id: "c1" }], null, 2)); + }); + + it("prints a singular header and full field fallback chain for one credential", async () => { + vi.mocked(getUserDetail).mockResolvedValue({ + user: null, + sessions: [], + events: [], + credentials: [ + { deviceName: "iPhone", id: "c1", createdAt: "2024-01-01" }, + ], + }); + await runUsers(["credentials", "u1"]); + expect(logs()).toContain("1 credential for user u1"); + expect(logs()).toContain("iPhone"); + expect(logs()).toContain("c1"); + expect(logs()).toContain("added 2024-01-01"); + }); + + it("prints a plural header and falls back through name/type/credentialId when deviceName is absent", async () => { + vi.mocked(getUserDetail).mockResolvedValue({ + user: null, + sessions: [], + events: [], + credentials: [ + { name: "Named", credentialId: "cred-2" }, + { type: "passkey" }, + {}, + ], + }); + await runUsers(["credentials", "u1"]); + expect(logs()).toContain("3 credentials for user u1"); + expect(logs()).toContain("Named"); + expect(logs()).toContain("cred-2"); + expect(logs()).toContain("passkey"); + expect(logs()).toContain("credential"); + }); +}); + +describe("runUsers prepare-device-replacement", () => { + it("prints usage and exits 1 when no id is given", async () => { + await expect( + runUsers(["prepare-device-replacement"]), + ).rejects.toBeInstanceOf(ExitError); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Usage: seamless users prepare-device-replacement "), + ); + }); + + it("cancels when the prompt is cancelled", async () => { + vi.mocked(isCancel).mockReturnValue(true); + await runUsers(["prepare-device-replacement", "u1"]); + expect(logs()).toContain("Cancelled."); + expect(vi.mocked(prepareDeviceReplacement)).not.toHaveBeenCalled(); + }); + + it("cancels when the user declines", async () => { + vi.mocked(confirm).mockResolvedValue(false); + await runUsers(["prepare-device-replacement", "u1"]); + expect(logs()).toContain("Cancelled."); + expect(vi.mocked(prepareDeviceReplacement)).not.toHaveBeenCalled(); + }); + + it("confirms with the default action list and reports stats on success", async () => { + vi.mocked(prepareDeviceReplacement).mockResolvedValue({ + revokedSessions: 2, + removedCredentials: 1, + disabledTotpCredentials: 1, + }); + await runUsers(["prepare-device-replacement", "u1"]); + expect(vi.mocked(confirm)).toHaveBeenCalledWith({ + message: + "Prepare device replacement for u1? This will revoke all sessions, remove passkeys, disable TOTP.", + initialValue: false, + }); + expect(vi.mocked(prepareDeviceReplacement)).toHaveBeenCalledWith(fakeClient, "u1", { + revokeSessions: true, + removePasskeys: true, + disableTotp: true, + }); + expect(logs()).toContain("Prepared device replacement for u1."); + expect(logs()).toContain("Revoked sessions: 2, removed credentials: 1, disabled TOTP: 1"); + }); + + it("honors --keep-* flags and falls back stats to 0 when fields are missing", async () => { + vi.mocked(prepareDeviceReplacement).mockResolvedValue({}); + await runUsers([ + "prepare-device-replacement", + "u1", + "--keep-sessions", + "--keep-passkeys", + "--keep-totp", + ]); + expect(vi.mocked(confirm)).toHaveBeenCalledWith({ + message: "Prepare device replacement for u1? This will .", + initialValue: false, + }); + expect(vi.mocked(prepareDeviceReplacement)).toHaveBeenCalledWith(fakeClient, "u1", { + revokeSessions: false, + removePasskeys: false, + disableTotp: false, + }); + expect(logs()).toContain("Revoked sessions: 0, removed credentials: 0, disabled TOTP: 0"); + }); +}); diff --git a/src/commands/verify.test.ts b/src/commands/verify.test.ts new file mode 100644 index 0000000..fd7a636 --- /dev/null +++ b/src/commands/verify.test.ts @@ -0,0 +1,386 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { execSync } from "child_process"; +import fs from "fs"; + +import { runCommand } from "../core/exec.js"; +import { runVerify } from "./verify.js"; + +vi.mock("child_process", () => ({ execSync: vi.fn() })); +vi.mock("../core/exec.js", () => ({ runCommand: vi.fn() })); +vi.mock("fs", () => { + const fns = { + existsSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(), + rmSync: vi.fn(), + }; + return { default: fns, ...fns }; +}); + +const PKG_JSON = JSON.stringify({ + version: "1.2.3", + dependencies: { + "@seamless-auth/express": "^2.0.0", + "@seamless-auth/react": "^3.0.0", + }, +}); + +const REGISTRY_JSON = JSON.stringify({ + templates: [ + { id: "web-basic", kind: "web", status: "stable", path: "templates/web-basic" }, + { id: "coming", kind: "web", status: "coming-soon", path: "templates/coming" }, + { id: "an-api", kind: "api", status: "stable", path: "templates/an-api" }, + ], +}); + +// Default fs behavior: everything exists, package/registry/manifest reads return +// well-formed JSON. Individual tests narrow this to hit specific branches. +function setupFsDefaults(): void { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue(["stale.tgz", "keep.txt"] as never); + vi.mocked(fs.rmSync).mockReturnValue(undefined as never); + vi.mocked(fs.readFileSync).mockImplementation((p: never) => { + const s = String(p); + if (s.endsWith("registry.json")) return REGISTRY_JSON as never; + if (s.endsWith("template.json")) + return JSON.stringify({ verify: { flows: ["oauth"] } }) as never; + return PKG_JSON as never; + }); +} + +// Args passed to every runCommand invocation, tail of docker compose args (dropping +// the fixed "compose -f " prefix) so assertions ignore absolute paths. +function dockerTails(): string[][] { + return vi + .mocked(runCommand) + .mock.calls.filter((c) => c[0] === "docker") + .map((c) => (c[1] as string[]).slice(3)); +} + +function callsFor(cmd: string): string[][] { + return vi + .mocked(runCommand) + .mock.calls.filter((c) => c[0] === cmd) + .map((c) => c[1] as string[]); +} + +let exitSpy: ReturnType; +let logSpy: ReturnType; +const SAVED_ENV = { ...process.env }; + +beforeEach(() => { + vi.clearAllMocks(); + setupFsDefaults(); + vi.mocked(execSync).mockReturnValue(Buffer.from("")); + vi.mocked(runCommand).mockResolvedValue(undefined); + + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + // Deterministic sibling-checkout resolution independent of the real filesystem. + process.env.SEAMLESS_API_DIR = "/fake/api"; + process.env.SEAMLESS_SERVER_DIR = "/fake/server"; + process.env.SEAMLESS_REACT_SDK_DIR = "/fake/reactsdk"; + process.env.SEAMLESS_TEMPLATES_DIR = "/fake/templates"; + delete process.env.SEAMLESS_REACT_DIR; +}); + +afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + process.env = { ...SAVED_ENV }; +}); + +describe("runVerify — published (default) mode", () => { + it("cleans vendor, builds the base stack, and runs API + web layers", async () => { + await runVerify([]); + + // Stale tarballs are removed from both vendor dirs; non-tgz files are left. + expect(fs.rmSync).toHaveBeenCalledTimes(2); + + const tails = dockerTails(); + expect(tails).toContainEqual(["--profile", "react", "down", "-v"]); // initial clean + expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api", "adapter"]); + expect(tails).toContainEqual(["--profile", "react", "up", "-d", "--build", "react"]); + expect(tails).toContainEqual(["--profile", "react", "rm", "-sf", "react"]); + + // No --local ⇒ no pnpm/build packing. + expect(callsFor("pnpm")).toHaveLength(0); + + const npmTests = callsFor("npm").filter((a) => a[0] === "test"); + expect(npmTests).toContainEqual(["test", "--", "--project", "api", "--project", "adapter"]); + // The web template declares verify.flows ["oauth"] ⇒ Playwright grep "@oauth". + expect(npmTests).toContainEqual(["test", "--", "--project", "react", "--grep", "@oauth"]); + + // A successful run does not exit non-zero. + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("installs harness deps and the browser when node_modules is missing", async () => { + vi.mocked(fs.existsSync).mockImplementation((p: never) => !String(p).endsWith("node_modules")); + + await runVerify([]); + + const npm = callsFor("npm"); + expect(npm).toContainEqual(["install"]); + expect(callsFor("npx")).toContainEqual(["playwright", "install", "chromium"]); + }); + + it("tears the stack down by default and exits 0", async () => { + await runVerify([]); + const tails = dockerTails(); + // Final teardown call is the react-profile down -v. + expect(tails[tails.length - 1]).toEqual(["--profile", "react", "down", "-v"]); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); + +describe("runVerify — flag parsing", () => { + it("--api-only drops the adapter service and the browser layer", async () => { + await runVerify(["--api-only"]); + + const tails = dockerTails(); + expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api"]); + // No react profile is ever brought up. + expect(tails.some((t) => t.includes("react") && t.includes("up"))).toBe(false); + + const npmTests = callsFor("npm").filter((a) => a[0] === "test"); + expect(npmTests).toContainEqual(["test", "--", "--project", "api"]); + expect(callsFor("npx")).toHaveLength(0); + }); + + it("--no-react keeps the adapter but skips the web layer", async () => { + await runVerify(["--no-react"]); + + const tails = dockerTails(); + expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api", "adapter"]); + expect(callsFor("npx")).toHaveLength(0); + const npmTests = callsFor("npm").filter((a) => a[0] === "test"); + expect(npmTests).toContainEqual(["test", "--", "--project", "api", "--project", "adapter"]); + // No react project test runs. + expect(npmTests.some((t) => t.includes("react"))).toBe(false); + }); + + it("--keep-up leaves the stack running (no teardown)", async () => { + await runVerify(["--keep-up"]); + + const tails = dockerTails(); + // The only down -v is the initial clean; there is no teardown down at the end. + const downs = tails.filter((t) => t.includes("down")); + expect(downs).toHaveLength(1); + }); + + it("--filter overrides the manifest flows for every layer", async () => { + await runVerify(["--filter=@login"]); + + const npmTests = callsFor("npm").filter((a) => a[0] === "test"); + expect(npmTests).toContainEqual([ + "test", + "--", + "--project", + "api", + "--project", + "adapter", + "--grep", + "@login", + ]); + expect(npmTests).toContainEqual([ + "test", + "--", + "--project", + "react", + "--grep", + "@login", + ]); + }); +}); + +describe("runVerify — local mode", () => { + it("packs local server and react SDKs before starting the stack", async () => { + await runVerify(["--local"]); + + const pnpm = callsFor("pnpm"); + expect(pnpm).toContainEqual(["--filter", "@seamless-auth/core", "build"]); + expect(pnpm).toContainEqual(["--filter", "@seamless-auth/express", "build"]); + expect(pnpm.some((a) => a.includes("pack"))).toBe(true); + + // The react SDK is built and packed with npm. + const npm = callsFor("npm"); + expect(npm).toContainEqual(["run", "build"]); + expect(npm.some((a) => a[0] === "pack")).toBe(true); + + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("falls back to sibling checkouts when the dir env vars are unset", async () => { + // Exercises the default path.resolve(...) branches for the templates root and + // the react SDK dir; everything "exists" so resolution succeeds. + delete process.env.SEAMLESS_TEMPLATES_DIR; + delete process.env.SEAMLESS_REACT_SDK_DIR; + + await runVerify(["--local"]); + + expect(callsFor("npm")).toContainEqual(["run", "build"]); // react SDK packed + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("drops SDK version lines when local checkouts are unavailable", async () => { + // Server + react SDK checkouts are missing; api checkout is present. + vi.mocked(fs.existsSync).mockImplementation((p: never) => { + const s = String(p); + if (s.includes("/fake/server")) return false; + if (s.includes("/fake/reactsdk")) return false; + return true; + }); + + await runVerify(["--local"]); + + // packLocalSdks re-resolves the (missing) server dir and aborts the run. + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("runVerify — SEAMLESS_REACT_DIR override", () => { + it("uses the single override template and its manifest flows", async () => { + process.env.SEAMLESS_REACT_DIR = "/fake/override-template"; + + await runVerify([]); + + const npmTests = callsFor("npm").filter((a) => a[0] === "test"); + // basename of the override dir becomes the layer id; flows come from template.json. + expect(npmTests).toContainEqual(["test", "--", "--project", "react", "--grep", "@oauth"]); + }); + + it("runs the whole suite when the override manifest declares no flows", async () => { + process.env.SEAMLESS_REACT_DIR = "/fake/override-template"; + vi.mocked(fs.readFileSync).mockImplementation((p: never) => { + const s = String(p); + if (s.endsWith("template.json")) return JSON.stringify({}) as never; // no verify.flows + return PKG_JSON as never; + }); + + await runVerify([]); + + const npmTests = callsFor("npm").filter((a) => a[0] === "test"); + // No grep is appended when there are no flows. + expect(npmTests).toContainEqual(["test", "--", "--project", "react"]); + }); + + it("swallows a malformed override manifest and runs the whole suite", async () => { + process.env.SEAMLESS_REACT_DIR = "/fake/override-template"; + vi.mocked(fs.readFileSync).mockImplementation((p: never) => { + const s = String(p); + if (s.endsWith("template.json")) return "{ not json" as never; + return PKG_JSON as never; + }); + + await runVerify([]); + const npmTests = callsFor("npm").filter((a) => a[0] === "test"); + expect(npmTests).toContainEqual(["test", "--", "--project", "react"]); + }); + + it("throws when the override dir has no package.json", async () => { + process.env.SEAMLESS_REACT_DIR = "/fake/override-template"; + vi.mocked(fs.existsSync).mockImplementation( + (p: never) => !String(p).startsWith("/fake/override-template"), + ); + + await expect(runVerify([])).rejects.toThrow(/no package\.json/); + }); +}); + +describe("runVerify — setup failures (thrown before the run)", () => { + it("aborts when Docker is not installed", async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error("not found"); + }); + await expect(runVerify([])).rejects.toThrow(/Docker is required/); + }); + + it("aborts when the auth API source cannot be found", async () => { + vi.mocked(fs.existsSync).mockImplementation((p: never) => !String(p).includes("/fake/api")); + await expect(runVerify([])).rejects.toThrow(/seamless-auth-api/); + }); + + it("aborts when the templates registry is missing", async () => { + vi.mocked(fs.existsSync).mockImplementation((p: never) => !String(p).endsWith("registry.json")); + await expect(runVerify([])).rejects.toThrow(/templates registry/); + }); + + it("aborts when the registry has no runnable web templates", async () => { + vi.mocked(fs.readFileSync).mockImplementation((p: never) => { + const s = String(p); + if (s.endsWith("registry.json")) + return JSON.stringify({ templates: [{ id: "x", kind: "api", status: "stable", path: "x" }] }) as never; + return PKG_JSON as never; + }); + await expect(runVerify([])).rejects.toThrow(/no runnable web templates/); + }); + + it("aborts when a registered web template has no package.json", async () => { + vi.mocked(fs.existsSync).mockImplementation( + (p: never) => !String(p).includes("web-basic"), + ); + await expect(runVerify([])).rejects.toThrow(/has no package\.json/); + }); +}); + +describe("runVerify — conformance failures (caught, exits 1)", () => { + it("marks the run failed when a Playwright layer fails", async () => { + vi.mocked(runCommand).mockImplementation((cmd: string, args: string[]) => { + if (cmd === "npm" && args[0] === "test") return Promise.reject(new Error("tests failed")); + return Promise.resolve(); + }); + + await runVerify([]); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("reports a setup error and prints no layers when the stack fails to build", async () => { + vi.mocked(runCommand).mockImplementation((cmd: string, args: string[]) => { + // The initial clean uses `down`; the build uses `up` and is not swallowed. + if (cmd === "docker" && args.includes("up")) return Promise.reject(new Error("compose boom")); + return Promise.resolve(); + }); + // Force every package.json read to fail so the summary reports zero packages too. + vi.mocked(fs.readFileSync).mockImplementation((p: never) => { + const s = String(p); + if (s.endsWith("registry.json")) return REGISTRY_JSON as never; + if (s.endsWith("template.json")) return JSON.stringify({}) as never; + throw new Error("no manifest"); + }); + + await runVerify([]); + expect(exitSpy).toHaveBeenCalledWith(1); + const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(printed).toMatch(/Verify aborted/); + expect(printed).toMatch(/No conformance layers ran/); + }); +}); + +describe("runVerify — duration formatting in the summary", () => { + it("renders sub-minute layer durations as seconds", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(0) // startedAt + .mockReturnValueOnce(0) // layer started + .mockReturnValueOnce(5000) // layer finished ⇒ 5.0s + .mockReturnValue(5000); // elapsed total + + await runVerify(["--api-only"]); + const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(printed).toMatch(/5\.0s/); + }); + + it("renders multi-minute durations as minutes and seconds", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(0) // startedAt + .mockReturnValueOnce(0) // layer started + .mockReturnValueOnce(65000) // layer finished ⇒ 1m 5s + .mockReturnValue(300000); // elapsed total ⇒ 5m 0s + + await runVerify(["--api-only"]); + const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(printed).toMatch(/1m 5s/); + }); +}); diff --git a/src/commands/whoami.test.ts b/src/commands/whoami.test.ts new file mode 100644 index 0000000..093c158 --- /dev/null +++ b/src/commands/whoami.test.ts @@ -0,0 +1,153 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertProfile } from "../core/config.js"; +import { saveTokens, setBackendForTesting, type KeychainBackend } from "../core/keychain.js"; +import { runWhoami } from "./whoami.js"; + +function fakeBackend(): KeychainBackend { + const store = new Map(); + return { + get: (account) => store.get(account) ?? null, + set: (account, secret) => { + store.set(account, secret); + }, + delete: (account) => store.delete(account), + }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const profile = { name: "default", instanceUrl: "https://auth.example.com" }; + +let configHome: string; +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(async () => { + configHome = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-whoami-")); + process.env.XDG_CONFIG_HOME = configHome; + delete process.env.SEAMLESS_PROFILE; + delete process.env.SEAMLESS_REFRESH_TOKEN; + + setBackendForTesting(fakeBackend()); + upsertProfile(profile); + await saveTokens(profile, { accessToken: "access", refreshToken: "refresh" }); + + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + setBackendForTesting(null); + fs.rmSync(configHome, { recursive: true, force: true }); + delete process.env.XDG_CONFIG_HOME; +}); + +describe("runWhoami", () => { + it("prints the identity for the active profile", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + json({ + user: { id: "user-1", email: "dev@example.com", roles: ["admin", "user"] }, + }), + ), + ); + + await runWhoami([]); + + const lines = logSpy.mock.calls.map((c) => c[0] as string); + expect(lines.some((l) => l.includes("Profile") && l.includes("default"))).toBe(true); + expect(lines.some((l) => l.includes("Instance") && l.includes(profile.instanceUrl))).toBe( + true, + ); + expect(lines.some((l) => l.includes("Sub") && l.includes("user-1"))).toBe(true); + expect(lines.some((l) => l.includes("Email") && l.includes("dev@example.com"))).toBe(true); + expect(lines.some((l) => l.includes("Roles") && l.includes("admin, user"))).toBe(true); + }); + + it("falls back to the profile's sub/email and (unknown)/(none) when identity omits them", async () => { + upsertProfile({ ...profile, sub: "profile-sub", email: "profile@example.com" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ user: {} })), + ); + + await runWhoami([]); + + const lines = logSpy.mock.calls.map((c) => c[0] as string); + expect(lines.some((l) => l.includes("Sub") && l.includes("profile-sub"))).toBe(true); + expect(lines.some((l) => l.includes("Email") && l.includes("profile@example.com"))).toBe( + true, + ); + expect(lines.some((l) => l.includes("Roles") && l.includes("(none)"))).toBe(true); + }); + + it("shows (unknown) when neither identity nor profile has sub/email", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ user: {} })), + ); + + await runWhoami([]); + + const lines = logSpy.mock.calls.map((c) => c[0] as string); + expect(lines.some((l) => l.includes("Sub") && l.includes("(unknown)"))).toBe(true); + expect(lines.some((l) => l.includes("Email") && l.includes("(unknown)"))).toBe(true); + }); + + it("exits 1 with a yellow message when reauth is required", async () => { + setBackendForTesting({ + get: () => null, + set: () => {}, + delete: () => false, + }); + + await expect(runWhoami([])).rejects.toThrow("exit:1"); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Run: seamless login.")); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("exits 1 with a red error on other failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => json(null, 500)), + ); + + await expect(runWhoami([])).rejects.toThrow("exit:1"); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not load your identity"), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("honors the --profile flag", async () => { + const other = { name: "other", instanceUrl: "https://other.example.com" }; + upsertProfile(other); + await saveTokens(other, { accessToken: "a2", refreshToken: "r2" }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ user: { id: "other-user", roles: [] } })), + ); + + await runWhoami(["--profile", "other"]); + + const lines = logSpy.mock.calls.map((c) => c[0] as string); + expect(lines.some((l) => l.includes("Profile") && l.includes("other"))).toBe(true); + expect(lines.some((l) => l.includes("other-user"))).toBe(true); + }); +}); diff --git a/src/core/admin.test.ts b/src/core/admin.test.ts index c194923..b807350 100644 --- a/src/core/admin.test.ts +++ b/src/core/admin.test.ts @@ -6,6 +6,7 @@ import { AdminApiError, createOrg, deleteUser, + getOrg, getUserDetail, listMembers, listOrgs, @@ -111,6 +112,27 @@ describe("users", () => { const { client } = fakeClient(() => response(403, { error: "Forbidden" })); await expect(listUsers(client)).rejects.toBeInstanceOf(PermissionError); }); + + it("returns the recovery payload on a successful device replacement", async () => { + const { client } = fakeClient(() => response(200, { recoveryUrl: "https://x" })); + const result = await prepareDeviceReplacement(client, "u1", { + revokeSessions: true, + removePasskeys: false, + disableTotp: false, + }); + expect(result).toEqual({ recoveryUrl: "https://x" }); + }); + + it("maps a generic failure on device replacement to an AdminApiError", async () => { + const { client } = fakeClient(() => response(500, { error: "boom" })); + await expect( + prepareDeviceReplacement(client, "u1", { + revokeSessions: false, + removePasskeys: false, + disableTotp: false, + }), + ).rejects.toThrow(/Could not prepare device replacement/); + }); }); describe("organizations", () => { @@ -185,4 +207,59 @@ describe("organizations", () => { AdminApiError, ); }); + + it("gets a single org and unwraps the envelope", async () => { + const { client } = fakeClient(({ path }) => { + expect(path).toBe("/admin/organizations/o1"); + return response(200, { organization: { id: "o1", name: "Acme" } }); + }); + expect(await getOrg(client, "o1")).toEqual({ id: "o1", name: "Acme" }); + }); + + it("maps a 404 get org to a clear error", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect(getOrg(client, "missing")).rejects.toThrow(/No organization found/); + }); + + it("throws from the org envelope when the response is not ok", async () => { + const { client } = fakeClient(() => response(500, { error: "boom" })); + await expect(createOrg(client, { name: "Acme" })).rejects.toBeInstanceOf( + AdminApiError, + ); + }); + + it("maps a 404 on listMembers to a clear error", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect(listMembers(client, "missing")).rejects.toThrow( + /No organization found/, + ); + }); + + it("throws from the membership envelope when the response is not ok", async () => { + const { client } = fakeClient(() => response(500, { error: "boom" })); + await expect(addMember(client, "o1", { email: "x@example.com" })).rejects.toBeInstanceOf( + AdminApiError, + ); + }); + + it("maps a 404 on addMember to a clear error", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect( + addMember(client, "missing", { email: "x@example.com" }), + ).rejects.toThrow(/No organization found/); + }); + + it("maps a 404 on updateMember to a clear error", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect( + updateMember(client, "o1", "missing", { roles: ["admin"] }), + ).rejects.toThrow(/No such organization or member/); + }); + + it("maps a 404 on removeMember to a clear error", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect(removeMember(client, "o1", "missing")).rejects.toThrow( + /No such organization or member/, + ); + }); }); diff --git a/src/core/args.test.ts b/src/core/args.test.ts new file mode 100644 index 0000000..3d14dcc --- /dev/null +++ b/src/core/args.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { extractFlag } from "./args.js"; + +describe("extractFlag", () => { + it("extracts a --flag value pair and removes both from rest", () => { + const result = extractFlag(["--name", "acme", "positional"], "name"); + expect(result).toEqual({ value: "acme", rest: ["positional"] }); + }); + + it("extracts a --flag=value form", () => { + const result = extractFlag(["--name=acme", "positional"], "name"); + expect(result).toEqual({ value: "acme", rest: ["positional"] }); + }); + + it("returns undefined value when the flag is missing", () => { + const result = extractFlag(["positional"], "name"); + expect(result).toEqual({ value: undefined, rest: ["positional"] }); + }); + + it("keeps the last occurrence when the flag is repeated", () => { + const result = extractFlag(["--name", "first", "--name", "second"], "name"); + expect(result).toEqual({ value: "second", rest: [] }); + }); + + it("does not confuse a similarly prefixed flag with the target flag", () => { + const result = extractFlag(["--nameOther", "x", "--name", "y"], "name"); + expect(result).toEqual({ value: "y", rest: ["--nameOther", "x"] }); + }); + + it("returns an empty rest and undefined value for an empty args list", () => { + const result = extractFlag([], "name"); + expect(result).toEqual({ value: undefined, rest: [] }); + }); +}); diff --git a/src/core/authClient.test.ts b/src/core/authClient.test.ts index c313cf8..b4b2756 100644 --- a/src/core/authClient.test.ts +++ b/src/core/authClient.test.ts @@ -6,6 +6,7 @@ import { upsertProfile } from "./config.js"; import { isRateLimited } from "./http.js"; import { getTokens, + KeychainUnavailableError, saveTokens, setBackendForTesting, type KeychainBackend, @@ -79,6 +80,12 @@ afterEach(() => { }); describe("createAuthClient", () => { + it("throws a reauth error when there is no active profile", async () => { + await expect( + createAuthClient({ profileFlag: "ghost" }), + ).rejects.toBeInstanceOf(ReauthRequiredError); + }); + it("throws a reauth error when there is no session", async () => { await expect(createAuthClient()).rejects.toBeInstanceOf(ReauthRequiredError); }); @@ -157,6 +164,122 @@ describe("transparent refresh", () => { expect(await getTokens(profile)).toBeNull(); }); + + it("demands re-login when the refresh response is missing token fields", async () => { + await saveTokens(profile, { + accessToken: "old-access", + refreshToken: "old-refresh", + }); + + mockFetch([ + () => new Response(null, { status: 401 }), + () => json({ message: "no tokens here" }), + ]); + + const client = await createAuthClient(); + await expect(client.get("/whoami")).rejects.toThrow( + /unexpected refresh response/, + ); + + expect(await getTokens(profile)).toBeNull(); + }); + + it("swallows a KeychainUnavailableError when persisting rotated tokens", async () => { + const store = new Map(); + const key = `${profile.name}::${profile.instanceUrl}`; + store.set( + key, + JSON.stringify({ accessToken: "old-access", refreshToken: "old-refresh" }), + ); + setBackendForTesting({ + get: (account) => store.get(account) ?? null, + set: () => { + throw new KeychainUnavailableError(); + }, + delete: (account) => store.delete(account), + }); + + mockFetch([ + () => new Response(null, { status: 401 }), + () => json({ token: "new-access", refreshToken: "new-refresh" }), + () => json({ sub: "user-1" }), + ]); + + const client = await createAuthClient(); + const res = await client.get("/whoami"); + expect(res.ok).toBe(true); + }); + + it("propagates unexpected errors when persisting rotated tokens", async () => { + await saveTokens(profile, { + accessToken: "old-access", + refreshToken: "old-refresh", + }); + const client = await createAuthClient(); + + setBackendForTesting({ + get: () => null, + set: () => { + throw new Error("disk full"); + }, + delete: () => false, + }); + + mockFetch([ + () => new Response(null, { status: 401 }), + () => json({ token: "new-access", refreshToken: "new-refresh" }), + ]); + + await expect(client.get("/whoami")).rejects.toThrow("disk full"); + }); + + it("propagates unexpected errors when clearing a rejected session", async () => { + await saveTokens(profile, { + accessToken: "old-access", + refreshToken: "reused-refresh", + }); + const client = await createAuthClient(); + + setBackendForTesting({ + get: () => null, + set: () => {}, + delete: () => { + throw new Error("keychain locked"); + }, + }); + + mockFetch([ + () => new Response(null, { status: 401 }), + () => json({ error: "token reuse detected" }, 401), + ]); + + await expect(client.get("/whoami")).rejects.toThrow("keychain locked"); + }); + + it("swallows a KeychainUnavailableError when clearing a rejected session", async () => { + await saveTokens(profile, { + accessToken: "old-access", + refreshToken: "reused-refresh", + }); + const client = await createAuthClient(); + + setBackendForTesting({ + get: () => null, + set: () => {}, + delete: () => { + throw new KeychainUnavailableError(); + }, + }); + + mockFetch([ + () => new Response(null, { status: 401 }), + () => json({ error: "token reuse detected" }, 401), + ]); + + await expect(client.get("/whoami")).rejects.toBeInstanceOf( + ReauthRequiredError, + ); + }); }); describe("error and edge responses", () => { diff --git a/src/core/bootstrapSecret.test.ts b/src/core/bootstrapSecret.test.ts new file mode 100644 index 0000000..85fe75f --- /dev/null +++ b/src/core/bootstrapSecret.test.ts @@ -0,0 +1,89 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveBootstrapSecret } from "./bootstrapSecret.js"; + +let tmpDir: string; +let originalCwd: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-bootstrap-")); + originalCwd = process.cwd(); + process.chdir(tmpDir); + delete process.env.SEAMLESS_BOOTSTRAP_SECRET; +}); + +afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.SEAMLESS_BOOTSTRAP_SECRET; +}); + +describe("resolveBootstrapSecret", () => { + it("prefers the SEAMLESS_BOOTSTRAP_SECRET env var over any file", () => { + process.env.SEAMLESS_BOOTSTRAP_SECRET = '"env-secret"'; + fs.writeFileSync(path.join(tmpDir, ".env"), "SEAMLESS_BOOTSTRAP_SECRET=file-secret\n"); + + expect(resolveBootstrapSecret()).toBe("env-secret"); + }); + + it("reads from the root .env file when no env var is set", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "SEAMLESS_BOOTSTRAP_SECRET=root-secret\n"); + expect(resolveBootstrapSecret()).toBe("root-secret"); + }); + + it("falls back to auth/.env when the root .env has no match", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "OTHER=1\n"); + fs.mkdirSync(path.join(tmpDir, "auth")); + fs.writeFileSync( + path.join(tmpDir, "auth", ".env"), + "SEAMLESS_BOOTSTRAP_SECRET=auth-secret\n", + ); + + expect(resolveBootstrapSecret()).toBe("auth-secret"); + }); + + it("falls back to docker-compose.yml when no .env files match", () => { + fs.writeFileSync( + path.join(tmpDir, "docker-compose.yml"), + "services:\n auth:\n environment:\n SEAMLESS_BOOTSTRAP_SECRET: compose-secret\n", + ); + + expect(resolveBootstrapSecret()).toBe("compose-secret"); + }); + + it("returns null when nothing matches anywhere", () => { + expect(resolveBootstrapSecret()).toBeNull(); + }); + + it("strips matching single quotes from a normalized value", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "SEAMLESS_BOOTSTRAP_SECRET='quoted-secret'\n"); + expect(resolveBootstrapSecret()).toBe("quoted-secret"); + }); + + it("trims whitespace without unquoting mismatched quotes", () => { + fs.writeFileSync( + path.join(tmpDir, ".env"), + 'SEAMLESS_BOOTSTRAP_SECRET= "unbalanced \n', + ); + expect(resolveBootstrapSecret()).toBe('"unbalanced'); + }); + + it("returns null when the root .env exists but has no matching key", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "OTHER=1\n"); + expect(resolveBootstrapSecret()).toBeNull(); + }); + + it("returns null when auth/.env exists but has no matching key", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "OTHER=1\n"); + fs.mkdirSync(path.join(tmpDir, "auth")); + fs.writeFileSync(path.join(tmpDir, "auth", ".env"), "OTHER=1\n"); + expect(resolveBootstrapSecret()).toBeNull(); + }); + + it("returns null when docker-compose.yml exists but has no matching key", () => { + fs.writeFileSync(path.join(tmpDir, "docker-compose.yml"), "services:\n auth: {}\n"); + expect(resolveBootstrapSecret()).toBeNull(); + }); +}); diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 1593d35..0721a08 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -44,6 +44,42 @@ describe("loadConfig", () => { fs.writeFileSync(getConfigPath(), "{ not json"); expect(() => loadConfig()).toThrow(/not valid JSON/); }); + + it("throws a clear error when the config file cannot be read", () => { + fs.mkdirSync(getConfigPath(), { recursive: true }); + expect(() => loadConfig()).toThrow(/Unable to read config/); + }); + + it("picks up a persisted identifierType", () => { + fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true }); + fs.writeFileSync( + getConfigPath(), + JSON.stringify({ + activeProfile: "prod", + profiles: { + prod: { + name: "prod", + instanceUrl: "https://auth.example.com", + identifierType: "phone", + }, + }, + }), + ); + expect(getProfile("prod")?.identifierType).toBe("phone"); + }); + + it("falls back to the default active profile when unset", () => { + fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true }); + fs.writeFileSync( + getConfigPath(), + JSON.stringify({ + profiles: { + prod: { name: "prod", instanceUrl: "https://auth.example.com" }, + }, + }), + ); + expect(loadConfig().activeProfile).toBe("default"); + }); }); describe("upsertProfile", () => { @@ -114,6 +150,12 @@ describe("resolveActiveProfileName", () => { it("falls back to the persisted active profile", () => { expect(resolveActiveProfileName({})).toBe("prod"); }); + + it("falls back to the default profile name when nothing else is set", () => { + expect( + resolveActiveProfileName({}, { activeProfile: "", profiles: {} }), + ).toBe("default"); + }); }); describe("normalizeInstanceUrl", () => { @@ -151,4 +193,10 @@ describe("normalizeInstanceUrl", () => { it("rejects an empty value", () => { expect(() => normalizeInstanceUrl(" ")).toThrow(/required/); }); + + it("rejects a non-http(s) scheme", () => { + expect(() => normalizeInstanceUrl("ftp://auth.example.com")).toThrow( + /must use http or https/, + ); + }); }); diff --git a/src/core/env.test.ts b/src/core/env.test.ts new file mode 100644 index 0000000..271f50f --- /dev/null +++ b/src/core/env.test.ts @@ -0,0 +1,114 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { parseEnv, parseEnvString, writeEnv } from "./env.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-env-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("parseEnv", () => { + it("parses simple key=value lines from a file", () => { + const file = path.join(tmpDir, ".env"); + fs.writeFileSync(file, "FOO=bar\nBAZ=qux\n"); + + expect(parseEnv(file)).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + it("skips blank lines and comment lines", () => { + const file = path.join(tmpDir, ".env"); + fs.writeFileSync(file, "\n# a comment\nFOO=bar\n\n#another\nBAZ=qux\n"); + + expect(parseEnv(file)).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + it("skips lines with no key", () => { + const file = path.join(tmpDir, ".env"); + fs.writeFileSync(file, "=novalue\nFOO=bar\n"); + + expect(parseEnv(file)).toEqual({ FOO: "bar" }); + }); + + it("trims whitespace around key and value", () => { + const file = path.join(tmpDir, ".env"); + fs.writeFileSync(file, " FOO = bar \n"); + + expect(parseEnv(file)).toEqual({ FOO: "bar" }); + }); + + it("rejoins values that contain an equals sign", () => { + const file = path.join(tmpDir, ".env"); + fs.writeFileSync(file, "URL=https://example.com?a=1&b=2\n"); + + expect(parseEnv(file)).toEqual({ URL: "https://example.com?a=1&b=2" }); + }); + + it("keeps quotes in values as-is (no unquoting)", () => { + const file = path.join(tmpDir, ".env"); + fs.writeFileSync(file, 'FOO="bar baz"\n'); + + expect(parseEnv(file)).toEqual({ FOO: '"bar baz"' }); + }); +}); + +describe("parseEnvString", () => { + it("parses simple key=value lines from a string", () => { + expect(parseEnvString("FOO=bar\nBAZ=qux\n")).toEqual({ + FOO: "bar", + BAZ: "qux", + }); + }); + + it("skips blank lines and comment lines", () => { + expect(parseEnvString("\n# a comment\nFOO=bar\n\n#another\n")).toEqual({ + FOO: "bar", + }); + }); + + it("skips lines with no key", () => { + expect(parseEnvString("=novalue\nFOO=bar\n")).toEqual({ FOO: "bar" }); + }); + + it("trims whitespace around key and value", () => { + expect(parseEnvString(" FOO = bar ")).toEqual({ FOO: "bar" }); + }); + + it("rejoins values that contain an equals sign", () => { + expect(parseEnvString("A=1=2=3")).toEqual({ A: "1=2=3" }); + }); + + it("returns an empty object for empty content", () => { + expect(parseEnvString("")).toEqual({}); + }); +}); + +describe("writeEnv", () => { + it("writes key=value pairs, one per line, with a trailing newline", () => { + const file = path.join(tmpDir, "out.env"); + writeEnv(file, { FOO: "bar", BAZ: "qux" }); + + expect(fs.readFileSync(file, "utf-8")).toBe("FOO=bar\nBAZ=qux\n"); + }); + + it("writes just a trailing newline for an empty env object", () => { + const file = path.join(tmpDir, "empty.env"); + writeEnv(file, {}); + + expect(fs.readFileSync(file, "utf-8")).toBe("\n"); + }); + + it("round-trips through parseEnv", () => { + const file = path.join(tmpDir, "roundtrip.env"); + const original = { A: "1", B: "two", C: "" }; + writeEnv(file, original); + + expect(parseEnv(file)).toEqual(original); + }); +}); diff --git a/src/core/exec.test.ts b/src/core/exec.test.ts new file mode 100644 index 0000000..1bee57d --- /dev/null +++ b/src/core/exec.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from "events"; +import { describe, expect, it, vi } from "vitest"; +import { spawn } from "child_process"; +import { runCommand } from "./exec.js"; + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})); + +function fakeChild() { + const emitter = new EventEmitter(); + return emitter; +} + +describe("runCommand", () => { + it("spawns the command with inherited stdio and resolves on a zero exit code", async () => { + const child = fakeChild(); + vi.mocked(spawn).mockReturnValue(child as never); + + const promise = runCommand("echo", ["hi"], "/tmp/proj"); + child.emit("close", 0); + await expect(promise).resolves.toBeUndefined(); + + expect(spawn).toHaveBeenCalledWith("echo", ["hi"], { + stdio: "inherit", + cwd: "/tmp/proj", + shell: true, + env: process.env, + }); + }); + + it("uses a supplied env instead of process.env", async () => { + const child = fakeChild(); + vi.mocked(spawn).mockReturnValue(child as never); + const customEnv = { CUSTOM: "1" }; + + const promise = runCommand("echo", [], "/tmp/proj", customEnv); + child.emit("close", 0); + await promise; + + expect(spawn).toHaveBeenCalledWith("echo", [], { + stdio: "inherit", + cwd: "/tmp/proj", + shell: true, + env: customEnv, + }); + }); + + it("rejects with the command name when the exit code is non-zero", async () => { + const child = fakeChild(); + vi.mocked(spawn).mockReturnValue(child as never); + + const promise = runCommand("failing-cmd", [], "/tmp/proj"); + child.emit("close", 1); + + await expect(promise).rejects.toThrow("failing-cmd failed"); + }); +}); diff --git a/src/core/fetch.test.ts b/src/core/fetch.test.ts new file mode 100644 index 0000000..5ce2e7f --- /dev/null +++ b/src/core/fetch.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SEAMLESS_AUTH_API_VERSION } from "./images.js"; +import { fetchEnvExample } from "./fetch.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchEnvExample", () => { + it("fetches the pinned env.example from the auth API repo", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + text: async () => "FOO=bar\n", + })); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchEnvExample(); + + expect(result).toBe("FOO=bar\n"); + expect(fetchMock).toHaveBeenCalledWith( + `https://raw.githubusercontent.com/fells-code/seamless-auth-api/${SEAMLESS_AUTH_API_VERSION}/.env.example`, + ); + }); + + it("throws when the response is not ok", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, text: async () => "" })), + ); + + await expect(fetchEnvExample()).rejects.toThrow( + "Failed to fetch auth env.example", + ); + }); + + it("propagates a network failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + + await expect(fetchEnvExample()).rejects.toThrow("network down"); + }); +}); diff --git a/src/core/images.test.ts b/src/core/images.test.ts new file mode 100644 index 0000000..9b49a1b --- /dev/null +++ b/src/core/images.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + POSTGRES_IMAGE, + SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE, + SEAMLESS_AUTH_ADMIN_DASHBOARD_VERSION, + SEAMLESS_AUTH_API_IMAGE, + SEAMLESS_AUTH_API_VERSION, + SEAMLESS_TEMPLATES_REF, + SEAMLESS_TEMPLATES_REPO, +} from "./images.js"; + +describe("image and version constants", () => { + it("pins the postgres image", () => { + expect(POSTGRES_IMAGE).toBe("postgres:17"); + }); + + it("builds the auth API image tag from its version", () => { + expect(SEAMLESS_AUTH_API_IMAGE).toBe( + `ghcr.io/fells-code/seamless-auth-api:${SEAMLESS_AUTH_API_VERSION}`, + ); + }); + + it("builds the admin dashboard image tag from its version", () => { + expect(SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE).toBe( + `ghcr.io/fells-code/seamless-auth-admin-dashboard:${SEAMLESS_AUTH_ADMIN_DASHBOARD_VERSION}`, + ); + }); + + it("defines the templates monorepo location", () => { + expect(SEAMLESS_TEMPLATES_REPO).toBe("fells-code/seamless-templates"); + expect(SEAMLESS_TEMPLATES_REF).toBeTruthy(); + }); +}); diff --git a/src/core/inspect.test.ts b/src/core/inspect.test.ts new file mode 100644 index 0000000..a88727d --- /dev/null +++ b/src/core/inspect.test.ts @@ -0,0 +1,46 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { inspectProject } from "./inspect.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-inspect-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("inspectProject", () => { + it("reports package.json detected and picks up the package manager", async () => { + fs.writeFileSync(path.join(tmpDir, "package.json"), "{}"); + fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), ""); + + const result = await inspectProject(tmpDir); + + expect(result).toEqual({ + root: tmpDir, + packageManager: "pnpm", + detected: { + packageJson: true, + anything: true, + }, + }); + }); + + it("reports nothing detected and defaults to npm when the directory is empty", async () => { + const result = await inspectProject(tmpDir); + + expect(result).toEqual({ + root: tmpDir, + packageManager: "npm", + detected: { + packageJson: false, + anything: false, + }, + }); + }); +}); diff --git a/src/core/jwks.test.ts b/src/core/jwks.test.ts new file mode 100644 index 0000000..e44b318 --- /dev/null +++ b/src/core/jwks.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { generateJWKS } from "./jwks.js"; + +describe("generateJWKS", () => { + it("returns a PEM-encoded RSA public and private key pair", () => { + const { publicKey, privateKey } = generateJWKS(); + + expect(publicKey).toMatch(/^-----BEGIN PUBLIC KEY-----/); + expect(publicKey.trim()).toMatch(/-----END PUBLIC KEY-----$/); + expect(privateKey).toMatch(/^-----BEGIN PRIVATE KEY-----/); + expect(privateKey.trim()).toMatch(/-----END PRIVATE KEY-----$/); + }); + + it("generates a distinct key pair on each call", () => { + const first = generateJWKS(); + const second = generateJWKS(); + expect(first.privateKey).not.toBe(second.privateKey); + expect(first.publicKey).not.toBe(second.publicKey); + }); +}); diff --git a/src/core/keychain.test.ts b/src/core/keychain.test.ts index 1be2e6e..621f3c2 100644 --- a/src/core/keychain.test.ts +++ b/src/core/keychain.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { accountKey, deleteTokens, @@ -132,3 +132,87 @@ describe("redaction", () => { }); }); }); + +describe("loadBackend (real backend, mocked @napi-rs/keyring)", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock("@napi-rs/keyring"); + vi.resetModules(); + }); + + it("wraps a failure to load the native module in a KeychainUnavailableError", async () => { + vi.doMock("@napi-rs/keyring", () => { + throw new Error("native module not found for this platform"); + }); + + const mod = await import("./keychain.js"); + await expect(mod.saveTokens(prod, bundle)).rejects.toBeInstanceOf( + mod.KeychainUnavailableError, + ); + }); + + it("gets, sets, and deletes through the real Entry-backed backend", async () => { + const store = new Map(); + vi.doMock("@napi-rs/keyring", () => ({ + Entry: class { + account: string; + constructor(_service: string, account: string) { + this.account = account; + } + getPassword() { + const value = store.get(this.account); + if (value === undefined) throw new Error("no matching entry found"); + return value; + } + setPassword(secret: string) { + store.set(this.account, secret); + } + deletePassword() { + if (!store.has(this.account)) throw new Error("entry not found"); + store.delete(this.account); + return true; + } + }, + })); + + const mod = await import("./keychain.js"); + + expect(await mod.getTokens(prod)).toBeNull(); + + await mod.saveTokens(prod, bundle); + expect(await mod.getTokens(prod)).toEqual(bundle); + + expect(await mod.deleteTokens(prod)).toBe(true); + expect(await mod.deleteTokens(prod)).toBe(false); + }); + + it("wraps unexpected keychain errors in a KeychainUnavailableError", async () => { + vi.doMock("@napi-rs/keyring", () => ({ + Entry: class { + getPassword(): string { + throw new Error("permission denied"); + } + setPassword(): void { + throw new Error("permission denied"); + } + deletePassword(): boolean { + throw new Error("permission denied"); + } + }, + })); + + const mod = await import("./keychain.js"); + await expect(mod.getTokens(prod)).rejects.toBeInstanceOf( + mod.KeychainUnavailableError, + ); + await expect(mod.saveTokens(prod, bundle)).rejects.toBeInstanceOf( + mod.KeychainUnavailableError, + ); + await expect(mod.deleteTokens(prod)).rejects.toBeInstanceOf( + mod.KeychainUnavailableError, + ); + }); +}); diff --git a/src/core/loginFlow.test.ts b/src/core/loginFlow.test.ts index 545442b..39ad6af 100644 --- a/src/core/loginFlow.test.ts +++ b/src/core/loginFlow.test.ts @@ -240,4 +240,204 @@ describe("completeLogin", () => { expect(result).toBeNull(); }); + + it("runs the phone OTP flow, defaulting loginMethods when omitted", async () => { + const calls = mockRouter({ + "/login": [() => json({ token: "e1", identifierType: "phone" })], + "/otp/generate-login-phone-otp": [() => json({ message: "sent" })], + "/otp/verify-login-phone-otp": [ + () => json({ token: "a", refreshToken: "r" }), + ], + }); + + const result = await completeLogin({ + instanceUrl: INSTANCE, + identifier: "+15555550100", + getCode: async () => "123456", + }); + + expect(result?.identity.identifierType).toBe("phone"); + expect(calls.some((c) => c.url.endsWith("/otp/generate-login-phone-otp"))).toBe( + true, + ); + expect(calls.some((c) => c.url.endsWith("/otp/verify-login-phone-otp"))).toBe( + true, + ); + }); + + it("wraps a network failure while starting login", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }), + ); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/Could not reach/); + }); + + it("surfaces the rate limiter when starting login", async () => { + mockRouter({ + "/login": [() => json({ error: "rate limited" }, 429)], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/rate limiting requests/); + }); + + it("rejects an invalid identifier with a 400", async () => { + mockRouter({ + "/login": [() => json({ error: "Bad identifier" }, 400)], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "not-an-identifier", + getCode: async () => "123456", + }), + ).rejects.toThrow(/not a valid email or phone number/); + }); + + it("rejects an unknown account with a 401 that isn't a verify message", async () => { + mockRouter({ + "/login": [() => json({ error: "No such account" }, 401)], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/No account was found/); + }); + + it("maps other login failures to a generic status error", async () => { + mockRouter({ + "/login": [() => json({ error: "boom" }, 500)], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/Login request failed \(500\)/); + }); + + it("rejects when the instance omits the ephemeral token", async () => { + mockRouter({ + "/login": [() => json({ identifierType: "email", loginMethods: ["email_otp"] })], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/did not return a login token/); + }); + + it("rejects when OTP login is disabled on the instance", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ error: "disabled" }, 403)], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/Email OTP login is disabled/); + }); + + it("includes the API message when sending a code fails", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [ + () => json({ message: "SMTP is down" }, 500), + ], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/Could not send a code: SMTP is down/); + }); + + it("falls back to a generic message when sending a code fails without detail", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({}, 500)], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/Could not send a login code\./); + }); + + it("rejects an unexpected verification response shape", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ status: "ok" })], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/unexpected verification response/); + }); + + it("surfaces the rate limiter when verifying a code", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ error: "rate limited" }, 429)], + }); + + await expect( + completeLogin({ + instanceUrl: INSTANCE, + identifier: "dev@example.com", + getCode: async () => "123456", + }), + ).rejects.toThrow(/Too many attempts/); + }); }); diff --git a/src/core/oauthProviders.test.ts b/src/core/oauthProviders.test.ts new file mode 100644 index 0000000..536974a --- /dev/null +++ b/src/core/oauthProviders.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + buildOAuthAuthEnv, + OAUTH_PROVIDER_CATALOG, + withLoginMethod, + type CollectedOAuthProvider, +} from "./oauthProviders.js"; + +function collected( + id: string, + clientId: string, + clientSecret: string, +): CollectedOAuthProvider { + const catalog = OAUTH_PROVIDER_CATALOG.find((p) => p.id === id); + if (!catalog) throw new Error(`unknown test provider ${id}`); + return { catalog, clientId, clientSecret }; +} + +describe("OAUTH_PROVIDER_CATALOG", () => { + it("contains the four supported providers", () => { + expect(OAUTH_PROVIDER_CATALOG.map((p) => p.id)).toEqual([ + "google", + "github", + "microsoft", + "gitlab", + ]); + }); + + it("does not include Apple", () => { + expect(OAUTH_PROVIDER_CATALOG.some((p) => p.id === "apple")).toBe(false); + }); +}); + +describe("buildOAuthAuthEnv", () => { + it("returns empty env/pending for no providers, but still sets shared keys", () => { + const { env, pending } = buildOAuthAuthEnv([]); + + expect(pending).toEqual([]); + expect(JSON.parse(env.OAUTH_PROVIDERS)).toEqual([]); + expect(env.OAUTH_STATE_SECRET).toMatch(/^[0-9a-f]{64}$/); + }); + + it("marks a fully-credentialed provider enabled with no pending entry", () => { + const { env, pending } = buildOAuthAuthEnv([ + collected("google", "client-123", "secret-456"), + ]); + + expect(pending).toEqual([]); + expect(env.GOOGLE_CLIENT_SECRET).toBe("secret-456"); + + const configs = JSON.parse(env.OAUTH_PROVIDERS); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ + id: "google", + name: "Google", + enabled: true, + clientId: "client-123", + clientSecretEnv: "GOOGLE_CLIENT_SECRET", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo", + scopes: ["openid", "email", "profile"], + redirectUri: "http://localhost:5173/oauth/callback", + redirectUris: ["http://localhost:5173/oauth/callback"], + pkce: true, + }); + }); + + it("marks a provider missing the client id as disabled and pending, with a placeholder id", () => { + const { env, pending } = buildOAuthAuthEnv([ + collected("github", "", "secret-456"), + ]); + + expect(pending).toEqual(["GitHub"]); + const configs = JSON.parse(env.OAUTH_PROVIDERS); + expect(configs[0].enabled).toBe(false); + expect(configs[0].clientId).toBe("REPLACE_WITH_GITHUB_CLIENT_ID"); + // github has extra claim-path overrides and no pkce flag + expect(configs[0].subjectJsonPath).toBe("id"); + expect(configs[0].nameJsonPath).toBe("name"); + expect(configs[0].pkce).toBeUndefined(); + }); + + it("marks a provider missing the client secret as disabled and pending", () => { + const { env, pending } = buildOAuthAuthEnv([ + collected("microsoft", "client-only", ""), + ]); + + expect(pending).toEqual(["Microsoft"]); + expect(env.MICROSOFT_CLIENT_SECRET).toBe(""); + const configs = JSON.parse(env.OAUTH_PROVIDERS); + expect(configs[0].enabled).toBe(false); + expect(configs[0].clientId).toBe("client-only"); + }); + + it("handles multiple providers, replacing dashes in the secret env name", () => { + const { env, pending } = buildOAuthAuthEnv([ + collected("google", "g-id", "g-secret"), + collected("gitlab", "", ""), + ]); + + expect(pending).toEqual(["GitLab"]); + expect(env.GOOGLE_CLIENT_SECRET).toBe("g-secret"); + expect(env.GITLAB_CLIENT_SECRET).toBe(""); + + const configs = JSON.parse(env.OAUTH_PROVIDERS); + expect(configs).toHaveLength(2); + expect(configs[1].clientSecretEnv).toBe("GITLAB_CLIENT_SECRET"); + }); +}); + +describe("withLoginMethod", () => { + it("defaults to passkey,magic_link when current is undefined and appends the method", () => { + expect(withLoginMethod(undefined, "oauth")).toBe( + "passkey,magic_link,oauth", + ); + }); + + it("appends the method to an existing list", () => { + expect(withLoginMethod("passkey", "oauth")).toBe("passkey,oauth"); + }); + + it("does not duplicate a method that is already present", () => { + expect(withLoginMethod("passkey,oauth", "oauth")).toBe("passkey,oauth"); + }); + + it("trims whitespace and drops empty entries", () => { + expect(withLoginMethod(" passkey , , oauth ", "magic_link")).toBe( + "passkey,oauth,magic_link", + ); + }); + + it("treats an empty string as current as having no existing methods (nullish coalescing does not apply)", () => { + expect(withLoginMethod("", "oauth")).toBe("oauth"); + }); +}); diff --git a/src/core/output.test.ts b/src/core/output.test.ts new file mode 100644 index 0000000..20d33f6 --- /dev/null +++ b/src/core/output.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { printManagedSuccessOutput, printSuccessOutput } from "./output.js"; + +let logSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + logSpy.mockRestore(); +}); + +function allLogs(): string { + return logSpy.mock.calls.map((call) => call.join(" ")).join("\n"); +} + +describe("printSuccessOutput", () => { + it("prints local-mode output with web + api frameworks, projectName and useDocker", () => { + printSuccessOutput({ + projectName: "my-app", + root: "/tmp/my-app", + webFramework: "react", + apiFramework: "express", + authMode: "local", + useDocker: true, + }); + + const out = allLogs(); + expect(out).toContain("SEAMLESS"); + expect(out).toContain("Project initialized successfully."); + expect(out).toContain("Project directory: "); + expect(out).toContain("my-app"); + expect(out).toContain("cd my-app"); + expect(out).toContain("Web application"); + expect(out).toContain("(React)"); + expect(out).toContain("API server"); + expect(out).toContain("(Express)"); + expect(out).toContain("(local source)"); + expect(out).toContain("Admin dashboard"); + expect(out).toContain("1. Start services"); + expect(out).toContain("docker compose up"); + expect(out).toContain("2. Create your first admin user"); + expect(out).toContain("seamless bootstrap-admin"); + expect(out).toContain("3. Complete registration in the browser"); + expect(out).toContain("http://localhost:5312"); + expect(out).toContain("http://localhost:3000"); + expect(out).toContain("http://localhost:5173"); + expect(out).toContain("http://localhost:5174"); + expect(out).toContain("Docs: "); + expect(out).toContain("https://docs.seamlessauth.com"); + expect(out).toContain("Setup complete."); + }); + + it("prints docker authMode label even when useDocker is falsy (symbol edge case not exercised here)", () => { + printSuccessOutput({ + root: "/tmp/my-app", + webFramework: null, + apiFramework: null, + authMode: "docker", + useDocker: false, + }); + + const out = allLogs(); + expect(out).toContain("(Docker image)"); + // No project name section when omitted + expect(out).not.toContain("Project directory: "); + // No web/api service lines + expect(out).not.toContain("Web application"); + expect(out).not.toContain("API server"); + // Local (non-docker) branch: no local-mode auth server steps since authMode is "docker" + expect(out).not.toContain("Requires a local PostgreSQL instance"); + // No API/Web url lines + expect(out).not.toContain("API: "); + expect(out).not.toContain("Web: "); + expect(out).toContain("2. Create your first admin user"); + }); + + it("prints local (non-docker) auth server setup steps when authMode is local and useDocker is false", () => { + printSuccessOutput({ + root: "/tmp/my-app", + webFramework: "vue", + apiFramework: "fastapi", + authMode: "local", + useDocker: false, + }); + + const out = allLogs(); + expect(out).toContain("# Auth server"); + expect(out).toContain( + "Requires a local PostgreSQL instance running on localhost:5432", + ); + expect(out).toContain("cd auth"); + expect(out).toContain("npm install"); + expect(out).toContain("# Initialize database"); + expect(out).toContain("npm run db:create"); + expect(out).toContain("npm run db:migrate"); + expect(out).toContain("# Start auth server"); + expect(out).toContain("npm run dev"); + expect(out).toContain("# API server"); + expect(out).toContain("cd api && npm install && npm run dev"); + expect(out).toContain("# Web app"); + expect(out).toContain("cd web && npm install && npm run dev"); + expect(out).toContain("(FastAPI)"); + expect(out).toContain("(Vue)"); + }); + + it("handles an unknown framework name by passing it through unchanged", () => { + printSuccessOutput({ + root: "/tmp/my-app", + webFramework: "svelte", + apiFramework: "django", + authMode: "docker", + useDocker: true, + }); + + const out = allLogs(); + expect(out).toContain("(svelte)"); + expect(out).toContain("(django)"); + }); + + it("covers useDocker as a symbol (truthy, non-boolean) taking the docker branch", () => { + printSuccessOutput({ + root: "/tmp/my-app", + webFramework: null, + apiFramework: null, + authMode: "docker", + useDocker: Symbol("cancel"), + }); + + const out = allLogs(); + expect(out).toContain("1. Start services"); + expect(out).toContain("docker compose up"); + }); +}); + +describe("printManagedSuccessOutput", () => { + it("prints managed output with web + api frameworks and projectName", () => { + printManagedSuccessOutput({ + projectName: "my-app", + webFramework: "next", + apiFramework: "fastify", + authServerUrl: "https://auth.example.com", + appName: "Acme App", + }); + + const out = allLogs(); + expect(out).toContain("SEAMLESS"); + expect(out).toContain("Project connected to a managed instance."); + expect(out).toContain("Managed application: "); + expect(out).toContain("Acme App"); + expect(out).toContain("Auth server: "); + expect(out).toContain("https://auth.example.com"); + expect(out).toContain("Project directory: "); + expect(out).toContain("my-app"); + expect(out).toContain("cd my-app"); + expect(out).toContain("Web application"); + expect(out).toContain("(Next.js)"); + expect(out).toContain("API server"); + expect(out).toContain("(Fastify)"); + expect(out).toContain("(managed instance)"); + expect(out).toContain("# API server"); + expect(out).toContain("cd api && npm install && npm run dev"); + expect(out).toContain("# Web app"); + expect(out).toContain("cd web && npm install && npm run dev"); + expect(out).toContain( + "Sign in from the web app to confirm the session resolves.", + ); + expect(out).toContain( + "The API service token was written to api/.env. Keep it out of version control.", + ); + expect(out).toContain( + "Auth, users, and OAuth providers are managed from the dashboard, not locally.", + ); + expect(out).toContain( + "Rotate the service token anytime with the dashboard.", + ); + expect(out).toContain("Docs: "); + expect(out).toContain("https://docs.seamlessauth.com"); + expect(out).toContain("Setup complete."); + }); + + it("omits project directory and web/api service lines when unset", () => { + printManagedSuccessOutput({ + webFramework: null, + apiFramework: null, + authServerUrl: "https://auth.example.com", + appName: "Acme App", + }); + + const out = allLogs(); + expect(out).not.toContain("Project directory: "); + expect(out).not.toContain("Web application"); + expect(out).not.toContain("API server"); + expect(out).not.toContain("# API server"); + expect(out).not.toContain("# Web app"); + expect(out).toContain( + "Sign in from the web app to confirm the session resolves.", + ); + }); +}); diff --git a/src/core/packageManager.test.ts b/src/core/packageManager.test.ts new file mode 100644 index 0000000..f457028 --- /dev/null +++ b/src/core/packageManager.test.ts @@ -0,0 +1,37 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { detectPackageManager } from "./packageManager.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-pm-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("detectPackageManager", () => { + it("detects pnpm from pnpm-lock.yaml", () => { + fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), ""); + expect(detectPackageManager(tmpDir)).toBe("pnpm"); + }); + + it("detects yarn from yarn.lock", () => { + fs.writeFileSync(path.join(tmpDir, "yarn.lock"), ""); + expect(detectPackageManager(tmpDir)).toBe("yarn"); + }); + + it("prefers pnpm when both pnpm-lock.yaml and yarn.lock are present", () => { + fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), ""); + fs.writeFileSync(path.join(tmpDir, "yarn.lock"), ""); + expect(detectPackageManager(tmpDir)).toBe("pnpm"); + }); + + it("defaults to npm when no lockfile is present", () => { + expect(detectPackageManager(tmpDir)).toBe("npm"); + }); +}); diff --git a/src/core/paths.test.ts b/src/core/paths.test.ts new file mode 100644 index 0000000..6a03060 --- /dev/null +++ b/src/core/paths.test.ts @@ -0,0 +1,13 @@ +import path from "path"; +import { describe, expect, it } from "vitest"; +import { PROJECT_ROOT, TEMPLATE_ROOT } from "./paths.js"; + +describe("paths", () => { + it("resolves PROJECT_ROOT as an absolute directory", () => { + expect(path.isAbsolute(PROJECT_ROOT)).toBe(true); + }); + + it("derives TEMPLATE_ROOT from PROJECT_ROOT", () => { + expect(TEMPLATE_ROOT).toBe(path.join(PROJECT_ROOT, "templates")); + }); +}); diff --git a/src/core/portal.test.ts b/src/core/portal.test.ts index 62d8962..3bb049b 100644 --- a/src/core/portal.test.ts +++ b/src/core/portal.test.ts @@ -163,6 +163,18 @@ describe("rotateServiceToken", () => { ); }); + it("treats a 401 or 403 as an authorization failure", async () => { + const forbidden = fakeClient([response(403, null)]); + await expect( + rotateServiceToken(forbidden.client, "app-1"), + ).rejects.toBeInstanceOf(PortalError); + + const unauth = fakeClient([response(401, null)]); + await expect( + rotateServiceToken(unauth.client, "app-1"), + ).rejects.toBeInstanceOf(PortalError); + }); + it("fails when the response omits a token", async () => { const { client } = fakeClient([response(200, { message: "ok" })]); await expect(rotateServiceToken(client, "app-1")).rejects.toBeInstanceOf( diff --git a/src/core/secrets.test.ts b/src/core/secrets.test.ts new file mode 100644 index 0000000..a0ccb27 --- /dev/null +++ b/src/core/secrets.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { generateKid, generateSecret } from "./secrets.js"; + +describe("generateSecret", () => { + it("generates a 32-byte hex string by default (64 hex chars)", () => { + const secret = generateSecret(); + expect(secret).toMatch(/^[0-9a-f]{64}$/); + }); + + it("honors a custom byte length", () => { + const secret = generateSecret(8); + expect(secret).toMatch(/^[0-9a-f]{16}$/); + }); + + it("generates distinct values across calls", () => { + expect(generateSecret()).not.toBe(generateSecret()); + }); +}); + +describe("generateKid", () => { + it("prefixes with dev- and appends a 6-byte hex suffix", () => { + const kid = generateKid(); + expect(kid).toMatch(/^dev-[0-9a-f]{12}$/); + }); + + it("generates distinct values across calls", () => { + expect(generateKid()).not.toBe(generateKid()); + }); +}); diff --git a/src/core/systemConfig.test.ts b/src/core/systemConfig.test.ts index 86bb2ea..cbaab77 100644 --- a/src/core/systemConfig.test.ts +++ b/src/core/systemConfig.test.ts @@ -95,12 +95,34 @@ describe("patchSystemConfig", () => { ); }); + it("surfaces a 400 without details as a bare reason", async () => { + const { client } = fakeClient(() => response(400, { error: "Invalid payload" })); + await expect(patchSystemConfig(client, { rpid: "" })).rejects.toThrow( + "Invalid payload.", + ); + }); + it("maps 403 to a PermissionError", async () => { const { client } = fakeClient(() => response(403, { error: "Forbidden" })); await expect( patchSystemConfig(client, { app_name: "x" }), ).rejects.toBeInstanceOf(PermissionError); }); + + it("throws a ConfigApiError on other failures", async () => { + const { client } = fakeClient(() => response(500, { error: "boom" })); + await expect( + patchSystemConfig(client, { app_name: "x" }), + ).rejects.toBeInstanceOf(ConfigApiError); + }); + + it("defaults updatedKeys to an empty array when the response omits it", async () => { + const { client } = fakeClient(() => response(200, { success: true })); + expect(await patchSystemConfig(client, { app_name: "x" })).toEqual({ + success: true, + updatedKeys: [], + }); + }); }); describe("getRoles", () => { @@ -111,6 +133,21 @@ describe("getRoles", () => { }); expect(await getRoles(client)).toEqual(["admin", "user"]); }); + + it("returns an empty array when roles is missing", async () => { + const { client } = fakeClient(() => response(200, {})); + expect(await getRoles(client)).toEqual([]); + }); + + it("maps 403 to a PermissionError", async () => { + const { client } = fakeClient(() => response(403, { error: "Forbidden" })); + await expect(getRoles(client)).rejects.toBeInstanceOf(PermissionError); + }); + + it("throws a ConfigApiError on other failures", async () => { + const { client } = fakeClient(() => response(500, null)); + await expect(getRoles(client)).rejects.toBeInstanceOf(ConfigApiError); + }); }); describe("parseValue", () => { @@ -146,6 +183,8 @@ describe("deepEqual and diffConfig", () => { ); expect(deepEqual([1, 2], [2, 1])).toBe(false); expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false); + expect(deepEqual([1, 2], { a: 1 })).toBe(false); }); it("reports only changed and added keys from the local file", () => { diff --git a/src/core/templates.test.ts b/src/core/templates.test.ts new file mode 100644 index 0000000..682a57d --- /dev/null +++ b/src/core/templates.test.ts @@ -0,0 +1,470 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import AdmZip from "adm-zip"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// templates.ts imports VERSION from ../index.js, and index.ts runs main() at +// import time. Mock it so importing templates.ts never dispatches the CLI. +vi.mock("../index.js", () => ({ VERSION: "0.0.0-test" })); + +import { SEAMLESS_TEMPLATES_REF, SEAMLESS_TEMPLATES_REPO } from "./images.js"; +import { + applyTemplateEnv, + assertCliSupports, + openTemplateSource, + type RegistryEntry, + type ScaffoldContext, + type TemplateManifest, +} from "./templates.js"; + +function mkTmpDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function rmDir(dir: string) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// Routes fetch calls by a substring of the request URL, so a test can answer the +// registry.json request differently from the archive .zip request. +function mockFetchByUrl( + routes: Record< + string, + () => { + ok: boolean; + status?: number; + text?: () => Promise | string; + arrayBuffer?: () => Promise | Buffer; + } + >, +) { + const fn = vi.fn(async (url: string) => { + const key = Object.keys(routes).find((k) => url.includes(k)); + if (!key) throw new Error(`no mocked route for ${url}`); + return routes[key](); + }); + vi.stubGlobal("fetch", fn); + return fn; +} + +// Builds a zip buffer whose entries mimic a GitHub codeload archive: everything +// nested under one top-level directory. +function buildZip(entries: Record): Buffer { + const zip = new AdmZip(); + for (const [name, content] of Object.entries(entries)) { + zip.addFile(name, Buffer.from(content, "utf-8")); + } + return zip.toBuffer(); +} + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.SEAMLESS_TEMPLATES_DIR; + delete process.env.SEAMLESS_TEMPLATES_REF; +}); + +describe("openTemplateSource (local)", () => { + let dir: string; + + beforeEach(() => { + dir = mkTmpDir("seamless-templates-local-"); + process.env.SEAMLESS_TEMPLATES_DIR = dir; + }); + + afterEach(() => { + rmDir(dir); + }); + + it("throws when registry.json is missing", async () => { + await expect(openTemplateSource()).rejects.toThrow( + /no registry\.json was found/, + ); + }); + + it("throws when the registry has no templates array", async () => { + fs.writeFileSync(path.join(dir, "registry.json"), JSON.stringify({ foo: 1 })); + await expect(openTemplateSource()).rejects.toThrow(/malformed/); + }); + + it("throws when templates is not an array", async () => { + fs.writeFileSync( + path.join(dir, "registry.json"), + JSON.stringify({ schemaVersion: 1, templates: "nope" }), + ); + await expect(openTemplateSource()).rejects.toThrow(/malformed/); + }); + + it("parses a valid registry and exposes it", async () => { + const registry = { + schemaVersion: 1, + templates: [ + { + id: "web-a", + kind: "web", + framework: "react", + label: "React", + status: "stable", + path: "templates/web-a", + }, + ], + }; + fs.writeFileSync(path.join(dir, "registry.json"), JSON.stringify(registry)); + + const source = await openTemplateSource(); + expect(source.registry).toEqual(registry); + }); + + it("readManifest reads template.json from the local checkout", async () => { + const registry = { + schemaVersion: 1, + templates: [ + { + id: "web-a", + kind: "web", + framework: "react", + label: "React", + status: "stable", + path: "templates/web-a", + }, + ], + }; + fs.writeFileSync(path.join(dir, "registry.json"), JSON.stringify(registry)); + + const manifest: TemplateManifest = { id: "web-a", targetDir: "web" }; + fs.mkdirSync(path.join(dir, "templates", "web-a"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "templates", "web-a", "template.json"), + JSON.stringify(manifest), + ); + + const source = await openTemplateSource(); + const read = await source.readManifest(registry.templates[0] as RegistryEntry); + expect(read).toEqual(manifest); + }); + + it("copyInto copies files recursively while skipping ignored names", async () => { + const registry = { + schemaVersion: 1, + templates: [ + { + id: "web-a", + kind: "web", + framework: "react", + label: "React", + status: "stable", + path: "templates/web-a", + }, + ], + }; + fs.writeFileSync(path.join(dir, "registry.json"), JSON.stringify(registry)); + + const templateDir = path.join(dir, "templates", "web-a"); + fs.mkdirSync(path.join(templateDir, "src", "nested"), { recursive: true }); + fs.mkdirSync(path.join(templateDir, "node_modules", "pkg"), { recursive: true }); + fs.mkdirSync(path.join(templateDir, ".git"), { recursive: true }); + fs.writeFileSync(path.join(templateDir, "template.json"), "{}"); + fs.writeFileSync(path.join(templateDir, "src", "index.ts"), "export {};"); + fs.writeFileSync(path.join(templateDir, "src", "nested", "deep.ts"), "deep"); + fs.writeFileSync(path.join(templateDir, "node_modules", "pkg", "index.js"), "ignored"); + fs.writeFileSync(path.join(templateDir, ".git", "HEAD"), "ignored"); + fs.writeFileSync(path.join(templateDir, ".DS_Store"), "ignored"); + + const source = await openTemplateSource(); + const destDir = mkTmpDir("seamless-templates-dest-"); + try { + await source.copyInto(registry.templates[0] as RegistryEntry, destDir); + + expect(fs.readFileSync(path.join(destDir, "template.json"), "utf-8")).toBe("{}"); + expect(fs.readFileSync(path.join(destDir, "src", "index.ts"), "utf-8")).toBe( + "export {};", + ); + expect( + fs.readFileSync(path.join(destDir, "src", "nested", "deep.ts"), "utf-8"), + ).toBe("deep"); + expect(fs.existsSync(path.join(destDir, "node_modules"))).toBe(false); + expect(fs.existsSync(path.join(destDir, ".git"))).toBe(false); + expect(fs.existsSync(path.join(destDir, ".DS_Store"))).toBe(false); + } finally { + rmDir(destDir); + } + }); +}); + +describe("openTemplateSource (remote)", () => { + const registryPayload = { + schemaVersion: 1, + templates: [ + { + id: "web-a", + kind: "web", + framework: "react", + label: "React", + status: "stable", + path: "templates/web-a", + }, + ], + }; + + it("uses the SEAMLESS_TEMPLATES_REF override in the registry URL", async () => { + process.env.SEAMLESS_TEMPLATES_REF = "custom-ref"; + const fetchMock = mockFetchByUrl({ + "registry.json": () => ({ + ok: true, + text: () => JSON.stringify(registryPayload), + }), + }); + + await openTemplateSource(); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain(`/${SEAMLESS_TEMPLATES_REPO}/custom-ref/registry.json`); + }); + + it("falls back to the default ref when SEAMLESS_TEMPLATES_REF is unset", async () => { + const fetchMock = mockFetchByUrl({ + "registry.json": () => ({ + ok: true, + text: () => JSON.stringify(registryPayload), + }), + }); + + await openTemplateSource(); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain(`/${SEAMLESS_TEMPLATES_REPO}/${SEAMLESS_TEMPLATES_REF}/registry.json`); + }); + + it("throws with the status code when the registry fetch fails", async () => { + mockFetchByUrl({ + "registry.json": () => ({ ok: false, status: 404 }), + }); + + await expect(openTemplateSource()).rejects.toThrow( + /Failed to fetch the template registry \(404\)/, + ); + }); + + it("throws when the fetched registry is malformed", async () => { + mockFetchByUrl({ + "registry.json": () => ({ ok: true, text: () => JSON.stringify({}) }), + }); + + await expect(openTemplateSource()).rejects.toThrow(/malformed/); + }); + + it("throws with the status code when the archive download fails", async () => { + mockFetchByUrl({ + "registry.json": () => ({ + ok: true, + text: () => JSON.stringify(registryPayload), + }), + ".zip": () => ({ ok: false, status: 503 }), + }); + + const source = await openTemplateSource(); + await expect( + source.readManifest(registryPayload.templates[0] as RegistryEntry), + ).rejects.toThrow(/Failed to download templates \(503\)/); + }); + + it("throws when the downloaded archive has no entries", async () => { + mockFetchByUrl({ + "registry.json": () => ({ + ok: true, + text: () => JSON.stringify(registryPayload), + }), + ".zip": () => ({ ok: true, arrayBuffer: () => new AdmZip().toBuffer() }), + }); + + const source = await openTemplateSource(); + await expect( + source.readManifest(registryPayload.templates[0] as RegistryEntry), + ).rejects.toThrow(/archive was empty/); + }); + + it("throws when the entry has no template.json in the archive", async () => { + const root = "seamless-templates-abc123"; + const zipBuffer = buildZip({ + [`${root}/templates/other/template.json`]: "{}", + }); + mockFetchByUrl({ + "registry.json": () => ({ + ok: true, + text: () => JSON.stringify(registryPayload), + }), + ".zip": () => ({ ok: true, arrayBuffer: () => zipBuffer }), + }); + + const source = await openTemplateSource(); + await expect( + source.readManifest(registryPayload.templates[0] as RegistryEntry), + ).rejects.toThrow(/missing template\.json in the registry/); + }); + + it("reads the manifest and copies filtered files, downloading the archive only once", async () => { + const root = "seamless-templates-abc123"; + const manifest: TemplateManifest = { id: "web-a", targetDir: "web" }; + const zipBuffer = buildZip({ + [`${root}/templates/web-a/template.json`]: JSON.stringify(manifest), + [`${root}/templates/web-a/src/App.tsx`]: "app content", + [`${root}/templates/web-a/node_modules/pkg/index.js`]: "ignored", + [`${root}/templates/web-a/.env`]: "ignored", + [`${root}/templates/other/template.json`]: "{}", + }); + const fetchMock = mockFetchByUrl({ + "registry.json": () => ({ + ok: true, + text: () => JSON.stringify(registryPayload), + }), + ".zip": () => ({ ok: true, arrayBuffer: () => zipBuffer }), + }); + + const source = await openTemplateSource(); + const entry = registryPayload.templates[0] as RegistryEntry; + + const readManifest = await source.readManifest(entry); + expect(readManifest).toEqual(manifest); + + const destDir = mkTmpDir("seamless-templates-dest-"); + try { + await source.copyInto(entry, destDir); + + expect(fs.readFileSync(path.join(destDir, "template.json"), "utf-8")).toBe( + JSON.stringify(manifest), + ); + expect(fs.readFileSync(path.join(destDir, "src", "App.tsx"), "utf-8")).toBe( + "app content", + ); + expect(fs.existsSync(path.join(destDir, "node_modules"))).toBe(false); + expect(fs.existsSync(path.join(destDir, ".env"))).toBe(false); + } finally { + rmDir(destDir); + } + + // Registry fetch + one archive fetch, shared across readManifest and copyInto. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("assertCliSupports", () => { + it("does nothing when requires is absent", () => { + expect(() => assertCliSupports({ id: "x", targetDir: "." }, "X")).not.toThrow(); + }); + + it("does nothing when cliMin is satisfied by the running CLI version", () => { + expect(() => + assertCliSupports({ id: "x", targetDir: ".", requires: { cliMin: "0.0.0" } }, "X"), + ).not.toThrow(); + }); + + it("throws a clear error when cliMin exceeds the running CLI version", () => { + expect(() => + assertCliSupports({ id: "x", targetDir: ".", requires: { cliMin: "0.0.1" } }, "X"), + ).toThrow(/Template "X" requires seamless-cli >= 0\.0\.1, but this is 0\.0\.0-test/); + }); +}); + +describe("applyTemplateEnv", () => { + let destDir: string; + const ctx: ScaffoldContext = { + authServerUrl: "http://auth.local", + apiUrl: "http://api.local", + apiToken: "tok-123", + jwksKid: "kid-1", + }; + + beforeEach(() => { + destDir = mkTmpDir("seamless-apply-env-"); + }); + + afterEach(() => { + rmDir(destDir); + }); + + function readEnv(): string { + return fs.readFileSync(path.join(destDir, ".env"), "utf-8"); + } + + it("seeds values from env.fromExample when the file exists", () => { + fs.writeFileSync(path.join(destDir, ".env.example"), "FOO=bar\n"); + applyTemplateEnv(destDir, { id: "x", targetDir: ".", env: { fromExample: ".env.example" } }, ctx); + expect(readEnv()).toContain("FOO=bar"); + }); + + it("ignores a fromExample file that does not exist", () => { + applyTemplateEnv(destDir, { id: "x", targetDir: ".", env: { fromExample: ".env.example" } }, ctx); + expect(readEnv()).toBe("\n"); + }); + + it("reads an existing .env when there is no fromExample", () => { + fs.writeFileSync(path.join(destDir, ".env"), "EXIST=1\n"); + applyTemplateEnv(destDir, { id: "x", targetDir: "." }, ctx); + expect(readEnv()).toContain("EXIST=1"); + }); + + it("starts empty when there is neither fromExample nor an existing .env", () => { + applyTemplateEnv(destDir, { id: "x", targetDir: "." }, ctx); + expect(readEnv()).toBe("\n"); + }); + + it("resolves known context placeholders and leaves plain values untouched", () => { + applyTemplateEnv( + destDir, + { + id: "x", + targetDir: ".", + env: { + set: { + AUTH: "{{authServerUrl}}", + API: "{{apiUrl}}", + TOKEN: "{{apiToken}}", + KID: "{{jwksKid}}", + PLAIN: "literal-value", + }, + }, + }, + ctx, + ); + + const written = readEnv(); + expect(written).toContain("AUTH=http://auth.local"); + expect(written).toContain("API=http://api.local"); + expect(written).toContain("TOKEN=tok-123"); + expect(written).toContain("KID=kid-1"); + expect(written).toContain("PLAIN=literal-value"); + }); + + it("resolves secret:N placeholders to N bytes of hex", () => { + applyTemplateEnv( + destDir, + { id: "x", targetDir: ".", env: { set: { SECRET: "{{secret:16}}" } } }, + ctx, + ); + + const match = /SECRET=([0-9a-f]+)/.exec(readEnv()); + expect(match?.[1]).toHaveLength(32); + }); + + it("throws when a known placeholder has no value in the scaffold context", () => { + const partialCtx: ScaffoldContext = { authServerUrl: "a", apiUrl: "b" }; + expect(() => + applyTemplateEnv( + destDir, + { id: "x", targetDir: ".", env: { set: { TOKEN: "{{apiToken}}" } } }, + partialCtx, + ), + ).toThrow(/Template placeholder \{\{apiToken\}\} has no value in this configuration/); + }); + + it("throws for a placeholder token that is not recognized", () => { + expect(() => + applyTemplateEnv( + destDir, + { id: "x", targetDir: ".", env: { set: { X: "{{bogus}}" } } }, + ctx, + ), + ).toThrow(/Unknown template placeholder \{\{bogus\}\}/); + }); +}); diff --git a/src/generators/admin/admin.test.ts b/src/generators/admin/admin.test.ts new file mode 100644 index 0000000..28c68c5 --- /dev/null +++ b/src/generators/admin/admin.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +// admin.ts is an intentionally empty module: the admin service scaffolding +// currently lives in ../docker/docker.ts (adminService/adminMode), which is +// covered by docker.test.ts. There is no source in this file to exercise, so +// this only guards that the module still loads cleanly with no exports. +describe("generators/admin/admin.ts", () => { + it("has no exports", async () => { + const mod = await import("./admin.js"); + expect(Object.keys(mod)).toEqual([]); + }); +}); diff --git a/src/generators/auth/auth.test.ts b/src/generators/auth/auth.test.ts new file mode 100644 index 0000000..bcc0284 --- /dev/null +++ b/src/generators/auth/auth.test.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from "events"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { spawn } from "child_process"; +import { generateAuthServer } from "./auth.js"; +import { + POSTGRES_IMAGE, + SEAMLESS_AUTH_API_IMAGE, +} from "../../core/images.js"; +import type { CollectedOAuthProvider } from "../../core/oauthProviders.js"; +import { OAUTH_PROVIDER_CATALOG } from "../../core/oauthProviders.js"; + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})); + +const AUTH_REPO = "https://github.com/fells-code/seamless-auth-api"; + +let tmpDir: string; +let logSpy: ReturnType; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-authgen-test-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + logSpy.mockRestore(); + vi.unstubAllGlobals(); + vi.mocked(spawn).mockReset(); +}); + +function fakeChild() { + return new EventEmitter(); +} + +function googleProvider(): CollectedOAuthProvider { + const catalog = OAUTH_PROVIDER_CATALOG.find((p) => p.id === "google")!; + return { catalog, clientId: "gid", clientSecret: "gsecret" }; +} + +function stubEnvExampleFetch(content: string) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, text: async () => content })), + ); +} + +describe("generateAuthServer local mode", () => { + it("clones the auth repo and writes auth/.env from the cloned example", async () => { + const child = fakeChild(); + vi.mocked(spawn).mockImplementation(() => { + // simulate `git clone` populating the auth directory + fs.mkdirSync(path.join(tmpDir, "auth"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "auth", ".env.example"), + "SOME_KEY=placeholder\n", + ); + return child as never; + }); + + const promise = generateAuthServer({ root: tmpDir }, "local", [ + googleProvider(), + ]); + child.emit("close", 0); + const shared = await promise; + + expect(spawn).toHaveBeenCalledWith( + "git", + ["clone", AUTH_REPO, "auth"], + { stdio: "inherit", cwd: tmpDir, shell: true, env: process.env }, + ); + + expect(shared.kid).toBe("dev-main"); + expect(shared.apiToken).toMatch(/^[0-9a-f]{64}$/); + + const written = fs.readFileSync( + path.join(tmpDir, "auth", ".env"), + "utf-8", + ); + expect(written).toContain("SOME_KEY=placeholder"); + expect(written).toContain(`API_SERVICE_TOKEN=${shared.apiToken}`); + }); + + it("propagates a failure when the git clone fails", async () => { + const child = fakeChild(); + vi.mocked(spawn).mockReturnValue(child as never); + + const promise = generateAuthServer({ root: tmpDir }, "local"); + child.emit("close", 1); + + await expect(promise).rejects.toThrow("git failed"); + }); +}); + +describe("generateAuthServer docker mode", () => { + it("fetches the env example and writes a docker-compose.yml", async () => { + stubEnvExampleFetch("SOME_VAR=value\n"); + + const shared = await generateAuthServer({ root: tmpDir }, "docker"); + + expect(spawn).not.toHaveBeenCalled(); + expect(shared.kid).toBe("dev-main"); + expect(shared.apiToken).toMatch(/^[0-9a-f]{64}$/); + + const compose = fs.readFileSync( + path.join(tmpDir, "docker-compose.yml"), + "utf-8", + ); + + expect(compose).toContain(`image: ${POSTGRES_IMAGE}`); + expect(compose).toContain(`image: ${SEAMLESS_AUTH_API_IMAGE}`); + expect(compose).toContain(`"${shared.apiToken}"`); + expect(compose).toContain("volumes:\n pgdata:"); + expect(compose.endsWith("\n")).toBe(true); + }); +}); diff --git a/src/generators/config/config.test.ts b/src/generators/config/config.test.ts new file mode 100644 index 0000000..6eacf6d --- /dev/null +++ b/src/generators/config/config.test.ts @@ -0,0 +1,153 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { generateSeamlessConfig } from "./config.js"; +import { VERSION } from "../../index.js"; +import { + SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE, + SEAMLESS_AUTH_API_IMAGE, +} from "../../core/images.js"; + +let tmpDir: string; +let logSpy: ReturnType; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-config-test-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + logSpy.mockRestore(); +}); + +function readConfig(root: string) { + return JSON.parse( + fs.readFileSync(path.join(root, "seamless.config.json"), "utf-8"), + ); +} + +describe("generateSeamlessConfig", () => { + it("writes a managed config using the explicit managed details", () => { + generateSeamlessConfig(tmpDir, { + projectName: "my-app", + webFramework: "react", + apiFramework: "express", + authMode: "managed", + adminMode: "image", + managed: { + instanceUrl: "https://acme.seamlessauth.com", + applicationId: "app-1", + applicationName: "Acme", + }, + }); + + const config = readConfig(tmpDir); + + expect(config.version).toBe(VERSION); + expect(config.projectName).toBe("my-app"); + expect(config.services.auth).toEqual({ + mode: "managed", + instanceUrl: "https://acme.seamlessauth.com", + applicationId: "app-1", + applicationName: "Acme", + image: null, + path: null, + }); + expect(config.services.admin).toEqual({ + mode: "hosted", + image: null, + path: null, + }); + expect(config.services.database).toEqual({ type: "postgres" }); + expect(config.docker).toBeNull(); + expect(typeof config.createdAt).toBe("string"); + expect(new Date(config.createdAt).toString()).not.toBe("Invalid Date"); + }); + + it("defaults managed fields to null when authMode is managed but no managed details are given", () => { + generateSeamlessConfig(tmpDir, { + webFramework: "react", + apiFramework: "express", + authMode: "managed", + adminMode: "image", + }); + + const config = readConfig(tmpDir); + + expect(config.services.auth).toEqual({ + mode: "managed", + instanceUrl: null, + applicationId: null, + applicationName: null, + image: null, + path: null, + }); + // no projectName supplied, falls back to the root directory's basename + expect(config.projectName).toBe(path.basename(tmpDir)); + }); + + it("writes a docker-auth config with an image-mode admin dashboard", () => { + generateSeamlessConfig(tmpDir, { + projectName: "my-app", + webFramework: "react", + apiFramework: "express", + authMode: "docker", + adminMode: "image", + }); + + const config = readConfig(tmpDir); + + expect(config.services.auth).toEqual({ + mode: "docker", + image: SEAMLESS_AUTH_API_IMAGE, + path: null, + }); + expect(config.services.admin).toEqual({ + mode: "image", + image: SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE, + path: null, + }); + expect(config.docker).toEqual({ composeFile: "docker-compose.yml" }); + }); + + it("writes a local-auth config with a source-mode admin dashboard", () => { + generateSeamlessConfig(tmpDir, { + projectName: "my-app", + webFramework: "vue", + apiFramework: "fastify", + authMode: "local", + adminMode: "source", + }); + + const config = readConfig(tmpDir); + + expect(config.services.auth).toEqual({ + mode: "local", + image: null, + path: "./auth", + }); + expect(config.services.admin).toEqual({ + mode: "source", + image: null, + path: "./admin", + }); + expect(config.services.web).toEqual({ framework: "vue", path: "./web" }); + expect(config.services.api).toEqual({ + framework: "fastify", + path: "./api", + }); + }); + + it("logs confirmation once the config file is created", () => { + generateSeamlessConfig(tmpDir, { + webFramework: "react", + apiFramework: "express", + authMode: "local", + adminMode: "image", + }); + + expect(logSpy).toHaveBeenCalledWith("Seamless config created."); + }); +}); diff --git a/src/generators/docker/docker.test.ts b/src/generators/docker/docker.test.ts new file mode 100644 index 0000000..ac0fcdd --- /dev/null +++ b/src/generators/docker/docker.test.ts @@ -0,0 +1,267 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildAuthEnv, + buildJWKSConfig, + configureAuthLocalEnv, + envToDockerBlock, + extractSharedFromExistingEnv, + generateDockerCompose, +} from "./docker.js"; +import { + POSTGRES_IMAGE, + SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE, + SEAMLESS_AUTH_API_IMAGE, +} from "../../core/images.js"; +import type { CollectedOAuthProvider } from "../../core/oauthProviders.js"; +import { OAUTH_PROVIDER_CATALOG } from "../../core/oauthProviders.js"; + +let tmpDir: string; +let logSpy: ReturnType; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-docker-test-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + logSpy.mockRestore(); + vi.unstubAllGlobals(); +}); + +function googleProvider( + overrides: Partial = {}, +): CollectedOAuthProvider { + const catalog = OAUTH_PROVIDER_CATALOG.find((p) => p.id === "google")!; + return { catalog, clientId: "gid", clientSecret: "gsecret", ...overrides }; +} + +function writeAuthEnvFixture(root: string, kidKey?: string) { + fs.mkdirSync(path.join(root, "auth"), { recursive: true }); + const lines = ["API_SERVICE_TOKEN=existing-token"]; + if (kidKey) lines.push(`${kidKey}=existing-kid`); + fs.writeFileSync(path.join(root, "auth", ".env"), lines.join("\n") + "\n"); +} + +function stubEnvExampleFetch(content: string) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, text: async () => content })), + ); +} + +describe("envToDockerBlock", () => { + it("renders single-line values as quoted JSON strings", () => { + const block = envToDockerBlock({ FOO: "bar", BAZ: "1 2 3" }); + expect(block).toBe(' FOO: "bar"\n BAZ: "1 2 3"'); + }); + + it("renders multiline values as an indented block scalar", () => { + const block = envToDockerBlock({ KEY: "line1\nline2" }); + expect(block).toBe(" KEY: |\n line1\n line2"); + }); +}); + +describe("buildJWKSConfig", () => { + it("generates a keypair and a valid public JWKS document", () => { + const config = buildJWKSConfig(); + + expect(config.kid).toBe("main"); + expect(config.privateKey).toContain("PRIVATE KEY"); + expect(config.publicKey).toContain("PUBLIC KEY"); + + const parsed = JSON.parse(config.publicJwksJson); + expect(parsed).toEqual({ + keys: [{ kid: "main", pem: config.publicKey }], + }); + }); +}); + +describe("buildAuthEnv", () => { + it("wires docker-mode networking values", () => { + const { env, shared } = buildAuthEnv({}, "docker"); + + expect(env.ISSUER).toBe("http://auth:5312"); + expect(env.DB_HOST).toBe("db"); + expect(env.AUTH_MODE).toBe("server"); + expect(env.PORT).toBe("5312"); + expect(env.NODE_ENV).toBe("development"); + expect(env.SEAMLESS_BOOTSTRAP_ENABLED).toBe("true"); + expect(env.SEAMLESS_BOOTSTRAP_SECRET).toBe(shared.bootstrapSecret); + expect(env.API_SERVICE_TOKEN).toBe(shared.apiToken); + expect(env.REFRESH_TOKEN_LOOKUP_SECRET).toMatch(/^[0-9a-f]{64}$/); + expect(env.TOTP_SECRET_ENCRYPTION_KEY).toMatch(/^[0-9a-f]{64}$/); + expect(env.APP_ORIGINS).toBe("http://localhost:3000"); + expect(env.ORIGINS).toBe("http://localhost:5173,http://localhost:5174"); + expect(env.LOGIN_METHODS).toBeUndefined(); + expect(shared.kid).toBe("dev-main"); + }); + + it("wires local-mode networking values", () => { + const { env } = buildAuthEnv({}, "local"); + + expect(env.ISSUER).toBe("http://localhost:5312"); + expect(env.DB_HOST).toBe("localhost"); + }); + + it("wires oauth env vars and enables the oauth login method when providers are given", () => { + const { env } = buildAuthEnv({}, "docker", [googleProvider()]); + + expect(env.OAUTH_STATE_SECRET).toMatch(/^[0-9a-f]{64}$/); + const providers = JSON.parse(env.OAUTH_PROVIDERS); + expect(providers).toHaveLength(1); + expect(providers[0]).toMatchObject({ id: "google", enabled: true }); + expect(env.LOGIN_METHODS).toBe("passkey,magic_link,oauth"); + }); +}); + +describe("extractSharedFromExistingEnv", () => { + it("prefers SEAMLESS_JWKS_ACTIVE_KID", () => { + writeAuthEnvFixture(tmpDir, "SEAMLESS_JWKS_ACTIVE_KID"); + expect(extractSharedFromExistingEnv(tmpDir)).toEqual({ + apiToken: "existing-token", + kid: "existing-kid", + }); + }); + + it("falls back to JWKS_ACTIVE_KID", () => { + writeAuthEnvFixture(tmpDir, "JWKS_ACTIVE_KID"); + expect(extractSharedFromExistingEnv(tmpDir)).toEqual({ + apiToken: "existing-token", + kid: "existing-kid", + }); + }); + + it("defaults kid to dev-main when neither is present", () => { + writeAuthEnvFixture(tmpDir); + expect(extractSharedFromExistingEnv(tmpDir)).toEqual({ + apiToken: "existing-token", + kid: "dev-main", + }); + }); +}); + +describe("configureAuthLocalEnv", () => { + it("throws when auth/.env.example is missing", async () => { + fs.mkdirSync(path.join(tmpDir, "auth"), { recursive: true }); + + await expect(configureAuthLocalEnv(tmpDir)).rejects.toThrow( + ".env.example not found in auth directory", + ); + }); + + it("writes auth/.env derived from the example file and returns shared secrets", async () => { + fs.mkdirSync(path.join(tmpDir, "auth"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "auth", ".env.example"), + "SOME_KEY=placeholder\n# a comment\n", + ); + + const shared = await configureAuthLocalEnv(tmpDir, [googleProvider()]); + + expect(shared.kid).toBe("dev-main"); + expect(shared.apiToken).toMatch(/^[0-9a-f]{64}$/); + + const written = fs.readFileSync(path.join(tmpDir, "auth", ".env"), "utf-8"); + expect(written).toContain(`API_SERVICE_TOKEN=${shared.apiToken}`); + expect(written).toContain("SOME_KEY=placeholder"); + expect(written).toContain("AUTH_MODE=server"); + expect(written).toContain("ISSUER=http://localhost:5312"); + expect(written).toContain("DB_HOST=localhost"); + expect(written.endsWith("\n")).toBe(true); + }); +}); + +describe("generateDockerCompose", () => { + it("builds a local-auth compose file with an image-mode admin service", async () => { + writeAuthEnvFixture(tmpDir, "SEAMLESS_JWKS_ACTIVE_KID"); + + const shared = await generateDockerCompose(tmpDir, { + authMode: "local", + adminMode: "image", + includeAdmin: true, + }); + + expect(shared).toEqual({ apiToken: "existing-token", kid: "existing-kid" }); + + const compose = fs.readFileSync( + path.join(tmpDir, "docker-compose.yml"), + "utf-8", + ); + + expect(compose).toContain(`image: ${POSTGRES_IMAGE}`); + expect(compose).toContain("container_name: seamless-db"); + expect(compose).toContain("build:\n context: ./auth"); + expect(compose).toContain("env_file:\n - ./auth/.env"); + expect(compose).toContain("DB_HOST: db"); + expect(compose).toContain("ISSUER: http://auth:5312"); + expect(compose).toContain("API_SERVICE_TOKEN: existing-token"); + expect(compose).toContain("JWKS_KID: existing-kid"); + expect(compose).toContain("container_name: web"); + expect(compose).toContain(`image: ${SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE}`); + expect(compose).not.toContain("build: ./admin"); + expect(compose).toContain("volumes:\n pgdata:"); + expect(compose.endsWith("\n")).toBe(true); + }); + + it("omits the admin service entirely when includeAdmin is false", async () => { + writeAuthEnvFixture(tmpDir, "SEAMLESS_JWKS_ACTIVE_KID"); + + await generateDockerCompose(tmpDir, { + authMode: "local", + adminMode: "image", + includeAdmin: false, + }); + + const compose = fs.readFileSync( + path.join(tmpDir, "docker-compose.yml"), + "utf-8", + ); + expect(compose).not.toContain("container_name: admin"); + }); + + it("builds a source-mode admin service when includeAdmin is a truthy symbol", async () => { + writeAuthEnvFixture(tmpDir, "SEAMLESS_JWKS_ACTIVE_KID"); + + await generateDockerCompose(tmpDir, { + authMode: "local", + adminMode: "source", + includeAdmin: Symbol("include"), + }); + + const compose = fs.readFileSync( + path.join(tmpDir, "docker-compose.yml"), + "utf-8", + ); + expect(compose).toContain("container_name: admin"); + expect(compose).toContain("build: ./admin"); + expect(compose).toContain("AUTH_MODE: server"); + expect(compose).toContain("- ./admin:/app"); + }); + + it("builds a docker-auth compose file using the fetched env.example and oauth wiring", async () => { + stubEnvExampleFetch("SOME_VAR=value\n"); + + const shared = await generateDockerCompose(tmpDir, { + authMode: "docker", + adminMode: "image", + includeAdmin: true, + oauth: [googleProvider()], + }); + + const compose = fs.readFileSync( + path.join(tmpDir, "docker-compose.yml"), + "utf-8", + ); + + expect(compose).toContain(`image: ${SEAMLESS_AUTH_API_IMAGE}`); + expect(compose).not.toContain("build:\n context: ./auth"); + expect(compose).toContain(`API_SERVICE_TOKEN: "${shared.apiToken}"`); + expect(compose).toContain(`API_SERVICE_TOKEN: ${shared.apiToken}`); + expect(compose).toContain(`JWKS_KID: ${shared.kid}`); + expect(compose).toContain("OAUTH_PROVIDERS"); + }); +}); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..e89328b --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import pkg from "../package.json" with { type: "json" }; + +// Every command module is stubbed so importing index.ts (which runs main() at load) +// never touches real command logic. args.js stays real (extractFlag is pure). +vi.mock("./commands/init.js", () => ({ runCLI: vi.fn() })); +vi.mock("./commands/check.js", () => ({ runCheck: vi.fn() })); +vi.mock("./commands/help.js", () => ({ printHelp: vi.fn() })); +vi.mock("./commands/bootstrapAdmin.js", () => ({ runBootstrapAdmin: vi.fn() })); +vi.mock("./commands/verify.js", () => ({ runVerify: vi.fn() })); +vi.mock("./commands/profile.js", () => ({ runProfile: vi.fn() })); +vi.mock("./commands/login.js", () => ({ runLogin: vi.fn() })); +vi.mock("./commands/whoami.js", () => ({ runWhoami: vi.fn() })); +vi.mock("./commands/logout.js", () => ({ runLogout: vi.fn() })); +vi.mock("./commands/sessions.js", () => ({ runSessions: vi.fn() })); +vi.mock("./commands/config.js", () => ({ runConfig: vi.fn() })); +vi.mock("./commands/users.js", () => ({ runUsers: vi.fn() })); +vi.mock("./commands/org.js", () => ({ runOrg: vi.fn() })); + +const flush = () => new Promise((r) => setImmediate(r)); + +const ORIGINAL_ARGV = process.argv; +let exitSpy: ReturnType; +let logSpy: ReturnType; +let errSpy: ReturnType; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + process.argv = ORIGINAL_ARGV; + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); +}); + +// Sets process.argv, imports index fresh so its module-level main() runs, and +// returns index's exports once the dispatch has settled. +async function dispatch(argv: string[]): Promise> { + process.argv = ["node", "index.js", ...argv]; + const mod = await import("./index.js"); + await flush(); + return mod as unknown as Record; +} + +describe("index dispatcher", () => { + it("prints help when no command is given", async () => { + await dispatch([]); + const { printHelp } = await import("./commands/help.js"); + expect(printHelp).toHaveBeenCalledTimes(1); + }); + + it.each(["-h", "--help"])("prints help for %s", async (flag) => { + await dispatch([flag]); + const { printHelp } = await import("./commands/help.js"); + expect(printHelp).toHaveBeenCalledTimes(1); + }); + + it.each(["-v", "--version"])("prints the version for %s", async (flag) => { + await dispatch([flag]); + expect(logSpy).toHaveBeenCalledWith(pkg.version); + }); + + it("dispatches init with parsed project name, aliases, profile and app flags", async () => { + await dispatch(["init", "my-app", "--local", "--oauth", "--profile", "prod", "--app", "app1"]); + const { runCLI } = await import("./commands/init.js"); + expect(runCLI).toHaveBeenCalledWith("my-app", ["oauth"], { + profileFlag: "prod", + appId: "app1", + local: true, + }); + }); + + it("dispatches check", async () => { + await dispatch(["check"]); + const { runCheck } = await import("./commands/check.js"); + expect(runCheck).toHaveBeenCalledTimes(1); + }); + + it("dispatches bootstrap-admin with the email argument", async () => { + await dispatch(["bootstrap-admin", "admin@example.com"]); + const { runBootstrapAdmin } = await import("./commands/bootstrapAdmin.js"); + expect(runBootstrapAdmin).toHaveBeenCalledWith("admin@example.com"); + }); + + it("dispatches verify with the remaining args", async () => { + await dispatch(["verify", "--local"]); + const { runVerify } = await import("./commands/verify.js"); + expect(runVerify).toHaveBeenCalledWith(["--local"]); + }); + + it.each([ + ["profile", "./commands/profile.js", "runProfile"], + ["login", "./commands/login.js", "runLogin"], + ["whoami", "./commands/whoami.js", "runWhoami"], + ["logout", "./commands/logout.js", "runLogout"], + ["sessions", "./commands/sessions.js", "runSessions"], + ["config", "./commands/config.js", "runConfig"], + ["users", "./commands/users.js", "runUsers"], + ["org", "./commands/org.js", "runOrg"], + ])("dispatches %s with the remaining args", async (cmd, modPath, fnName) => { + await dispatch([cmd, "sub", "--flag"]); + const mod = (await import(/* @vite-ignore */ modPath)) as Record>; + expect(mod[fnName]).toHaveBeenCalledWith(["sub", "--flag"]); + }); + + it("treats an unknown command as an init template alias", async () => { + await dispatch(["frobnicate"]); + const { runCLI } = await import("./commands/init.js"); + expect(runCLI).toHaveBeenCalledWith("frobnicate"); + }); + + it("logs the error and exits 1 when a command rejects", async () => { + process.argv = ["node", "index.js", "check"]; + const { runCheck } = await import("./commands/check.js"); + vi.mocked(runCheck).mockRejectedValueOnce(new Error("kaboom")); + + await import("./index.js"); + await flush(); + await flush(); + + expect(errSpy).toHaveBeenCalledWith("Error:", "kaboom"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("exports VERSION from package.json", async () => { + const mod = await dispatch(["--version"]); + expect(mod.VERSION).toBe(pkg.version); + }); +}); diff --git a/src/prompts/appSelect.test.ts b/src/prompts/appSelect.test.ts index 78b6ff0..b9899a6 100644 --- a/src/prompts/appSelect.test.ts +++ b/src/prompts/appSelect.test.ts @@ -1,8 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { select, isCancel, cancel } from "@clack/prompts"; import type { PortalApp } from "../core/portal.js"; import { NoApplicationsError, selectApplication } from "./appSelect.js"; +vi.mock("@clack/prompts", () => ({ + select: vi.fn(), + isCancel: vi.fn(), + cancel: vi.fn(), +})); + function app(over: Partial = {}): PortalApp { return { id: "app-1", @@ -42,4 +49,47 @@ describe("selectApplication", () => { selectApplication([app(), app({ id: "app-2" })], "nope"), ).rejects.toThrow(/No managed application matches/); }); + + it("prompts to choose among multiple applications and returns the match", async () => { + const a = app({ id: "app-1", name: "Acme" }); + const b = app({ id: "app-2", name: "Beta" }); + vi.mocked(select).mockResolvedValue("app-2" as never); + vi.mocked(isCancel).mockReturnValue(false); + + const result = await selectApplication([a, b]); + + expect(select).toHaveBeenCalledWith({ + message: "Which managed application should this project connect to?", + options: [ + { value: "app-1", label: "Acme", hint: a.domain }, + { value: "app-2", label: "Beta", hint: b.domain }, + ], + }); + expect(result).toBe(b); + expect(cancel).not.toHaveBeenCalled(); + }); + + it("returns null and cancels when the interactive prompt is cancelled", async () => { + const a = app({ id: "app-1" }); + const b = app({ id: "app-2" }); + const cancelSymbol = Symbol("cancel"); + vi.mocked(select).mockResolvedValue(cancelSymbol as never); + vi.mocked(isCancel).mockReturnValue(true); + + const result = await selectApplication([a, b]); + + expect(cancel).toHaveBeenCalledWith("Cancelled."); + expect(result).toBeNull(); + }); + + it("returns null when the selected value no longer matches any application", async () => { + const a = app({ id: "app-1" }); + const b = app({ id: "app-2" }); + vi.mocked(select).mockResolvedValue("app-3" as never); + vi.mocked(isCancel).mockReturnValue(false); + + const result = await selectApplication([a, b]); + + expect(result).toBeNull(); + }); }); diff --git a/src/prompts/oauthSetup.test.ts b/src/prompts/oauthSetup.test.ts new file mode 100644 index 0000000..f1fa2b0 --- /dev/null +++ b/src/prompts/oauthSetup.test.ts @@ -0,0 +1,97 @@ +import { multiselect, password, text } from "@clack/prompts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OAUTH_PROVIDER_CATALOG } from "../core/oauthProviders.js"; +import { runOAuthSetupPrompts } from "./oauthSetup.js"; + +vi.mock("@clack/prompts", () => ({ + multiselect: vi.fn(), + text: vi.fn(), + password: vi.fn(), +})); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("runOAuthSetupPrompts", () => { + it("passes the full catalog as multiselect options", async () => { + vi.mocked(multiselect).mockResolvedValue([] as never); + + await runOAuthSetupPrompts(); + + expect(multiselect).toHaveBeenCalledWith( + expect.objectContaining({ + options: OAUTH_PROVIDER_CATALOG.map((p) => ({ value: p.id, label: p.label })), + required: false, + }), + ); + }); + + it("returns an empty array when nothing is chosen", async () => { + vi.mocked(multiselect).mockResolvedValue([] as never); + + const result = await runOAuthSetupPrompts(); + + expect(result).toEqual([]); + expect(text).not.toHaveBeenCalled(); + }); + + it("returns an empty array when the prompt is cancelled (non-array result)", async () => { + vi.mocked(multiselect).mockResolvedValue(Symbol("cancel") as never); + + const result = await runOAuthSetupPrompts(); + + expect(result).toEqual([]); + expect(text).not.toHaveBeenCalled(); + }); + + it("collects trimmed credentials for each chosen provider", async () => { + vi.mocked(multiselect).mockResolvedValue(["google", "github"] as never); + + const clientIds = new Map([ + ["Google client ID", " google-id "], + ["GitHub client ID", undefined], + ]); + vi.mocked(text).mockImplementation(async (args: unknown) => { + const a = args as { message: string }; + return clientIds.get(a.message); + }); + + const clientSecrets = new Map([ + ["Google client secret", " google-secret "], + ["GitHub client secret", " "], + ]); + vi.mocked(password).mockImplementation(async (args: unknown) => { + const a = args as { message: string }; + return clientSecrets.get(a.message); + }); + + const result = await runOAuthSetupPrompts(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + catalog: OAUTH_PROVIDER_CATALOG.find((p) => p.id === "google"), + clientId: "google-id", + clientSecret: "google-secret", + }); + expect(result[1]).toEqual({ + catalog: OAUTH_PROVIDER_CATALOG.find((p) => p.id === "github"), + clientId: "", + clientSecret: "", + }); + }); + + it("skips an id chosen that is not in the catalog", async () => { + vi.mocked(multiselect).mockResolvedValue(["google", "bogus"] as never); + vi.mocked(text).mockResolvedValue("id" as never); + vi.mocked(password).mockResolvedValue("secret" as never); + + const result = await runOAuthSetupPrompts(); + + expect(result).toHaveLength(1); + expect(result[0].catalog.id).toBe("google"); + expect(text).toHaveBeenCalledTimes(1); + expect(password).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/prompts/projectSetup.test.ts b/src/prompts/projectSetup.test.ts new file mode 100644 index 0000000..661e339 --- /dev/null +++ b/src/prompts/projectSetup.test.ts @@ -0,0 +1,246 @@ +import { confirm, select } from "@clack/prompts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RegistryEntry } from "../core/templates.js"; +import { + runManagedTemplatePrompts, + runProjectSetupPrompts, +} from "./projectSetup.js"; + +vi.mock("@clack/prompts", () => ({ + select: vi.fn(), + confirm: vi.fn(), +})); + +interface SelectArgs { + message: string; + options: Array<{ value: string; label: string; disabled?: boolean }>; +} + +// Answers select() by matching the prompt message, and records every call's +// options so tests can assert on label/disabled derivation. +function mockSelect(responses: Record): SelectArgs[] { + const calls: SelectArgs[] = []; + vi.mocked(select).mockImplementation(async (args: unknown) => { + const a = args as SelectArgs; + calls.push(a); + if (!(a.message in responses)) { + throw new Error(`unexpected select prompt: ${a.message}`); + } + return responses[a.message]; + }); + return calls; +} + +function mockConfirm(responses: Record) { + vi.mocked(confirm).mockImplementation(async (args: unknown) => { + const a = args as { message: string }; + if (!(a.message in responses)) { + throw new Error(`unexpected confirm prompt: ${a.message}`); + } + return responses[a.message]; + }); +} + +function entry(over: Partial = {}): RegistryEntry { + return { + id: "web-a", + kind: "web", + framework: "react", + label: "React", + status: "stable", + path: "web-a", + ...over, + }; +} + +function fullRegistry(): RegistryEntry[] { + return [ + entry({ id: "web-a", kind: "web", label: "React", status: "stable", path: "web-a" }), + entry({ id: "web-b", kind: "web", label: "Vue", status: "beta", path: "web-b" }), + entry({ id: "web-c", kind: "web", label: "Svelte", status: "coming-soon", path: "web-c" }), + entry({ id: "api-a", kind: "api", label: "Express", status: "stable", path: "api-a" }), + ]; +} + +let logs: string[]; + +beforeEach(() => { + logs = []; + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + logs.push(String(msg ?? "")); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function out(): string { + return logs.join("\n"); +} + +describe("runManagedTemplatePrompts", () => { + it("throws when the registry has no templates of the requested kind", async () => { + const onlyApi = fullRegistry().filter((t) => t.kind === "api"); + await expect(runManagedTemplatePrompts(onlyApi)).rejects.toThrow( + /no web templates/, + ); + }); + + it("throws when there is no api template, after web is resolved", async () => { + const onlyWeb = fullRegistry().filter((t) => t.kind === "web"); + await expect( + runManagedTemplatePrompts(onlyWeb, { webTemplateId: "web-a" }), + ).rejects.toThrow(/no api templates/); + }); + + it("prompts for both web and api when nothing is preselected", async () => { + const calls = mockSelect({ + "Web example": "web-b", + "Backend framework": "api-a", + }); + + const result = await runManagedTemplatePrompts(fullRegistry()); + + expect(result).toEqual({ webTemplateId: "web-b", apiTemplateId: "api-a" }); + + const webCall = calls.find((c) => c.message === "Web example")!; + expect(webCall.options).toEqual([ + { value: "web-a", label: "React", disabled: false }, + { value: "web-b", label: "Vue (beta)", disabled: false }, + { value: "web-c", label: "Svelte (coming soon)", disabled: true }, + ]); + }); + + it("skips both prompts and logs the preselected labels", async () => { + const result = await runManagedTemplatePrompts(fullRegistry(), { + webTemplateId: "web-a", + apiTemplateId: "api-a", + }); + + expect(result).toEqual({ webTemplateId: "web-a", apiTemplateId: "api-a" }); + expect(select).not.toHaveBeenCalled(); + expect(out()).toContain("Web example: React"); + expect(out()).toContain("Backend: Express"); + }); + + it("falls back to the raw id when a preselected id is not in the registry", async () => { + await runManagedTemplatePrompts(fullRegistry(), { + webTemplateId: "unknown-id", + apiTemplateId: "api-a", + }); + + expect(out()).toContain("Web example: unknown-id"); + }); +}); + +describe("runProjectSetupPrompts", () => { + it("runs the full docker + admin-image flow with no preselection", async () => { + mockSelect({ + "Web example": "web-a", + "Backend framework": "api-a", + "How would you like to run SeamlessAuth?": "docker", + "Admin dashboard source": "image", + }); + mockConfirm({ "Include Admin Dashboard?": true }); + + const result = await runProjectSetupPrompts(fullRegistry()); + + expect(result).toEqual({ + web: true, + webTemplateId: "web-a", + api: true, + apiTemplateId: "api-a", + authMode: "docker", + useDocker: true, + includeAdmin: true, + adminMode: "image", + }); + }); + + it("uses preselected template ids and logs them instead of prompting", async () => { + mockSelect({ + "How would you like to run SeamlessAuth?": "docker", + }); + mockConfirm({ "Include Admin Dashboard?": false }); + + const result = await runProjectSetupPrompts(fullRegistry(), { + webTemplateId: "web-b", + apiTemplateId: "api-a", + }); + + expect(result.webTemplateId).toBe("web-b"); + expect(result.apiTemplateId).toBe("api-a"); + expect(out()).toContain("Web example: Vue"); + expect(out()).toContain("Backend: Express"); + }); + + it("skips the admin-source prompt and keeps the image default when admin is declined", async () => { + mockSelect({ + "Web example": "web-a", + "Backend framework": "api-a", + "How would you like to run SeamlessAuth?": "docker", + }); + mockConfirm({ "Include Admin Dashboard?": false }); + + const result = await runProjectSetupPrompts(fullRegistry()); + + expect(result.includeAdmin).toBe(false); + expect(result.adminMode).toBe("image"); + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Admin dashboard source" }), + ); + }); + + it("selects the source admin mode when chosen", async () => { + mockSelect({ + "Web example": "web-a", + "Backend framework": "api-a", + "How would you like to run SeamlessAuth?": "docker", + "Admin dashboard source": "source", + }); + mockConfirm({ "Include Admin Dashboard?": true }); + + const result = await runProjectSetupPrompts(fullRegistry()); + + expect(result.adminMode).toBe("source"); + }); + + it("confirms docker is required when local auth mode is chosen and accepted", async () => { + mockSelect({ + "Web example": "web-a", + "Backend framework": "api-a", + "How would you like to run SeamlessAuth?": "local", + "Admin dashboard source": "image", + }); + mockConfirm({ + "Include Admin Dashboard?": true, + "Auth server still requires Docker for full stack. Enable Docker?": true, + }); + + const result = await runProjectSetupPrompts(fullRegistry()); + + expect(result.authMode).toBe("local"); + expect(result.useDocker).toBe(true); + expect(out()).not.toContain("Enabling automatically"); + }); + + it("logs the auto-enable notice when the local docker confirmation is declined", async () => { + mockSelect({ + "Web example": "web-a", + "Backend framework": "api-a", + "How would you like to run SeamlessAuth?": "local", + "Admin dashboard source": "image", + }); + mockConfirm({ + "Include Admin Dashboard?": true, + "Auth server still requires Docker for full stack. Enable Docker?": false, + }); + + const result = await runProjectSetupPrompts(fullRegistry()); + + expect(result.useDocker).toBe(true); + expect(out()).toContain("Enabling automatically"); + }); +}); diff --git a/src/utils/writeEnv.test.ts b/src/utils/writeEnv.test.ts new file mode 100644 index 0000000..50ebc89 --- /dev/null +++ b/src/utils/writeEnv.test.ts @@ -0,0 +1,35 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { writeEnv } from "./writeEnv.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-writeenv-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("writeEnv", () => { + it("writes a .env file with key=value pairs and a trailing newline", () => { + writeEnv(tmpDir, { FOO: "bar", COUNT: 3 }); + + const content = fs.readFileSync(path.join(tmpDir, ".env"), "utf-8"); + expect(content).toBe("FOO=bar\nCOUNT=3\n"); + }); + + it("writes just a trailing newline for an empty values object", () => { + writeEnv(tmpDir, {}); + expect(fs.readFileSync(path.join(tmpDir, ".env"), "utf-8")).toBe("\n"); + }); + + it("overwrites an existing .env file", () => { + writeEnv(tmpDir, { FOO: "1" }); + writeEnv(tmpDir, { FOO: "2" }); + expect(fs.readFileSync(path.join(tmpDir, ".env"), "utf-8")).toBe("FOO=2\n"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index bf60a9e..903da1a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,15 @@ export default defineConfig({ reportsDirectory: "./coverage", include: ["src/**/*.ts"], exclude: ["src/**/*.d.ts", "src/**/*.test.ts"], + // Regression floor, set just below the current numbers (lines/statements + // ~99.8%, functions ~99.6%, branches ~96.9%). The remaining gap is a + // small set of unreachable branches; keep new code at or above these. + thresholds: { + lines: 99, + statements: 99, + functions: 99, + branches: 95, + }, }, }, });