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

connect-claude.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.

connect-claude.test.tsBlame225 lines · 1 contributor
662ce86Claude1/**
2 * Connect Claude — user-facing one-click MCP setup page.
3 *
4 * Coverage:
5 * 1. GET /connect/claude without auth → 302 to /login
6 * 2. GET /connect/claude/dxt without auth → 302 to /login
7 * 3. GET /connect/claude with a real session cookie → 200 + body markers
8 * 4. GET /settings/claude → 302 redirect (alias path)
9 * 5. POST /connect/claude/token requires auth
10 * 6. GET /api/connect/status requires auth + returns JSON shape
11 *
12 * The DB-backed tests are gated on `DATABASE_URL` to keep the suite green in
13 * environments without Postgres — matching `install.test.ts` and friends.
14 */
15
16import { describe, it, expect } from "bun:test";
17import app from "../app";
18
19const HAS_DB = Boolean(process.env.DATABASE_URL);
20
21// ---------------------------------------------------------------------------
22// 1. Auth gating — unauthenticated callers
23// ---------------------------------------------------------------------------
24
25describe("connect-claude — auth gating", () => {
26 it("GET /connect/claude without a session → 302 /login", async () => {
27 const res = await app.request("/connect/claude");
28 expect(res.status).toBe(302);
29 expect(res.headers.get("location") || "").toContain("/login");
30 });
31
32 it("GET /connect/claude/dxt without a session → 302 /login", async () => {
33 const res = await app.request("/connect/claude/dxt");
34 expect(res.status).toBe(302);
35 expect(res.headers.get("location") || "").toContain("/login");
36 });
37
38 it("GET /settings/claude without a session → 302 (alias or /login)", async () => {
39 const res = await app.request("/settings/claude");
40 // Either the requireAuth middleware redirects to /login OR the alias
41 // redirects to /connect/claude (which itself requires auth). Both are
42 // 302 — what matters is "never 200 anon".
43 expect(res.status).toBe(302);
44 const loc = res.headers.get("location") || "";
45 expect(loc).toMatch(/\/login|\/connect\/claude/);
46 });
47
48 it("POST /connect/claude/token without a session → 302 /login", async () => {
49 const res = await app.request("/connect/claude/token", {
50 method: "POST",
51 headers: { "content-type": "application/json" },
52 body: "{}",
53 });
54 expect(res.status).toBe(302);
55 expect(res.headers.get("location") || "").toContain("/login");
56 });
57
58 it("GET /api/connect/status without a session → 302 /login", async () => {
59 const res = await app.request("/api/connect/status");
60 expect(res.status).toBe(302);
61 expect(res.headers.get("location") || "").toContain("/login");
62 });
63});
64
65// ---------------------------------------------------------------------------
66// 2. DB-backed: a real session cookie reaches the rendered page.
67// ---------------------------------------------------------------------------
68
69describe("connect-claude — rendered surface (authed)", () => {
70 it.skipIf(!HAS_DB)(
71 "GET /connect/claude with a session → 200 + page markers",
72 async () => {
73 const { db } = await import("../db");
74 const { users, sessions } = await import("../db/schema");
75 const { eq } = await import("drizzle-orm");
76
77 const uname = "cc-test-" + Math.random().toString(36).slice(2, 10);
78 const [user] = await db
79 .insert(users)
80 .values({
81 username: uname,
82 email: `${uname}@example.com`,
83 passwordHash: "x",
84 })
85 .returning();
86 const sessionToken =
87 "sess_cc_" + Math.random().toString(36).slice(2) + Date.now();
88 await db.insert(sessions).values({
89 userId: user.id,
90 token: sessionToken,
91 expiresAt: new Date(Date.now() + 60_000),
92 });
93
94 try {
95 const res = await app.request("/connect/claude", {
96 headers: { cookie: `session=${sessionToken}` },
97 });
98 expect(res.status).toBe(200);
99 const body = await res.text();
100 // Hero copy + the MCP endpoint must appear (the JSON snippet and CLI
101 // command both reference it).
102 expect(body).toContain("Connect Claude");
103 expect(body).toContain("/mcp");
104 // The personalized .dxt download link is rendered.
105 expect(body).toContain("/connect/claude/dxt");
106 // The user's username is rendered in the hero eyebrow.
107 expect(body).toContain(uname);
108 // Tools list — at least one known tool name is on the page.
109 expect(body).toContain("gluecron_repo_search");
110 } finally {
111 try {
112 await db.delete(users).where(eq(users.id, user.id));
113 } catch {
114 /* ignore */
115 }
116 }
117 }
118 );
119
120 it.skipIf(!HAS_DB)(
121 "POST /connect/claude/token with a session mints a glc_ PAT",
122 async () => {
123 const { db } = await import("../db");
124 const { users, sessions, apiTokens } = await import("../db/schema");
125 const { eq } = await import("drizzle-orm");
126
127 const uname = "cc-mint-" + Math.random().toString(36).slice(2, 10);
128 const [user] = await db
129 .insert(users)
130 .values({
131 username: uname,
132 email: `${uname}@example.com`,
133 passwordHash: "x",
134 })
135 .returning();
136 const sessionToken =
137 "sess_cc_" + Math.random().toString(36).slice(2) + Date.now();
138 await db.insert(sessions).values({
139 userId: user.id,
140 token: sessionToken,
141 expiresAt: new Date(Date.now() + 60_000),
142 });
143
144 try {
145 const res = await app.request("/connect/claude/token", {
146 method: "POST",
147 headers: {
148 cookie: `session=${sessionToken}`,
149 "content-type": "application/json",
150 },
151 body: "{}",
152 });
153 expect(res.status).toBe(201);
154 const body = await res.json();
155 expect(typeof body.token).toBe("string");
156 expect(body.token.startsWith("glc_")).toBe(true);
157 // Row exists with admin scope.
158 const [row] = await db
159 .select()
160 .from(apiTokens)
161 .where(eq(apiTokens.id, body.id))
162 .limit(1);
163 expect(row).toBeDefined();
164 expect(row!.userId).toBe(user.id);
165 expect(row!.scopes).toContain("admin");
166 } finally {
167 try {
168 await db.delete(users).where(eq(users.id, user.id));
169 } catch {
170 /* ignore */
171 }
172 }
173 }
174 );
175
176 it.skipIf(!HAS_DB)(
177 "GET /connect/claude/dxt with a session returns a zip with the token embedded",
178 async () => {
179 const { db } = await import("../db");
180 const { users, sessions } = await import("../db/schema");
181 const { eq } = await import("drizzle-orm");
182
183 const uname = "cc-dxt-" + Math.random().toString(36).slice(2, 10);
184 const [user] = await db
185 .insert(users)
186 .values({
187 username: uname,
188 email: `${uname}@example.com`,
189 passwordHash: "x",
190 })
191 .returning();
192 const sessionToken =
193 "sess_cc_" + Math.random().toString(36).slice(2) + Date.now();
194 await db.insert(sessions).values({
195 userId: user.id,
196 token: sessionToken,
197 expiresAt: new Date(Date.now() + 60_000),
198 });
199
200 try {
201 const res = await app.request("/connect/claude/dxt", {
202 headers: { cookie: `session=${sessionToken}` },
203 });
204 expect(res.status).toBe(200);
205 expect(res.headers.get("content-disposition") || "").toContain(
206 `gluecron-${uname}.dxt`
207 );
208 const buf = new Uint8Array(await res.arrayBuffer());
209 // Zip magic — PK\x03\x04
210 expect(buf[0]).toBe(0x50);
211 expect(buf[1]).toBe(0x4b);
212 expect(buf[2]).toBe(0x03);
213 expect(buf[3]).toBe(0x04);
214 // Must NOT cache (every download is a fresh PAT mint).
215 expect(res.headers.get("cache-control") || "").toContain("no-store");
216 } finally {
217 try {
218 await db.delete(users).where(eq(users.id, user.id));
219 } catch {
220 /* ignore */
221 }
222 }
223 }
224 );
225});