| 1 | import { chmodSync, mkdirSync, readdirSync, 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("tightens an existing loose config directory and file, and leaves no temporary file", async () => { |
| 42 | const env = isolatedEnv(); |
| 43 | const file = configPath(env); |
| 44 | mkdirSync(path.dirname(file), { recursive: true, mode: 0o755 }); |
| 45 | chmodSync(path.dirname(file), 0o755); |
| 46 | writeFileSync(file, "{}", { mode: 0o644 }); |
| 47 | chmodSync(file, 0o644); |
| 48 | await writeConfig(env, { url: "https://a.example", token: "dfy_y" }); |
| 49 | expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700); |
| 50 | expect(statSync(file).mode & 0o777).toBe(0o600); |
| 51 | expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_y" }); |
| 52 | expect(readdirSync(path.dirname(file))).toEqual(["config.json"]); |
| 53 | }); |
| 54 | |
| 55 | it("normalizes URLs", () => { |
| 56 | expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com"); |
| 57 | expect(normalizeUrl("http://localhost:8787")).toBe("http://localhost:8787"); |
| 58 | expect(normalizeUrl("http://127.0.0.1:8787")).toBe("http://127.0.0.1:8787"); |
| 59 | expect(normalizeUrl("http://[::1]:8787")).toBe("http://[::1]:8787"); |
| 60 | expect(normalizeUrl("http://dev.localhost")).toBe("http://dev.localhost"); |
| 61 | expect(() => normalizeUrl("ftp://x")).toThrow(); |
| 62 | }); |
| 63 | |
| 64 | it("refuses plain http anywhere but this machine, since tokens go with every request", () => { |
| 65 | expect(() => normalizeUrl("http://forge.example.com")).toThrow(/unencrypted/); |
| 66 | expect(() => normalizeUrl("http://10.0.0.5:8787")).toThrow(/unencrypted/); |
| 67 | expect(() => normalizeUrl("http://localhost.example.com")).toThrow(/unencrypted/); |
| 68 | }); |
| 69 | }); |
| 70 | |
| 71 | describe("login / whoami / logout", () => { |
| 72 | let srv: FakeServer; |
| 73 | beforeEach(async () => { |
| 74 | srv = await fakeServer(); |
| 75 | 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" } })); |
| 76 | }); |
| 77 | afterEach(() => srv.close()); |
| 78 | |
| 79 | it("verifies the token, saves it 0600, and whoami uses it", async () => { |
| 80 | const env = isolatedEnv(); |
| 81 | const r = await run(["login", "--url", srv.url, "--token", "dfy_good"], { env }); |
| 82 | expect(r.code).toBe(0); |
| 83 | expect(r.stdout).toContain("Logged in to"); |
| 84 | expect(r.stdout).toContain("alice"); |
| 85 | expect(r.stdout).not.toContain("dfy_good"); |
| 86 | const file = configPath(env); |
| 87 | expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url, token: "dfy_good" }); |
| 88 | expect(statSync(file).mode & 0o777).toBe(0o600); |
| 89 | |
| 90 | const who = await run(["whoami"], { env }); |
| 91 | expect(who.code).toBe(0); |
| 92 | expect(who.stdout).toContain("alice"); |
| 93 | expect(srv.find("GET", "/api/v3/user").at(-1)!.headers.authorization).toBe("Bearer dfy_good"); |
| 94 | |
| 95 | const out = await run(["logout"], { env }); |
| 96 | expect(out.code).toBe(0); |
| 97 | expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url }); |
| 98 | expect((await run(["whoami"], { env })).code).toBe(1); |
| 99 | }); |
| 100 | |
| 101 | it("reads the token from stdin when it is not a TTY", async () => { |
| 102 | const env = isolatedEnv(); |
| 103 | const r = await run(["login", "--url", srv.url], { env, stdin: "dfy_good\n" }); |
| 104 | expect(r.code).toBe(0); |
| 105 | expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good"); |
| 106 | }); |
| 107 | |
| 108 | it("refuses a token the server rejects and saves nothing", async () => { |
| 109 | const env = isolatedEnv(); |
| 110 | const r = await run(["login", "--url", srv.url, "--token", "dfy_bad"], { env }); |
| 111 | expect(r.code).toBe(1); |
| 112 | expect(r.stderr).toContain("bad credentials"); |
| 113 | expect(() => readFileSync(configPath(env))).toThrow(); |
| 114 | }); |
| 115 | |
| 116 | it("DIFFINERY_TOKEN/DIFFINERY_URL override the saved config", async () => { |
| 117 | const env = isolatedEnv(); |
| 118 | await writeConfig(env, { url: "http://127.0.0.1:1", token: "dfy_saved" }); |
| 119 | const r = await run(["whoami", "--json"], { env: { ...env, DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" } }); |
| 120 | expect(r.code).toBe(0); |
| 121 | expect(JSON.parse(r.stdout).login).toBe("alice"); |
| 122 | }); |
| 123 | |
| 124 | it("mcp-config prints a snippet without the token unless asked", async () => { |
| 125 | const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" }); |
| 126 | const r = await run(["mcp-config"], { env }); |
| 127 | const cfg = JSON.parse(r.stdout); |
| 128 | expect(cfg.mcpServers.diffinery).toEqual({ type: "http", url: `${srv.url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } }); |
| 129 | const shown = JSON.parse((await run(["mcp-config", "--show-token"], { env })).stdout); |
| 130 | expect(shown.mcpServers.diffinery.headers.Authorization).toBe("Bearer dfy_good"); |
| 131 | }); |
| 132 | }); |