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.tsBlame380 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
46d6165Claude175// ---------------------------------------------------------------------------
176// 2. POST /api/v2/auth/install-token — auth contract
177// ---------------------------------------------------------------------------
178
179describe("install-token — auth contract", () => {
180 it("rejects Bearer tokens outright with 401 JSON", async () => {
181 // Unknown / unresolvable bearer — the apiAuth middleware short-circuits
182 // before our handler runs, but the contract for the caller is the same:
183 // 401 JSON with an `error` string. The whole point is that a Bearer
184 // caller *never* gets a 200 + a fresh PAT.
185 const res = await app.request("/api/v2/auth/install-token", {
186 method: "POST",
187 headers: {
188 "content-type": "application/json",
189 authorization: "Bearer glc_" + "a".repeat(64),
190 },
191 body: JSON.stringify({ name: "abuse", scope: "admin" }),
192 });
193 expect(res.status).toBe(401);
194 const body = await res.json();
195 expect(typeof body.error).toBe("string");
196 expect(body.error.length).toBeGreaterThan(0);
197 });
198
199 it("rejects malformed Bearer (no token) the same way", async () => {
200 const res = await app.request("/api/v2/auth/install-token", {
201 method: "POST",
202 headers: {
203 "content-type": "application/json",
204 authorization: "Bearer ",
205 },
206 body: JSON.stringify({}),
207 });
208 // Either the apiAuth middleware fails the bearer lookup (401) or we
209 // hit our own bearer-reject branch (401). Either is acceptable; the
210 // important invariant is "never 200".
211 expect(res.status).toBe(401);
212 });
213
214 it("rejects unauthenticated callers with 401 JSON", async () => {
215 const res = await app.request("/api/v2/auth/install-token", {
216 method: "POST",
217 headers: { "content-type": "application/json" },
218 body: JSON.stringify({}),
219 });
220 expect(res.status).toBe(401);
221 const body = await res.json();
222 expect(typeof body.error).toBe("string");
223 expect(JSON.stringify(body).toLowerCase()).toContain("session");
224 });
225
226 it("rejects an empty body without a session", async () => {
227 const res = await app.request("/api/v2/auth/install-token", {
228 method: "POST",
229 });
230 expect(res.status).toBe(401);
231 });
232});
233
234// ---------------------------------------------------------------------------
235// 3. POST /api/v2/auth/install-token — successful mint (DB-backed)
236// ---------------------------------------------------------------------------
237
238describe("install-token — successful mint", () => {
239 it.skipIf(!HAS_DB)(
240 "mints a glc_ PAT + writes auth.install_token.created audit row",
241 async () => {
242 const { db } = await import("../db");
243 const { users, sessions, apiTokens, auditLog } = await import(
244 "../db/schema"
245 );
246 const { eq, and, desc } = await import("drizzle-orm");
247
248 // Set up a one-off user + session purely for this test.
249 const uname = "install-test-" + Math.random().toString(36).slice(2, 10);
250 const [user] = await db
251 .insert(users)
252 .values({
253 username: uname,
254 email: `${uname}@example.com`,
255 passwordHash: "x",
256 })
257 .returning();
258
259 const sessionToken =
260 "sess_test_" + Math.random().toString(36).slice(2) + Date.now();
261 const expiresAt = new Date(Date.now() + 60_000);
262 await db.insert(sessions).values({
263 userId: user.id,
264 token: sessionToken,
265 expiresAt,
266 });
267
268 try {
269 const res = await app.request("/api/v2/auth/install-token", {
270 method: "POST",
271 headers: {
272 "content-type": "application/json",
273 cookie: `session=${sessionToken}`,
274 },
275 body: JSON.stringify({ name: "ci-install", scope: "admin" }),
276 });
277
278 expect(res.status).toBe(201);
279 const body = await res.json();
280 expect(typeof body.token).toBe("string");
281 expect(body.token.startsWith("glc_")).toBe(true);
282 expect(body.token.length).toBe("glc_".length + 64);
283 expect(body.name).toBe("ci-install");
284 expect(body.scope).toBe("admin");
285 expect(body.id).toBeDefined();
286
287 // PAT row exists with the right prefix + scopes.
288 const [row] = await db
289 .select()
290 .from(apiTokens)
291 .where(eq(apiTokens.id, body.id))
292 .limit(1);
293 expect(row).toBeDefined();
294 expect(row!.userId).toBe(user.id);
295 expect(row!.name).toBe("ci-install");
296 expect(row!.scopes).toContain("admin");
297 expect(row!.tokenPrefix).toBe(body.token.slice(0, 12));
298
299 // Audit row written under the expected action name.
300 const [audit] = await db
301 .select()
302 .from(auditLog)
303 .where(
304 and(
305 eq(auditLog.userId, user.id),
306 eq(auditLog.action, "auth.install_token.created")
307 )
308 )
309 .orderBy(desc(auditLog.createdAt))
310 .limit(1);
311 expect(audit).toBeDefined();
312 expect(audit!.targetType).toBe("api_token");
313 expect(audit!.targetId).toBe(body.id);
314 } finally {
315 // Best-effort cleanup. Cascade on users covers sessions + tokens.
316 try {
317 await db.delete(users).where(eq(users.id, user.id));
318 } catch {
319 /* ignore */
320 }
321 }
322 }
323 );
324
325 it.skipIf(!HAS_DB)(
326 "defaults name + scope when body is empty",
327 async () => {
328 const { db } = await import("../db");
329 const { users, sessions, apiTokens } = await import("../db/schema");
330 const { eq } = await import("drizzle-orm");
331
332 const uname = "install-test2-" + Math.random().toString(36).slice(2, 10);
333 const [user] = await db
334 .insert(users)
335 .values({
336 username: uname,
337 email: `${uname}@example.com`,
338 passwordHash: "x",
339 })
340 .returning();
341
342 const sessionToken =
343 "sess_test_" + Math.random().toString(36).slice(2) + Date.now();
344 await db.insert(sessions).values({
345 userId: user.id,
346 token: sessionToken,
347 expiresAt: new Date(Date.now() + 60_000),
348 });
349
350 try {
351 const res = await app.request("/api/v2/auth/install-token", {
352 method: "POST",
353 headers: {
354 "content-type": "application/json",
355 cookie: `session=${sessionToken}`,
356 },
357 body: "",
358 });
359 expect(res.status).toBe(201);
360 const body = await res.json();
361 expect(body.scope).toBe("admin");
362 expect(typeof body.name).toBe("string");
363 expect(body.name.startsWith("gluecron-install-")).toBe(true);
364
365 const [row] = await db
366 .select()
367 .from(apiTokens)
368 .where(eq(apiTokens.id, body.id))
369 .limit(1);
370 expect(row).toBeDefined();
371 } finally {
372 try {
373 await db.delete(users).where(eq(users.id, user.id));
374 } catch {
375 /* ignore */
376 }
377 }
378 }
379 );
380});