Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

217 lines · 11.0 KBRaw History
1import { createHash } from "node:crypto";
2import { readFileSync, statSync, writeFileSync } from "node:fs";
3import path from "node:path";
4import { afterEach, beforeEach, describe, expect, it } from "vitest";
5import { configPath, writeConfig } from "../src/config.js";
6import { 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. */
9describe("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
97const url = (u: URL) => u.toString();
98
99describe("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
143describe("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
181describe("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});