Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

install.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

install.test.tsBlame296 lines · 1 contributor
46d6165Claude1/**
2 * Block L2 — one-command install.
3 *
4 * Coverage:
5 * - GET /install returns 200 + the bash script + correct headers
6 * - POST /api/v2/auth/install-token refuses Bearer-token callers
7 * - POST /api/v2/auth/install-token refuses unauthenticated callers
8 * - POST /api/v2/auth/install-token mints a PAT + writes an audit row when
9 * called over a real session cookie (DB-backed)
10 *
11 * The DB-backed test is gated on `DATABASE_URL` so the suite still runs in
12 * environments without Postgres — matching the convention used elsewhere
13 * (see `api-tokens.test.ts`).
14 */
15
16import { describe, it, expect } from "bun:test";
17import app from "../app";
18import { INSTALL_SCRIPT_SRC } from "../routes/install";
19
20const HAS_DB = Boolean(process.env.DATABASE_URL);
21
22// ---------------------------------------------------------------------------
23// 1. GET /install — the curl-able installer
24// ---------------------------------------------------------------------------
25
26describe("install — GET /install", () => {
27 it("returns 200 with the bash script body", async () => {
28 const res = await app.request("/install");
29 expect(res.status).toBe(200);
30 const body = await res.text();
31 expect(body.length).toBeGreaterThan(0);
32 expect(body.startsWith("#!")).toBe(true);
33 });
34
35 it("serves Content-Type: text/x-shellscript", async () => {
36 const res = await app.request("/install");
37 const ct = res.headers.get("content-type") || "";
38 expect(ct).toContain("text/x-shellscript");
39 });
40
41 it("serves a public Cache-Control so a CDN can absorb load", async () => {
42 const res = await app.request("/install");
43 const cc = res.headers.get("cache-control") || "";
44 expect(cc).toContain("public");
45 expect(cc).toContain("max-age=300");
46 });
47
48 it("script body contains the key install-flow markers", async () => {
49 const res = await app.request("/install");
50 const body = await res.text();
51 // Sanity-check the actual script (or fallback) loaded into memory.
52 expect(body).toContain("#!/usr/bin/env bash");
53 if (INSTALL_SCRIPT_SRC.includes("set -euo pipefail")) {
54 // Real script path — assert the user-facing flow it promises.
55 expect(body).toContain("set -euo pipefail");
56 expect(body).toContain("/api/v2/auth/install-token");
57 expect(body).toContain("claude_desktop_config.json");
58 expect(body).toContain("mcpServers");
59 }
60 });
61
62 it("INSTALL_SCRIPT_SRC exports a non-empty bash script", () => {
63 expect(INSTALL_SCRIPT_SRC.length).toBeGreaterThan(0);
64 expect(INSTALL_SCRIPT_SRC.startsWith("#!")).toBe(true);
65 });
66});
67
63fe719Claude68// ---------------------------------------------------------------------------
69// 1b. GET /install/vscode — VS Code extension landing
70// ---------------------------------------------------------------------------
71
72describe("install — GET /install/vscode", () => {
73 it("returns 200 with an HTML install page", async () => {
74 const res = await app.request("/install/vscode");
75 expect(res.status).toBe(200);
76 const ct = res.headers.get("content-type") || "";
77 expect(ct).toContain("text/html");
78 const body = await res.text();
79 expect(body).toContain("Gluecron for VS Code");
80 // Points users at the extension source.
81 expect(body).toContain("editor-extensions/vscode");
82 });
83
84 it("serves a cacheable response", async () => {
85 const res = await app.request("/install/vscode");
86 const cc = res.headers.get("cache-control") || "";
87 expect(cc).toContain("public");
88 });
89});
90
46d6165Claude91// ---------------------------------------------------------------------------
92// 2. POST /api/v2/auth/install-token — auth contract
93// ---------------------------------------------------------------------------
94
95describe("install-token — auth contract", () => {
96 it("rejects Bearer tokens outright with 401 JSON", async () => {
97 // Unknown / unresolvable bearer — the apiAuth middleware short-circuits
98 // before our handler runs, but the contract for the caller is the same:
99 // 401 JSON with an `error` string. The whole point is that a Bearer
100 // caller *never* gets a 200 + a fresh PAT.
101 const res = await app.request("/api/v2/auth/install-token", {
102 method: "POST",
103 headers: {
104 "content-type": "application/json",
105 authorization: "Bearer glc_" + "a".repeat(64),
106 },
107 body: JSON.stringify({ name: "abuse", scope: "admin" }),
108 });
109 expect(res.status).toBe(401);
110 const body = await res.json();
111 expect(typeof body.error).toBe("string");
112 expect(body.error.length).toBeGreaterThan(0);
113 });
114
115 it("rejects malformed Bearer (no token) the same way", async () => {
116 const res = await app.request("/api/v2/auth/install-token", {
117 method: "POST",
118 headers: {
119 "content-type": "application/json",
120 authorization: "Bearer ",
121 },
122 body: JSON.stringify({}),
123 });
124 // Either the apiAuth middleware fails the bearer lookup (401) or we
125 // hit our own bearer-reject branch (401). Either is acceptable; the
126 // important invariant is "never 200".
127 expect(res.status).toBe(401);
128 });
129
130 it("rejects unauthenticated callers with 401 JSON", async () => {
131 const res = await app.request("/api/v2/auth/install-token", {
132 method: "POST",
133 headers: { "content-type": "application/json" },
134 body: JSON.stringify({}),
135 });
136 expect(res.status).toBe(401);
137 const body = await res.json();
138 expect(typeof body.error).toBe("string");
139 expect(JSON.stringify(body).toLowerCase()).toContain("session");
140 });
141
142 it("rejects an empty body without a session", async () => {
143 const res = await app.request("/api/v2/auth/install-token", {
144 method: "POST",
145 });
146 expect(res.status).toBe(401);
147 });
148});
149
150// ---------------------------------------------------------------------------
151// 3. POST /api/v2/auth/install-token — successful mint (DB-backed)
152// ---------------------------------------------------------------------------
153
154describe("install-token — successful mint", () => {
155 it.skipIf(!HAS_DB)(
156 "mints a glc_ PAT + writes auth.install_token.created audit row",
157 async () => {
158 const { db } = await import("../db");
159 const { users, sessions, apiTokens, auditLog } = await import(
160 "../db/schema"
161 );
162 const { eq, and, desc } = await import("drizzle-orm");
163
164 // Set up a one-off user + session purely for this test.
165 const uname = "install-test-" + Math.random().toString(36).slice(2, 10);
166 const [user] = await db
167 .insert(users)
168 .values({
169 username: uname,
170 email: `${uname}@example.com`,
171 passwordHash: "x",
172 })
173 .returning();
174
175 const sessionToken =
176 "sess_test_" + Math.random().toString(36).slice(2) + Date.now();
177 const expiresAt = new Date(Date.now() + 60_000);
178 await db.insert(sessions).values({
179 userId: user.id,
180 token: sessionToken,
181 expiresAt,
182 });
183
184 try {
185 const res = await app.request("/api/v2/auth/install-token", {
186 method: "POST",
187 headers: {
188 "content-type": "application/json",
189 cookie: `session=${sessionToken}`,
190 },
191 body: JSON.stringify({ name: "ci-install", scope: "admin" }),
192 });
193
194 expect(res.status).toBe(201);
195 const body = await res.json();
196 expect(typeof body.token).toBe("string");
197 expect(body.token.startsWith("glc_")).toBe(true);
198 expect(body.token.length).toBe("glc_".length + 64);
199 expect(body.name).toBe("ci-install");
200 expect(body.scope).toBe("admin");
201 expect(body.id).toBeDefined();
202
203 // PAT row exists with the right prefix + scopes.
204 const [row] = await db
205 .select()
206 .from(apiTokens)
207 .where(eq(apiTokens.id, body.id))
208 .limit(1);
209 expect(row).toBeDefined();
210 expect(row!.userId).toBe(user.id);
211 expect(row!.name).toBe("ci-install");
212 expect(row!.scopes).toContain("admin");
213 expect(row!.tokenPrefix).toBe(body.token.slice(0, 12));
214
215 // Audit row written under the expected action name.
216 const [audit] = await db
217 .select()
218 .from(auditLog)
219 .where(
220 and(
221 eq(auditLog.userId, user.id),
222 eq(auditLog.action, "auth.install_token.created")
223 )
224 )
225 .orderBy(desc(auditLog.createdAt))
226 .limit(1);
227 expect(audit).toBeDefined();
228 expect(audit!.targetType).toBe("api_token");
229 expect(audit!.targetId).toBe(body.id);
230 } finally {
231 // Best-effort cleanup. Cascade on users covers sessions + tokens.
232 try {
233 await db.delete(users).where(eq(users.id, user.id));
234 } catch {
235 /* ignore */
236 }
237 }
238 }
239 );
240
241 it.skipIf(!HAS_DB)(
242 "defaults name + scope when body is empty",
243 async () => {
244 const { db } = await import("../db");
245 const { users, sessions, apiTokens } = await import("../db/schema");
246 const { eq } = await import("drizzle-orm");
247
248 const uname = "install-test2-" + Math.random().toString(36).slice(2, 10);
249 const [user] = await db
250 .insert(users)
251 .values({
252 username: uname,
253 email: `${uname}@example.com`,
254 passwordHash: "x",
255 })
256 .returning();
257
258 const sessionToken =
259 "sess_test_" + Math.random().toString(36).slice(2) + Date.now();
260 await db.insert(sessions).values({
261 userId: user.id,
262 token: sessionToken,
263 expiresAt: new Date(Date.now() + 60_000),
264 });
265
266 try {
267 const res = await app.request("/api/v2/auth/install-token", {
268 method: "POST",
269 headers: {
270 "content-type": "application/json",
271 cookie: `session=${sessionToken}`,
272 },
273 body: "",
274 });
275 expect(res.status).toBe(201);
276 const body = await res.json();
277 expect(body.scope).toBe("admin");
278 expect(typeof body.name).toBe("string");
279 expect(body.name.startsWith("gluecron-install-")).toBe(true);
280
281 const [row] = await db
282 .select()
283 .from(apiTokens)
284 .where(eq(apiTokens.id, body.id))
285 .limit(1);
286 expect(row).toBeDefined();
287 } finally {
288 try {
289 await db.delete(users).where(eq(users.id, user.id));
290 } catch {
291 /* ignore */
292 }
293 }
294 }
295 );
296});