| 1 | import { createHash } from "node:crypto"; |
| 2 | import { readFileSync, statSync, writeFileSync } from "node:fs"; |
| 3 | import path from "node:path"; |
| 4 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 5 | import { configPath, writeConfig } from "../src/config.js"; |
| 6 | import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; |
| 7 | |
| 8 | /** The CLI's side of signing in through the browser; the server's side is tested in apps/diffinery. */ |
| 9 | describe("diffinery login in the browser", () => { |
| 10 | let srv: FakeServer; |
| 11 | beforeEach(async () => { |
| 12 | srv = await fakeServer(); |
| 13 | srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_from_browser" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); |
| 14 | }); |
| 15 | afterEach(() => srv.close()); |
| 16 | |
| 17 | /** A browser that approves: Diffinery redirects to the CLI's callback with a code and the state it was given. */ |
| 18 | const approvingBrowser = (seen: { authorize?: URL; landing?: string }) => async (url: string) => { |
| 19 | const u = new URL(url); |
| 20 | seen.authorize = u; |
| 21 | const callback = `http://127.0.0.1:${u.searchParams.get("port")}/callback?code=the-code&state=${u.searchParams.get("state")}`; |
| 22 | // A stray request with the wrong state is ignored, and the real one still works afterwards. |
| 23 | expect((await fetch(callback.replace(/state=[^&]+/, "state=forged"), { redirect: "manual" })).status).toBe(400); |
| 24 | const res = await fetch(callback, { redirect: "manual" }); |
| 25 | seen.landing = res.headers.get("location") ?? ""; |
| 26 | return true; |
| 27 | }; |
| 28 | |
| 29 | it("opens the authorize page with a PKCE challenge, trades the code with the verifier, and saves the token", async () => { |
| 30 | let verifier = ""; |
| 31 | srv.on("POST", "/-/cli/token", (r) => { |
| 32 | const b = r.body as Record<string, string>; |
| 33 | verifier = b.code_verifier!; |
| 34 | return b.grant_type === "authorization_code" && b.code === "the-code" ? { body: { access_token: "dfy_from_browser", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } } : { status: 400, body: { error: "invalid_grant" } }; |
| 35 | }); |
| 36 | const env = isolatedEnv(); |
| 37 | const seen: { authorize?: URL; landing?: string } = {}; |
| 38 | const r = await run(["login", "--url", srv.url], { env, stdinTTY: true, openUrl: approvingBrowser(seen) }); |
| 39 | expect(r.stderr).not.toContain("error"); |
| 40 | expect(r.code).toBe(0); |
| 41 | const a = seen.authorize!; |
| 42 | expect(`${a.origin}${a.pathname}`).toBe(`${srv.url}/login/cli`); |
| 43 | expect(a.searchParams.get("code_challenge_method")).toBe("S256"); |
| 44 | expect(a.searchParams.get("scope")).toBe("write"); |
| 45 | expect(a.searchParams.get("host")).toBeTruthy(); |
| 46 | // The verifier never went to the browser; only its hash did. |
| 47 | expect(a.searchParams.get("code_challenge")).toBe(createHash("sha256").update(verifier).digest("base64url")); |
| 48 | expect(url(a)).not.toContain(verifier); |
| 49 | expect(seen.landing).toBe(`${srv.url}/login/cli/done?result=ok`); |
| 50 | expect(r.stdout).toContain("Logged in to"); |
| 51 | expect(r.stdout).toContain("alice"); |
| 52 | expect(r.stdout).toContain("with write access until"); |
| 53 | expect(r.stdout).not.toContain("dfy_from_browser"); |
| 54 | expect(r.stderr).not.toContain("dfy_from_browser"); |
| 55 | expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_from_browser" }); |
| 56 | expect(statSync(configPath(env)).mode & 0o777).toBe(0o600); |
| 57 | }); |
| 58 | |
| 59 | it("reports a cancelled sign-in and saves nothing", async () => { |
| 60 | const env = isolatedEnv(); |
| 61 | const r = await run(["login", "--url", srv.url, "--web", "--scope", "admin"], { |
| 62 | env, |
| 63 | openUrl: async (url) => { |
| 64 | const u = new URL(url); |
| 65 | expect(u.searchParams.get("scope")).toBe("admin"); |
| 66 | const res = await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?error=access_denied&state=${u.searchParams.get("state")}`, { redirect: "manual" }); |
| 67 | expect(res.headers.get("location")).toBe(`${srv.url}/login/cli/done?result=denied`); |
| 68 | return true; |
| 69 | }, |
| 70 | }); |
| 71 | expect(r.code).toBe(1); |
| 72 | expect(r.stderr).toContain("cancelled in the browser"); |
| 73 | expect(() => readFileSync(configPath(env))).toThrow(); |
| 74 | }); |
| 75 | |
| 76 | it("prints the link when no browser opens", async () => { |
| 77 | srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_from_browser", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); |
| 78 | const r = await run(["login", "--url", srv.url, "--web"], { |
| 79 | openUrl: async (url) => { |
| 80 | const u = new URL(url); |
| 81 | // The person copies the link into a browser themselves. |
| 82 | setTimeout(() => void fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" }), 20); |
| 83 | return false; |
| 84 | }, |
| 85 | }); |
| 86 | expect(r.code).toBe(0); |
| 87 | expect(r.stderr).toContain("Open this link in a browser on this computer"); |
| 88 | expect(r.stderr).toContain(`${srv.url}/login/cli?port=`); |
| 89 | }); |
| 90 | |
| 91 | it("refuses an unknown scope", async () => { |
| 92 | const r = await run(["login", "--url", srv.url, "--scope", "root"], { stdinTTY: true }); |
| 93 | expect(r.code).toBe(2); |
| 94 | }); |
| 95 | }); |
| 96 | |
| 97 | const url = (u: URL) => u.toString(); |
| 98 | |
| 99 | describe("diffinery login --device", () => { |
| 100 | let srv: FakeServer; |
| 101 | beforeEach(async () => { |
| 102 | srv = await fakeServer(); |
| 103 | srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_from_device" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); |
| 104 | srv.on("POST", "/-/cli/device", { body: { device_code: "secret-device-code", user_code: "BCDF-GHJK", verification_uri: `${"https://diffinery.example"}/login/device`, expires_in: 600, interval: 5 } }); |
| 105 | }); |
| 106 | afterEach(() => srv.close()); |
| 107 | |
| 108 | it("prints the code and polls until the person approves", async () => { |
| 109 | let polls = 0; |
| 110 | srv.on("POST", "/-/cli/token", (r) => { |
| 111 | expect(r.body).toEqual({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: "secret-device-code" }); |
| 112 | polls++; |
| 113 | if (polls === 1) return { status: 400, body: { error: "authorization_pending" } }; |
| 114 | if (polls === 2) return { status: 400, body: { error: "slow_down" } }; |
| 115 | return { body: { access_token: "dfy_from_device", scope: "read", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }; |
| 116 | }); |
| 117 | const env = isolatedEnv(); |
| 118 | const r = await run(["login", "--url", srv.url, "--device", "--scope", "read"], { env }); |
| 119 | expect(r.code).toBe(0); |
| 120 | expect(r.stderr).toContain("BCDF-GHJK"); |
| 121 | expect(r.stderr).toContain("https://diffinery.example/login/device"); |
| 122 | expect(r.stderr).not.toContain("secret-device-code"); |
| 123 | expect((srv.find("POST", "/-/cli/device")[0]!.body as { scope: string; client: { host: string } }).scope).toBe("read"); |
| 124 | expect(polls).toBe(3); |
| 125 | expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_from_device"); |
| 126 | }); |
| 127 | |
| 128 | it("is what a terminal without a display gets", async () => { |
| 129 | srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_from_device", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); |
| 130 | const r = await run(["login", "--url", srv.url], { env: isolatedEnv({ SSH_CONNECTION: "10.0.0.1 5000 10.0.0.2 22" }), stdinTTY: true }); |
| 131 | expect(r.code).toBe(0); |
| 132 | expect(r.stderr).toContain("BCDF-GHJK"); |
| 133 | }); |
| 134 | |
| 135 | it("stops when the person cancels", async () => { |
| 136 | srv.on("POST", "/-/cli/token", { status: 400, body: { error: "access_denied" } }); |
| 137 | const r = await run(["login", "--url", srv.url, "--device"]); |
| 138 | expect(r.code).toBe(1); |
| 139 | expect(r.stderr).toContain("cancelled in the browser"); |
| 140 | }); |
| 141 | }); |
| 142 | |
| 143 | describe("diffinery logout", () => { |
| 144 | let srv: FakeServer; |
| 145 | beforeEach(async () => { |
| 146 | srv = await fakeServer(); |
| 147 | }); |
| 148 | afterEach(() => srv.close()); |
| 149 | |
| 150 | it("revokes a CLI sign-in's token on the server, then forgets it", async () => { |
| 151 | srv.on("DELETE", "/-/cli/token", { body: { revoked: true } }); |
| 152 | const env = isolatedEnv(); |
| 153 | await writeConfig(env, { url: srv.url, token: "dfy_cli" }); |
| 154 | const r = await run(["logout"], { env }); |
| 155 | expect(r.code).toBe(0); |
| 156 | expect(r.stdout).toContain("revoked the CLI's token"); |
| 157 | expect(srv.find("DELETE", "/-/cli/token")[0]!.headers.authorization).toBe("Bearer dfy_cli"); |
| 158 | expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url }); |
| 159 | }); |
| 160 | |
| 161 | it("still signs out locally when the server can't revoke it", async () => { |
| 162 | srv.on("DELETE", "/-/cli/token", { status: 401, body: { message: "invalid, expired or revoked token" } }); |
| 163 | const env = isolatedEnv(); |
| 164 | await writeConfig(env, { url: srv.url, token: "dfy_old" }); |
| 165 | const r = await run(["logout"], { env }); |
| 166 | expect(r.code).toBe(0); |
| 167 | expect(r.stdout).toBe(`Logged out of ${srv.url}.\n`); |
| 168 | }); |
| 169 | |
| 170 | it("a new sign-in revokes the CLI token it replaces", async () => { |
| 171 | srv.on("DELETE", "/-/cli/token", { body: { revoked: true } }); |
| 172 | srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); |
| 173 | const env = isolatedEnv(); |
| 174 | await writeConfig(env, { url: srv.url, token: "dfy_previous" }); |
| 175 | const r = await run(["login", "--url", srv.url, "--token", "dfy_next"], { env }); |
| 176 | expect(r.code).toBe(0); |
| 177 | expect(srv.find("DELETE", "/-/cli/token").map((q) => q.headers.authorization)).toEqual(["Bearer dfy_previous"]); |
| 178 | }); |
| 179 | }); |
| 180 | |
| 181 | describe("diffinery upgrade", () => { |
| 182 | let srv: FakeServer; |
| 183 | const build = (n: number) => `#!/usr/bin/env node\n// build ${n}\n${"x".repeat(20_000)}\n`; |
| 184 | beforeEach(async () => { |
| 185 | srv = await fakeServer(); |
| 186 | }); |
| 187 | afterEach(() => srv.close()); |
| 188 | |
| 189 | it("replaces the file it runs from when the server has a new build, and says when it's current", async () => { |
| 190 | srv.on("GET", "/-/cli/diffinery.mjs", { body: build(2), headers: { "content-type": "text/javascript", "x-diffinery-cli-version": "0.2.0" } }); |
| 191 | const self = path.join(tempDir(), "diffinery.mjs"); |
| 192 | writeFileSync(self, build(1), { mode: 0o755 }); |
| 193 | const env = isolatedEnv({ DIFFINERY_URL: srv.url }); |
| 194 | const r = await run(["upgrade"], { env, selfPath: self }); |
| 195 | expect(r.stderr).toBe(""); |
| 196 | expect(r.code).toBe(0); |
| 197 | expect(r.stdout).toContain("Updated diffinery"); |
| 198 | expect(readFileSync(self, "utf8")).toBe(build(2)); |
| 199 | expect(statSync(self).mode & 0o777).toBe(0o755); |
| 200 | const again = await run(["upgrade"], { env, selfPath: self }); |
| 201 | expect(again.stdout).toContain("is up to date"); |
| 202 | }); |
| 203 | |
| 204 | it("refuses to replace a source checkout or with something that isn't the CLI", async () => { |
| 205 | srv.on("GET", "/-/cli/diffinery.mjs", { body: "<html>maintenance</html>", headers: { "content-type": "text/html" } }); |
| 206 | const env = isolatedEnv({ DIFFINERY_URL: srv.url }); |
| 207 | const src = path.join(tempDir(), "bin.ts"); |
| 208 | writeFileSync(src, "import x from 'y';\n"); |
| 209 | expect((await run(["upgrade"], { env, selfPath: src })).stderr).toContain("isn't an installed diffinery CLI"); |
| 210 | const self = path.join(tempDir(), "diffinery.mjs"); |
| 211 | writeFileSync(self, build(1)); |
| 212 | const r = await run(["upgrade"], { env, selfPath: self }); |
| 213 | expect(r.code).toBe(1); |
| 214 | expect(r.stderr).toContain("doesn't look like the CLI"); |
| 215 | expect(readFileSync(self, "utf8")).toBe(build(1)); |
| 216 | }); |
| 217 | }); |