| 1 | import { mkdirSync, statSync, writeFileSync, readFileSync } from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 4 | import { configPath, normalizeUrl, resolveConfig, writeConfig } from "../src/config.js"; |
| 5 | import type { Io } from "../src/io.js"; |
| 6 | import { fakeServer, isolatedEnv, run, type FakeServer } from "./helpers.js"; |
| 7 | |
| 8 | const ioWith = (env: NodeJS.ProcessEnv) => ({ env }) as unknown as Io; |
| 9 | |
| 10 | describe("config", () => { |
| 11 | it("defaults to https://diffinery.com with no token", async () => { |
| 12 | const cfg = await resolveConfig(ioWith(isolatedEnv())); |
| 13 | expect(cfg).toMatchObject({ url: "https://diffinery.com", token: null, urlSource: "default", tokenSource: null }); |
| 14 | }); |
| 15 | |
| 16 | it("reads the file under XDG_CONFIG_HOME and lets env vars override it", async () => { |
| 17 | const env = isolatedEnv(); |
| 18 | const file = path.join(env.XDG_CONFIG_HOME!, "diffinery", "config.json"); |
| 19 | expect(configPath(env)).toBe(file); |
| 20 | mkdirSync(path.dirname(file), { recursive: true }); |
| 21 | writeFileSync(file, JSON.stringify({ url: "https://forge.example.com/", token: "dfy_file" })); |
| 22 | |
| 23 | expect(await resolveConfig(ioWith(env))).toMatchObject({ url: "https://forge.example.com", token: "dfy_file", urlSource: "config", tokenSource: "config" }); |
| 24 | expect(await resolveConfig(ioWith({ ...env, DIFFINERY_TOKEN: "dfy_env" }))).toMatchObject({ url: "https://forge.example.com", token: "dfy_env", tokenSource: "env" }); |
| 25 | expect(await resolveConfig(ioWith({ ...env, DIFFINERY_URL: "other.example.com" }))).toMatchObject({ url: "https://other.example.com", token: "dfy_file", urlSource: "env" }); |
| 26 | }); |
| 27 | |
| 28 | it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { |
| 29 | const env = isolatedEnv(); |
| 30 | delete env.XDG_CONFIG_HOME; |
| 31 | expect(configPath(env)).toBe(path.join(env.HOME!, ".config", "diffinery", "config.json")); |
| 32 | }); |
| 33 | |
| 34 | it("writes the config with mode 0600", async () => { |
| 35 | const env = isolatedEnv(); |
| 36 | const file = await writeConfig(env, { url: "https://a.example", token: "dfy_x" }); |
| 37 | expect(statSync(file).mode & 0o777).toBe(0o600); |
| 38 | expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_x" }); |
| 39 | }); |
| 40 | |
| 41 | it("normalizes URLs", () => { |
| 42 | expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com"); |
| 43 | expect(normalizeUrl("http://localhost:8787")).toBe("http://localhost:8787"); |
| 44 | expect(() => normalizeUrl("ftp://x")).toThrow(); |
| 45 | }); |
| 46 | }); |
| 47 | |
| 48 | describe("login / whoami / logout", () => { |
| 49 | let srv: FakeServer; |
| 50 | beforeEach(async () => { |
| 51 | srv = await fakeServer(); |
| 52 | srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", name: "Alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); |
| 53 | }); |
| 54 | afterEach(() => srv.close()); |
| 55 | |
| 56 | it("verifies the token, saves it 0600, and whoami uses it", async () => { |
| 57 | const env = isolatedEnv(); |
| 58 | const r = await run(["login", "--url", srv.url, "--token", "dfy_good"], { env }); |
| 59 | expect(r.code).toBe(0); |
| 60 | expect(r.stdout).toContain("Logged in to"); |
| 61 | expect(r.stdout).toContain("alice"); |
| 62 | expect(r.stdout).not.toContain("dfy_good"); |
| 63 | const file = configPath(env); |
| 64 | expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url, token: "dfy_good" }); |
| 65 | expect(statSync(file).mode & 0o777).toBe(0o600); |
| 66 | |
| 67 | const who = await run(["whoami"], { env }); |
| 68 | expect(who.code).toBe(0); |
| 69 | expect(who.stdout).toContain("alice"); |
| 70 | expect(srv.find("GET", "/api/v3/user").at(-1)!.headers.authorization).toBe("Bearer dfy_good"); |
| 71 | |
| 72 | const out = await run(["logout"], { env }); |
| 73 | expect(out.code).toBe(0); |
| 74 | expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url }); |
| 75 | expect((await run(["whoami"], { env })).code).toBe(1); |
| 76 | }); |
| 77 | |
| 78 | it("reads the token from stdin when it is not a TTY", async () => { |
| 79 | const env = isolatedEnv(); |
| 80 | const r = await run(["login", "--url", srv.url], { env, stdin: "dfy_good\n" }); |
| 81 | expect(r.code).toBe(0); |
| 82 | expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good"); |
| 83 | }); |
| 84 | |
| 85 | it("refuses a token the server rejects and saves nothing", async () => { |
| 86 | const env = isolatedEnv(); |
| 87 | const r = await run(["login", "--url", srv.url, "--token", "dfy_bad"], { env }); |
| 88 | expect(r.code).toBe(1); |
| 89 | expect(r.stderr).toContain("bad credentials"); |
| 90 | expect(() => readFileSync(configPath(env))).toThrow(); |
| 91 | }); |
| 92 | |
| 93 | it("DIFFINERY_TOKEN/DIFFINERY_URL override the saved config", async () => { |
| 94 | const env = isolatedEnv(); |
| 95 | await writeConfig(env, { url: "http://127.0.0.1:1", token: "dfy_saved" }); |
| 96 | const r = await run(["whoami", "--json"], { env: { ...env, DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" } }); |
| 97 | expect(r.code).toBe(0); |
| 98 | expect(JSON.parse(r.stdout).login).toBe("alice"); |
| 99 | }); |
| 100 | |
| 101 | it("mcp-config prints a snippet without the token unless asked", async () => { |
| 102 | const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" }); |
| 103 | const r = await run(["mcp-config"], { env }); |
| 104 | const cfg = JSON.parse(r.stdout); |
| 105 | expect(cfg.mcpServers.diffinery).toEqual({ type: "http", url: `${srv.url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } }); |
| 106 | const shown = JSON.parse((await run(["mcp-config", "--show-token"], { env })).stdout); |
| 107 | expect(shown.mcpServers.diffinery.headers.Authorization).toBe("Bearer dfy_good"); |
| 108 | }); |
| 109 | }); |