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

environments.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.

environments.test.tsBlame297 lines · 1 contributor
25a91a6Claude1/**
2 * Tests for Block C4 — environments + deployment approvals.
3 *
4 * Unit tests exercise the pure-function glob matcher + the single-approver
5 * semantics of computeApprovalState. Route-level tests verify that settings
6 * CRUD and approve/reject endpoints are properly guarded — they tolerate
7 * DB-less test environments (302/303/307/404/503) rather than asserting
8 * a single happy-path status.
9 */
10
11import { describe, it, expect } from "bun:test";
12import app from "../app";
13import {
14 matchGlob,
15 reduceApprovalState,
16 reviewerIdsOf,
17 allowedBranchesOf,
a76d984Claude18 latestApprovalAt,
19 computeReadyAfter,
20 releaseExpiredWaitTimers,
25a91a6Claude21} from "../lib/environments";
22import type { Environment, DeploymentApproval } from "../db/schema";
23
24const envFixture = (overrides: Partial<Environment> = {}): Environment =>
25 ({
26 id: "env-1",
27 repositoryId: "repo-1",
28 name: "production",
29 requireApproval: true,
30 reviewers: "[]",
31 waitTimerMinutes: 0,
32 allowedBranches: "[]",
33 createdAt: new Date(),
34 updatedAt: new Date(),
35 ...overrides,
36 }) as Environment;
37
38describe("matchGlob", () => {
39 it("matches exact literals", () => {
40 expect(matchGlob("main", "main")).toBe(true);
41 });
42
43 it("does not match mismatched literals", () => {
44 expect(matchGlob("main", "release/*")).toBe(false);
45 });
46
47 it("supports single-segment * wildcards", () => {
48 expect(matchGlob("release/1.0", "release/*")).toBe(true);
49 expect(matchGlob("release/foo/bar", "release/*")).toBe(false);
50 });
51
52 it("supports ** for any path", () => {
53 expect(matchGlob("release/foo/bar", "release/**")).toBe(true);
54 expect(matchGlob("main", "**")).toBe(true);
55 });
56
57 it("strips refs/heads/ prefix on both sides", () => {
58 expect(matchGlob("refs/heads/main", "main")).toBe(true);
59 expect(matchGlob("main", "refs/heads/main")).toBe(true);
60 });
61
62 it("does not match unrelated branches", () => {
63 expect(matchGlob("feature/x", "release/*")).toBe(false);
64 expect(matchGlob("develop", "main")).toBe(false);
65 });
66});
67
68describe("reviewerIdsOf / allowedBranchesOf", () => {
69 it("parses valid JSON arrays", () => {
70 const env = envFixture({
71 reviewers: JSON.stringify(["u1", "u2"]),
72 allowedBranches: JSON.stringify(["main", "release/*"]),
73 });
74 expect(reviewerIdsOf(env)).toEqual(["u1", "u2"]);
75 expect(allowedBranchesOf(env)).toEqual(["main", "release/*"]);
76 });
77
78 it("returns [] for empty/invalid", () => {
79 expect(reviewerIdsOf(envFixture({ reviewers: "" }))).toEqual([]);
80 expect(reviewerIdsOf(envFixture({ reviewers: "not-json" }))).toEqual([]);
81 expect(allowedBranchesOf(envFixture({ allowedBranches: "[]" }))).toEqual([]);
82 });
83});
84
85const mkApproval = (
86 decision: "approved" | "rejected",
87 userId = "u1"
88): DeploymentApproval =>
89 ({
90 id: `a-${Math.random()}`,
91 deploymentId: "d1",
92 userId,
93 decision,
94 comment: null,
95 createdAt: new Date(),
96 }) as DeploymentApproval;
97
98describe("reduceApprovalState (single-approver semantics)", () => {
99 it("approved=true when any approval exists and no rejection", () => {
100 const state = reduceApprovalState([mkApproval("approved")]);
101 expect(state.approved).toBe(true);
102 expect(state.rejected).toBe(false);
103 expect(state.decided.length).toBe(1);
104 });
105
106 it("rejected=true when any rejection exists (overrides approval)", () => {
107 const state = reduceApprovalState([
108 mkApproval("approved", "u1"),
109 mkApproval("rejected", "u2"),
110 ]);
111 expect(state.rejected).toBe(true);
112 expect(state.approved).toBe(false);
113 });
114
115 it("neither approved nor rejected when no decisions", () => {
116 const state = reduceApprovalState([]);
117 expect(state.approved).toBe(false);
118 expect(state.rejected).toBe(false);
119 });
120});
121
122describe("environments routes — unauthed guards", () => {
123 const ok = [301, 302, 303, 307, 401, 404, 503];
124
125 it("GET /:owner/:repo/settings/environments redirects to login when unauthed", async () => {
126 const res = await app.request("/alice/project/settings/environments");
127 expect(ok).toContain(res.status);
128 });
129
130 it("POST /:owner/:repo/settings/environments requires auth", async () => {
131 const res = await app.request("/alice/project/settings/environments", {
132 method: "POST",
133 headers: { "content-type": "application/x-www-form-urlencoded" },
134 body: "name=staging",
135 });
136 expect(ok).toContain(res.status);
137 });
138
139 it("POST /:owner/:repo/settings/environments/:envId requires auth", async () => {
140 const res = await app.request(
141 "/alice/project/settings/environments/env-1",
142 {
143 method: "POST",
144 headers: { "content-type": "application/x-www-form-urlencoded" },
145 body: "",
146 }
147 );
148 expect(ok).toContain(res.status);
149 });
150
151 it("POST /:owner/:repo/settings/environments/:envId/delete requires auth", async () => {
152 const res = await app.request(
153 "/alice/project/settings/environments/env-1/delete",
154 {
155 method: "POST",
156 headers: { "content-type": "application/x-www-form-urlencoded" },
157 body: "",
158 }
159 );
160 expect(ok).toContain(res.status);
161 });
162
163 it("POST /:owner/:repo/deployments/:id/approve requires auth", async () => {
164 const res = await app.request(
165 "/alice/project/deployments/dep-1/approve",
166 {
167 method: "POST",
168 headers: { "content-type": "application/x-www-form-urlencoded" },
169 body: "",
170 }
171 );
172 expect(ok).toContain(res.status);
173 });
174
175 it("POST /:owner/:repo/deployments/:id/reject requires auth", async () => {
176 const res = await app.request(
177 "/alice/project/deployments/dep-1/reject",
178 {
179 method: "POST",
180 headers: { "content-type": "application/x-www-form-urlencoded" },
181 body: "",
182 }
183 );
184 expect(ok).toContain(res.status);
185 });
186
187 it("bearer auth with bogus token on settings POST returns 401", async () => {
188 const res = await app.request("/alice/project/settings/environments", {
189 method: "POST",
190 headers: {
191 "content-type": "application/x-www-form-urlencoded",
192 authorization: "Bearer glct_definitely-not-valid",
193 },
194 body: "name=staging",
195 });
196 // 401 from requireAuth with invalid bearer; 404/503 tolerated pre-route.
197 expect([401, 404, 503]).toContain(res.status);
198 });
199});
a76d984Claude200
201// ---------------------------------------------------------------------------
202// Wait-timer enforcement (no longer a stub)
203// ---------------------------------------------------------------------------
204
205const mkApprovalAt = (
206 decision: "approved" | "rejected",
207 createdAt: Date,
208 userId = "u1"
209): DeploymentApproval =>
210 ({
211 id: `a-${Math.random()}`,
212 deploymentId: "d1",
213 userId,
214 decision,
215 comment: null,
216 createdAt,
217 }) as DeploymentApproval;
218
219describe("latestApprovalAt", () => {
220 it("returns null when there are no approvals", () => {
221 expect(latestApprovalAt([])).toBeNull();
222 });
223
224 it("ignores rejection timestamps", () => {
225 const t1 = new Date("2026-01-01T00:00:00Z");
226 expect(latestApprovalAt([mkApprovalAt("rejected", t1)])).toBeNull();
227 });
228
229 it("returns the most recent approval timestamp", () => {
230 const t1 = new Date("2026-01-01T00:00:00Z");
231 const t2 = new Date("2026-01-02T00:00:00Z");
232 const t3 = new Date("2026-01-03T00:00:00Z");
233 const out = latestApprovalAt([
234 mkApprovalAt("approved", t2, "u1"),
235 mkApprovalAt("approved", t3, "u2"),
236 mkApprovalAt("approved", t1, "u3"),
237 ]);
238 expect(out?.toISOString()).toBe(t3.toISOString());
239 });
240
241 it("tolerates a malformed createdAt", () => {
242 const out = latestApprovalAt([
243 {
244 ...mkApprovalAt("approved", new Date("2026-01-01T00:00:00Z")),
245 createdAt: new Date("not-a-real-date"),
246 } as any,
247 ]);
248 expect(out).toBeNull();
249 });
250});
251
252describe("computeReadyAfter", () => {
253 it("returns null when waitTimerMinutes <= 0", () => {
254 const env = envFixture({ waitTimerMinutes: 0 });
255 const t = new Date("2026-01-01T00:00:00Z");
256 expect(computeReadyAfter(env, [mkApprovalAt("approved", t)])).toBeNull();
257 });
258
259 it("returns null when there are no approvals (timer hasn't started)", () => {
260 const env = envFixture({ waitTimerMinutes: 30 });
261 expect(computeReadyAfter(env, [])).toBeNull();
262 });
263
264 it("adds waitTimerMinutes to the latest approval timestamp", () => {
265 const env = envFixture({ waitTimerMinutes: 30 });
266 const t = new Date("2026-01-01T12:00:00Z");
267 const ready = computeReadyAfter(env, [mkApprovalAt("approved", t)]);
268 expect(ready?.toISOString()).toBe("2026-01-01T12:30:00.000Z");
269 });
270
271 it("uses the latest approval, not the earliest", () => {
272 const env = envFixture({ waitTimerMinutes: 60 });
273 const t1 = new Date("2026-01-01T00:00:00Z");
274 const t2 = new Date("2026-01-01T01:00:00Z");
275 const ready = computeReadyAfter(env, [
276 mkApprovalAt("approved", t1, "u1"),
277 mkApprovalAt("approved", t2, "u2"),
278 ]);
279 expect(ready?.toISOString()).toBe("2026-01-01T02:00:00.000Z");
280 });
281
282 it("returns null on a malformed waitTimerMinutes value", () => {
283 const env = envFixture({ waitTimerMinutes: NaN as any });
284 const t = new Date("2026-01-01T00:00:00Z");
285 expect(computeReadyAfter(env, [mkApprovalAt("approved", t)])).toBeNull();
286 });
287});
288
289describe("releaseExpiredWaitTimers — fail-open", () => {
290 it("returns 0 (not throws) when DB is unavailable / no rows match", async () => {
291 // No live DB → drizzle will throw inside the helper, which catches and
292 // returns 0. Either way the test asserts the never-throw contract.
293 const out = await releaseExpiredWaitTimers(new Date());
294 expect(typeof out).toBe("number");
295 expect(out).toBeGreaterThanOrEqual(0);
296 });
297});