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

admin-integrations.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.

admin-integrations.test.tsBlame318 lines · 1 contributor
509c376Claude1/**
2 * /admin/integrations — DB-stored platform integration secrets.
3 *
4 * Coverage:
5 * 1. GET /admin/integrations without auth → 302 to /login
6 * 2. GET /admin/integrations with non-admin session → 403
7 * 3. getConfigValue() returns DB value when set, env fallback when not,
8 * empty string when neither is set
9 * 4. setConfigValue() upserts + writes an audit row
10 * 5. Masked values aren't written back as the real value (re-submitting
11 * the rendered form doesn't overwrite the existing secret with `••••••`)
12 * 6. maskSecret() formats secrets as `prefix_••••••XY`
13 * 7. INTEGRATION_FIELDS exposes the documented surface
14 *
15 * The DB-backed tests are gated on `DATABASE_URL` so the suite stays green
16 * on machines without Postgres (mirroring connect-claude.test.ts).
17 */
18
19import { describe, it, expect, beforeEach } from "bun:test";
20import app from "../app";
21import {
22 getConfigValue,
23 setConfigValue,
24 maskSecret,
25 isMaskedValue,
26 INTEGRATION_FIELDS,
27 __resetCache,
28} from "../lib/system-config";
29
30const HAS_DB = Boolean(process.env.DATABASE_URL);
31
32// ---------------------------------------------------------------------------
33// 1. Auth gating
34// ---------------------------------------------------------------------------
35
36describe("admin-integrations — auth gating", () => {
37 it("GET /admin/integrations without a session → 302 /login", async () => {
38 const res = await app.request("/admin/integrations");
39 expect(res.status).toBe(302);
40 expect(res.headers.get("location") || "").toContain("/login");
41 });
42
43 it("POST /admin/integrations without a session → 302 /login", async () => {
44 const res = await app.request("/admin/integrations", {
45 method: "POST",
46 body: new URLSearchParams({ ANTHROPIC_API_KEY: "sk-test" }),
47 headers: { "content-type": "application/x-www-form-urlencoded" },
48 });
49 expect(res.status).toBe(302);
50 expect(res.headers.get("location") || "").toContain("/login");
51 });
52});
53
54// ---------------------------------------------------------------------------
55// 2. Non-admin → 403 (DB-backed: needs a real user + session)
56// ---------------------------------------------------------------------------
57
58describe.skipIf(!HAS_DB)("admin-integrations — non-admin gate", () => {
59 it("GET /admin/integrations with a non-admin session → 403", async () => {
60 const { db } = await import("../db");
61 const { users, sessions } = await import("../db/schema");
62 const { randomBytes } = await import("crypto");
63
64 // Make a throwaway user — no site_admins row → non-admin by definition.
65 const username = `integ-nonadmin-${randomBytes(4).toString("hex")}`;
66 const [u] = await db
67 .insert(users)
68 .values({
69 username,
70 email: `${username}@test.local`,
71 passwordHash: "x",
72 })
73 .returning({ id: users.id });
74
75 const token = randomBytes(32).toString("hex");
76 await db.insert(sessions).values({
77 userId: u!.id,
78 token,
79 expiresAt: new Date(Date.now() + 60_000),
80 });
81
82 const res = await app.request("/admin/integrations", {
83 headers: { cookie: `session=${token}` },
84 });
85 expect(res.status).toBe(403);
86 });
87});
88
89// ---------------------------------------------------------------------------
90// 3. getConfigValue — DB / env / empty fallback ladder
91// ---------------------------------------------------------------------------
92
93describe("system-config — getConfigValue fallback ladder", () => {
94 beforeEach(() => {
95 __resetCache();
96 });
97
98 it("returns empty string when neither DB nor env is set", async () => {
99 delete process.env.__SYSCFG_NOPE__;
100 const v = await getConfigValue("__SYSCFG_NOPE__", "__SYSCFG_NOPE__");
101 expect(v).toBe("");
102 });
103
104 it("returns env fallback when DB has no row", async () => {
105 const key = "__SYSCFG_ENV_ONLY__";
106 process.env[key] = "from-env";
107 __resetCache();
108 const v = await getConfigValue(key, key);
109 expect(v).toBe("from-env");
110 delete process.env[key];
111 });
112
113 // DB-only assertion — must have Postgres available.
114 it.skipIf(!HAS_DB)("returns DB value when both DB and env are set", async () => {
115 const key = `__SYSCFG_DB_${Date.now()}__`;
116 process.env[key] = "from-env";
117 try {
118 await setConfigValue(key, "from-db", null);
119 __resetCache();
120 const v = await getConfigValue(key, key);
121 expect(v).toBe("from-db");
122 } finally {
123 const { db } = await import("../db");
124 const { systemConfig } = await import("../db/schema");
125 const { eq } = await import("drizzle-orm");
126 await db.delete(systemConfig).where(eq(systemConfig.key, key));
127 delete process.env[key];
128 }
129 });
130});
131
132// ---------------------------------------------------------------------------
133// 4. setConfigValue — upsert + audit
134// ---------------------------------------------------------------------------
135
136describe.skipIf(!HAS_DB)("system-config — setConfigValue upserts + audits", () => {
137 it("writes a system_config row and an audit_log entry", async () => {
138 const { db } = await import("../db");
139 const { systemConfig, users, auditLog } = await import("../db/schema");
140 const { audit } = await import("../lib/notify");
141 const { eq, desc } = await import("drizzle-orm");
142 const { randomBytes } = await import("crypto");
143
144 const username = `integ-setval-${randomBytes(4).toString("hex")}`;
145 const [u] = await db
146 .insert(users)
147 .values({
148 username,
149 email: `${username}@test.local`,
150 passwordHash: "x",
151 })
152 .returning({ id: users.id });
153
154 const key = `__SYSCFG_AUDIT_${Date.now()}__`;
155 try {
156 await setConfigValue(key, "secret-value", u!.id);
157
158 // Row landed
159 const [row] = await db
160 .select()
161 .from(systemConfig)
162 .where(eq(systemConfig.key, key))
163 .limit(1);
164 expect(row).toBeTruthy();
165 expect(row!.value).toBe("secret-value");
166 expect(row!.updatedByUserId).toBe(u!.id);
167
168 // Mimic what the route handler does — record the key but never the
169 // value. We assert the audit row appears with action+target+key in
170 // metadata.
171 await audit({
172 userId: u!.id,
173 action: "admin.integrations.save",
174 targetType: "system_config",
175 targetId: key,
176 metadata: { key, hadValue: false, hasValue: true },
177 });
178
179 const auditRows = await db
180 .select()
181 .from(auditLog)
182 .where(eq(auditLog.targetId, key))
183 .orderBy(desc(auditLog.createdAt))
184 .limit(1);
185 expect(auditRows.length).toBe(1);
186 expect(auditRows[0]!.action).toBe("admin.integrations.save");
187 // Critical: the value itself must NOT appear in the audit metadata.
188 const meta = auditRows[0]!.metadata
189 ? JSON.parse(auditRows[0]!.metadata)
190 : {};
191 expect(JSON.stringify(meta)).not.toContain("secret-value");
192 expect(meta.key).toBe(key);
193 } finally {
194 await db.delete(systemConfig).where(eq(systemConfig.key, key));
195 // auditLog rows + users row cleaned up cascade-style by FK in dev DBs;
196 // explicit cleanup skipped here to keep the test small.
197 }
198 });
199});
200
201// ---------------------------------------------------------------------------
202// 5. Masked value semantics — re-submit must not overwrite real secret
203// ---------------------------------------------------------------------------
204
205describe("system-config — mask helpers", () => {
206 it("maskSecret returns empty for empty/nullish", () => {
207 expect(maskSecret("")).toBe("");
208 expect(maskSecret(null)).toBe("");
209 expect(maskSecret(undefined)).toBe("");
210 });
211
212 it("maskSecret keeps a short prefix + last 2 chars for long secrets", () => {
213 const m = maskSecret("re_abc123xyz789QR");
214 expect(m.startsWith("re_")).toBe(true);
215 expect(m).toContain("••••••");
216 expect(m.endsWith("QR")).toBe(true);
217 expect(m).not.toContain("abc123");
218 });
219
220 it("isMaskedValue detects strings that look like the rendered mask", () => {
221 expect(isMaskedValue("re_••••••QR")).toBe(true);
222 expect(isMaskedValue("sk-ant-real-token-here")).toBe(false);
223 expect(isMaskedValue("")).toBe(false);
224 expect(isMaskedValue(null)).toBe(false);
225 });
226
227 // Round-trip: a value masked, then submitted back unchanged, must NOT
228 // overwrite the underlying secret when the route's POST handler runs.
229 it.skipIf(!HAS_DB)(
230 "re-submitting a masked value preserves the real secret",
231 async () => {
232 const { db } = await import("../db");
233 const { systemConfig } = await import("../db/schema");
234 const { eq } = await import("drizzle-orm");
235 const key = `__SYSCFG_MASK_${Date.now()}__`;
236
237 try {
238 // Seed the real secret.
239 await setConfigValue(key, "real-secret-xyz", null);
240 const before = await getConfigValue(key, key);
241 expect(before).toBe("real-secret-xyz");
242
243 // Render its mask, then simulate the form POST handler's gate.
244 const masked = maskSecret(before);
245 expect(isMaskedValue(masked)).toBe(true);
246
247 // If this gate were missing, the value would now be `re_••••••yz`.
248 if (!isMaskedValue(masked)) {
249 await setConfigValue(key, masked, null);
250 }
251
252 // The real secret should still be there.
253 const after = await getConfigValue(key, key);
254 expect(after).toBe("real-secret-xyz");
255 } finally {
256 await db.delete(systemConfig).where(eq(systemConfig.key, key));
257 }
258 }
259 );
260});
261
262// ---------------------------------------------------------------------------
263// 6. Surface — INTEGRATION_FIELDS exposes the documented list
264// ---------------------------------------------------------------------------
265
266describe("system-config — INTEGRATION_FIELDS surface", () => {
267 it("includes every key the spec calls out", () => {
268 const keys = INTEGRATION_FIELDS.map((f) => f.key);
269 for (const expected of [
270 "ANTHROPIC_API_KEY",
271 "RESEND_API_KEY",
272 "EMAIL_FROM",
273 "GITHUB_TOKEN",
274 "GATETEST_URL",
275 "GATETEST_API_KEY",
276 "DEPLOY_EVENT_TOKEN",
277 "CRONTECH_DEPLOY_URL",
278 "CRONTECH_HMAC_SECRET",
279 ]) {
280 expect(keys).toContain(expected);
281 }
282 });
283
284 it("does NOT expose env-only keys (chicken-and-egg / infra)", () => {
285 const keys = INTEGRATION_FIELDS.map((f) => f.key);
06a502cClaude286 // DATABASE_URL / BUILD_SHA / PORT / GIT_REPOS_PATH stay env-only —
287 // changing them at runtime would break the running process. APP_BASE_URL
288 // and SELF_HOST_REPO are intentionally surfaced so operators can fix
289 // OAuth redirect_uri_mismatch + the self-host repo lookup without SSH.
509c376Claude290 for (const forbidden of [
291 "DATABASE_URL",
292 "BUILD_SHA",
293 "BUILD_TIME",
294 "PORT",
295 "GIT_REPOS_PATH",
296 ]) {
297 expect(keys).not.toContain(forbidden);
298 }
299 });
300
301 it("tags each field with a group + helper text", () => {
302 for (const f of INTEGRATION_FIELDS) {
303 expect(typeof f.label).toBe("string");
304 expect(f.label.length).toBeGreaterThan(0);
305 expect(typeof f.helper).toBe("string");
306 expect(f.helper.length).toBeGreaterThan(0);
06a502cClaude307 expect([
308 "platform",
309 "ai",
310 "email",
311 "scm",
312 "security",
313 "observability",
314 "webhook",
315 ]).toContain(f.group);
509c376Claude316 }
317 });
318});