Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame405 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";
c4e643cClaude18import { INSTALL_SCRIPT_SRC, SELF_HOST_SCRIPT_SRC } from "../routes/install";
46d6165Claude19
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
c4e643cClaude68// ---------------------------------------------------------------------------
69// 1a. GET /install-server — self-host single-binary installer
70// ---------------------------------------------------------------------------
71
72describe("install — GET /install-server", () => {
73 it("returns 200 with the self-host bash script body", async () => {
74 const res = await app.request("/install-server");
75 expect(res.status).toBe(200);
76 const body = await res.text();
77 expect(body.length).toBeGreaterThan(0);
78 expect(body.startsWith("#!/usr/bin/env bash")).toBe(true);
79 });
80
81 it("serves Content-Type: text/x-shellscript", async () => {
82 const res = await app.request("/install-server");
83 const ct = res.headers.get("content-type") || "";
84 expect(ct).toContain("text/x-shellscript");
85 });
86
87 it("serves a public Cache-Control so a CDN can absorb load", async () => {
88 const res = await app.request("/install-server");
89 const cc = res.headers.get("cache-control") || "";
90 expect(cc).toContain("public");
91 expect(cc).toContain("max-age=300");
92 });
93
94 it("script body contains the key self-host install markers", async () => {
95 const res = await app.request("/install-server");
96 const body = await res.text();
97 // Sanity-check the real script's contract.
98 if (SELF_HOST_SCRIPT_SRC.includes("set -Eeuo pipefail")) {
99 expect(body).toContain("set -Eeuo pipefail");
100 // Platform detection.
101 expect(body).toContain("uname -s");
102 // Binary download path matches the /dist/:filename route.
103 expect(body).toContain("/dist/SHA256SUMS");
104 expect(body).toContain("gluecron-server-");
105 // Sha-256 verification gate is present.
106 expect(body).toContain("SHA-256 mismatch");
107 // Service installation.
108 expect(body).toContain("systemd");
109 }
110 });
111
112 it("rewrites the default HOST to the request origin", async () => {
113 const res = await app.request("/install-server", {
114 headers: {
115 "x-forwarded-proto": "https",
116 "x-forwarded-host": "self-host.example",
117 },
118 });
119 const body = await res.text();
120 if (SELF_HOST_SCRIPT_SRC.includes("https://gluecron.com")) {
121 // The rewrite leaves an explicit fallback to the inbound origin.
122 expect(body).toContain("https://self-host.example");
123 }
124 });
125
126 it("SELF_HOST_SCRIPT_SRC exports a non-empty bash script", () => {
127 expect(SELF_HOST_SCRIPT_SRC.length).toBeGreaterThan(0);
128 expect(SELF_HOST_SCRIPT_SRC.startsWith("#!")).toBe(true);
129 });
130});
131
132// ---------------------------------------------------------------------------
133// 1c. GET /dist/:filename — binary release endpoint
134// ---------------------------------------------------------------------------
135
136describe("install — GET /dist/:filename", () => {
137 it("404s for filenames that don't exist", async () => {
138 const res = await app.request("/dist/definitely-not-a-real-binary");
139 expect(res.status).toBe(404);
140 });
141
142 it("rejects path traversal attempts", async () => {
143 // The route param regex blocks `..` outright — Hono URI-decodes %2F
144 // back to a slash so the param literally contains the traversal.
145 const res = await app.request("/dist/..%2Fpackage.json");
146 expect(res.status).toBe(404);
147 const body = await res.json();
148 expect(typeof body.error).toBe("string");
149 });
150});
151
63fe719Claude152// ---------------------------------------------------------------------------
153// 1b. GET /install/vscode — VS Code extension landing
154// ---------------------------------------------------------------------------
155
156describe("install — GET /install/vscode", () => {
157 it("returns 200 with an HTML install page", async () => {
158 const res = await app.request("/install/vscode");
159 expect(res.status).toBe(200);
160 const ct = res.headers.get("content-type") || "";
161 expect(ct).toContain("text/html");
162 const body = await res.text();
163 expect(body).toContain("Gluecron for VS Code");
164 // Points users at the extension source.
165 expect(body).toContain("editor-extensions/vscode");
166 });
167
168 it("serves a cacheable response", async () => {
169 const res = await app.request("/install/vscode");
170 const cc = res.headers.get("cache-control") || "";
171 expect(cc).toContain("public");
172 });
173});
174
1ab8aceClaude175// ---------------------------------------------------------------------------
176// 1c. GET /install/jetbrains — JetBrains plugin landing
177// ---------------------------------------------------------------------------
178
179describe("install — GET /install/jetbrains", () => {
180 it("returns 200 with an HTML install page", async () => {
181 const res = await app.request("/install/jetbrains");
182 expect(res.status).toBe(200);
183 const ct = res.headers.get("content-type") || "";
184 expect(ct).toContain("text/html");
185 const body = await res.text();
186 expect(body).toContain("Gluecron for JetBrains");
187 // Points users at the plugin source + names the JetBrains IDEs we cover.
188 expect(body).toContain("editor-extensions/jetbrains");
189 expect(body).toContain("IntelliJ");
190 expect(body).toContain("WebStorm");
191 });
192
193 it("serves a cacheable response", async () => {
194 const res = await app.request("/install/jetbrains");
195 const cc = res.headers.get("cache-control") || "";
196 expect(cc).toContain("public");
197 });
198});
199
46d6165Claude200// ---------------------------------------------------------------------------
201// 2. POST /api/v2/auth/install-token — auth contract
202// ---------------------------------------------------------------------------
203
204describe("install-token — auth contract", () => {
205 it("rejects Bearer tokens outright with 401 JSON", async () => {
206 // Unknown / unresolvable bearer — the apiAuth middleware short-circuits
207 // before our handler runs, but the contract for the caller is the same:
208 // 401 JSON with an `error` string. The whole point is that a Bearer
209 // caller *never* gets a 200 + a fresh PAT.
210 const res = await app.request("/api/v2/auth/install-token", {
211 method: "POST",
212 headers: {
213 "content-type": "application/json",
214 authorization: "Bearer glc_" + "a".repeat(64),
215 },
216 body: JSON.stringify({ name: "abuse", scope: "admin" }),
217 });
218 expect(res.status).toBe(401);
219 const body = await res.json();
220 expect(typeof body.error).toBe("string");
221 expect(body.error.length).toBeGreaterThan(0);
222 });
223
224 it("rejects malformed Bearer (no token) the same way", async () => {
225 const res = await app.request("/api/v2/auth/install-token", {
226 method: "POST",
227 headers: {
228 "content-type": "application/json",
229 authorization: "Bearer ",
230 },
231 body: JSON.stringify({}),
232 });
233 // Either the apiAuth middleware fails the bearer lookup (401) or we
234 // hit our own bearer-reject branch (401). Either is acceptable; the
235 // important invariant is "never 200".
236 expect(res.status).toBe(401);
237 });
238
239 it("rejects unauthenticated callers with 401 JSON", async () => {
240 const res = await app.request("/api/v2/auth/install-token", {
241 method: "POST",
242 headers: { "content-type": "application/json" },
243 body: JSON.stringify({}),
244 });
245 expect(res.status).toBe(401);
246 const body = await res.json();
247 expect(typeof body.error).toBe("string");
248 expect(JSON.stringify(body).toLowerCase()).toContain("session");
249 });
250
251 it("rejects an empty body without a session", async () => {
252 const res = await app.request("/api/v2/auth/install-token", {
253 method: "POST",
254 });
255 expect(res.status).toBe(401);
256 });
257});
258
259// ---------------------------------------------------------------------------
260// 3. POST /api/v2/auth/install-token — successful mint (DB-backed)
261// ---------------------------------------------------------------------------
262
263describe("install-token — successful mint", () => {
264 it.skipIf(!HAS_DB)(
265 "mints a glc_ PAT + writes auth.install_token.created audit row",
266 async () => {
267 const { db } = await import("../db");
268 const { users, sessions, apiTokens, auditLog } = await import(
269 "../db/schema"
270 );
271 const { eq, and, desc } = await import("drizzle-orm");
272
273 // Set up a one-off user + session purely for this test.
274 const uname = "install-test-" + Math.random().toString(36).slice(2, 10);
275 const [user] = await db
276 .insert(users)
277 .values({
278 username: uname,
279 email: `${uname}@example.com`,
280 passwordHash: "x",
281 })
282 .returning();
283
284 const sessionToken =
285 "sess_test_" + Math.random().toString(36).slice(2) + Date.now();
286 const expiresAt = new Date(Date.now() + 60_000);
287 await db.insert(sessions).values({
288 userId: user.id,
289 token: sessionToken,
290 expiresAt,
291 });
292
293 try {
294 const res = await app.request("/api/v2/auth/install-token", {
295 method: "POST",
296 headers: {
297 "content-type": "application/json",
298 cookie: `session=${sessionToken}`,
299 },
300 body: JSON.stringify({ name: "ci-install", scope: "admin" }),
301 });
302
303 expect(res.status).toBe(201);
304 const body = await res.json();
305 expect(typeof body.token).toBe("string");
306 expect(body.token.startsWith("glc_")).toBe(true);
307 expect(body.token.length).toBe("glc_".length + 64);
308 expect(body.name).toBe("ci-install");
309 expect(body.scope).toBe("admin");
310 expect(body.id).toBeDefined();
311
312 // PAT row exists with the right prefix + scopes.
313 const [row] = await db
314 .select()
315 .from(apiTokens)
316 .where(eq(apiTokens.id, body.id))
317 .limit(1);
318 expect(row).toBeDefined();
319 expect(row!.userId).toBe(user.id);
320 expect(row!.name).toBe("ci-install");
321 expect(row!.scopes).toContain("admin");
322 expect(row!.tokenPrefix).toBe(body.token.slice(0, 12));
323
324 // Audit row written under the expected action name.
325 const [audit] = await db
326 .select()
327 .from(auditLog)
328 .where(
329 and(
330 eq(auditLog.userId, user.id),
331 eq(auditLog.action, "auth.install_token.created")
332 )
333 )
334 .orderBy(desc(auditLog.createdAt))
335 .limit(1);
336 expect(audit).toBeDefined();
337 expect(audit!.targetType).toBe("api_token");
338 expect(audit!.targetId).toBe(body.id);
339 } finally {
340 // Best-effort cleanup. Cascade on users covers sessions + tokens.
341 try {
342 await db.delete(users).where(eq(users.id, user.id));
343 } catch {
344 /* ignore */
345 }
346 }
347 }
348 );
349
350 it.skipIf(!HAS_DB)(
351 "defaults name + scope when body is empty",
352 async () => {
353 const { db } = await import("../db");
354 const { users, sessions, apiTokens } = await import("../db/schema");
355 const { eq } = await import("drizzle-orm");
356
357 const uname = "install-test2-" + Math.random().toString(36).slice(2, 10);
358 const [user] = await db
359 .insert(users)
360 .values({
361 username: uname,
362 email: `${uname}@example.com`,
363 passwordHash: "x",
364 })
365 .returning();
366
367 const sessionToken =
368 "sess_test_" + Math.random().toString(36).slice(2) + Date.now();
369 await db.insert(sessions).values({
370 userId: user.id,
371 token: sessionToken,
372 expiresAt: new Date(Date.now() + 60_000),
373 });
374
375 try {
376 const res = await app.request("/api/v2/auth/install-token", {
377 method: "POST",
378 headers: {
379 "content-type": "application/json",
380 cookie: `session=${sessionToken}`,
381 },
382 body: "",
383 });
384 expect(res.status).toBe(201);
385 const body = await res.json();
386 expect(body.scope).toBe("admin");
387 expect(typeof body.name).toBe("string");
388 expect(body.name.startsWith("gluecron-install-")).toBe(true);
389
390 const [row] = await db
391 .select()
392 .from(apiTokens)
393 .where(eq(apiTokens.id, body.id))
394 .limit(1);
395 expect(row).toBeDefined();
396 } finally {
397 try {
398 await db.delete(users).where(eq(users.id, user.id));
399 } catch {
400 /* ignore */
401 }
402 }
403 }
404 );
405});