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.
| 25a91a6 | 1 | /** |
| 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 | ||
| 11 | import { describe, it, expect } from "bun:test"; | |
| 12 | import app from "../app"; | |
| 13 | import { | |
| 14 | matchGlob, | |
| 15 | reduceApprovalState, | |
| 16 | reviewerIdsOf, | |
| 17 | allowedBranchesOf, | |
| a76d984 | 18 | latestApprovalAt, |
| 19 | computeReadyAfter, | |
| 20 | releaseExpiredWaitTimers, | |
| 25a91a6 | 21 | } from "../lib/environments"; |
| 22 | import type { Environment, DeploymentApproval } from "../db/schema"; | |
| 23 | ||
| 24 | const 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 | ||
| 38 | describe("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 | ||
| 68 | describe("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 | ||
| 85 | const 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 | ||
| 98 | describe("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 | ||
| 122 | describe("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 | }); | |
| a76d984 | 200 | |
| 201 | // --------------------------------------------------------------------------- | |
| 202 | // Wait-timer enforcement (no longer a stub) | |
| 203 | // --------------------------------------------------------------------------- | |
| 204 | ||
| 205 | const 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 | ||
| 219 | describe("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 | ||
| 252 | describe("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 | ||
| 289 | describe("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 | }); |