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, | |
| 18 | } from "../lib/environments"; | |
| 19 | import type { Environment, DeploymentApproval } from "../db/schema"; | |
| 20 | ||
| 21 | const envFixture = (overrides: Partial<Environment> = {}): Environment => | |
| 22 | ({ | |
| 23 | id: "env-1", | |
| 24 | repositoryId: "repo-1", | |
| 25 | name: "production", | |
| 26 | requireApproval: true, | |
| 27 | reviewers: "[]", | |
| 28 | waitTimerMinutes: 0, | |
| 29 | allowedBranches: "[]", | |
| 30 | createdAt: new Date(), | |
| 31 | updatedAt: new Date(), | |
| 32 | ...overrides, | |
| 33 | }) as Environment; | |
| 34 | ||
| 35 | describe("matchGlob", () => { | |
| 36 | it("matches exact literals", () => { | |
| 37 | expect(matchGlob("main", "main")).toBe(true); | |
| 38 | }); | |
| 39 | ||
| 40 | it("does not match mismatched literals", () => { | |
| 41 | expect(matchGlob("main", "release/*")).toBe(false); | |
| 42 | }); | |
| 43 | ||
| 44 | it("supports single-segment * wildcards", () => { | |
| 45 | expect(matchGlob("release/1.0", "release/*")).toBe(true); | |
| 46 | expect(matchGlob("release/foo/bar", "release/*")).toBe(false); | |
| 47 | }); | |
| 48 | ||
| 49 | it("supports ** for any path", () => { | |
| 50 | expect(matchGlob("release/foo/bar", "release/**")).toBe(true); | |
| 51 | expect(matchGlob("main", "**")).toBe(true); | |
| 52 | }); | |
| 53 | ||
| 54 | it("strips refs/heads/ prefix on both sides", () => { | |
| 55 | expect(matchGlob("refs/heads/main", "main")).toBe(true); | |
| 56 | expect(matchGlob("main", "refs/heads/main")).toBe(true); | |
| 57 | }); | |
| 58 | ||
| 59 | it("does not match unrelated branches", () => { | |
| 60 | expect(matchGlob("feature/x", "release/*")).toBe(false); | |
| 61 | expect(matchGlob("develop", "main")).toBe(false); | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | describe("reviewerIdsOf / allowedBranchesOf", () => { | |
| 66 | it("parses valid JSON arrays", () => { | |
| 67 | const env = envFixture({ | |
| 68 | reviewers: JSON.stringify(["u1", "u2"]), | |
| 69 | allowedBranches: JSON.stringify(["main", "release/*"]), | |
| 70 | }); | |
| 71 | expect(reviewerIdsOf(env)).toEqual(["u1", "u2"]); | |
| 72 | expect(allowedBranchesOf(env)).toEqual(["main", "release/*"]); | |
| 73 | }); | |
| 74 | ||
| 75 | it("returns [] for empty/invalid", () => { | |
| 76 | expect(reviewerIdsOf(envFixture({ reviewers: "" }))).toEqual([]); | |
| 77 | expect(reviewerIdsOf(envFixture({ reviewers: "not-json" }))).toEqual([]); | |
| 78 | expect(allowedBranchesOf(envFixture({ allowedBranches: "[]" }))).toEqual([]); | |
| 79 | }); | |
| 80 | }); | |
| 81 | ||
| 82 | const mkApproval = ( | |
| 83 | decision: "approved" | "rejected", | |
| 84 | userId = "u1" | |
| 85 | ): DeploymentApproval => | |
| 86 | ({ | |
| 87 | id: `a-${Math.random()}`, | |
| 88 | deploymentId: "d1", | |
| 89 | userId, | |
| 90 | decision, | |
| 91 | comment: null, | |
| 92 | createdAt: new Date(), | |
| 93 | }) as DeploymentApproval; | |
| 94 | ||
| 95 | describe("reduceApprovalState (single-approver semantics)", () => { | |
| 96 | it("approved=true when any approval exists and no rejection", () => { | |
| 97 | const state = reduceApprovalState([mkApproval("approved")]); | |
| 98 | expect(state.approved).toBe(true); | |
| 99 | expect(state.rejected).toBe(false); | |
| 100 | expect(state.decided.length).toBe(1); | |
| 101 | }); | |
| 102 | ||
| 103 | it("rejected=true when any rejection exists (overrides approval)", () => { | |
| 104 | const state = reduceApprovalState([ | |
| 105 | mkApproval("approved", "u1"), | |
| 106 | mkApproval("rejected", "u2"), | |
| 107 | ]); | |
| 108 | expect(state.rejected).toBe(true); | |
| 109 | expect(state.approved).toBe(false); | |
| 110 | }); | |
| 111 | ||
| 112 | it("neither approved nor rejected when no decisions", () => { | |
| 113 | const state = reduceApprovalState([]); | |
| 114 | expect(state.approved).toBe(false); | |
| 115 | expect(state.rejected).toBe(false); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 119 | describe("environments routes — unauthed guards", () => { | |
| 120 | const ok = [301, 302, 303, 307, 401, 404, 503]; | |
| 121 | ||
| 122 | it("GET /:owner/:repo/settings/environments redirects to login when unauthed", async () => { | |
| 123 | const res = await app.request("/alice/project/settings/environments"); | |
| 124 | expect(ok).toContain(res.status); | |
| 125 | }); | |
| 126 | ||
| 127 | it("POST /:owner/:repo/settings/environments requires auth", async () => { | |
| 128 | const res = await app.request("/alice/project/settings/environments", { | |
| 129 | method: "POST", | |
| 130 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 131 | body: "name=staging", | |
| 132 | }); | |
| 133 | expect(ok).toContain(res.status); | |
| 134 | }); | |
| 135 | ||
| 136 | it("POST /:owner/:repo/settings/environments/:envId requires auth", async () => { | |
| 137 | const res = await app.request( | |
| 138 | "/alice/project/settings/environments/env-1", | |
| 139 | { | |
| 140 | method: "POST", | |
| 141 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 142 | body: "", | |
| 143 | } | |
| 144 | ); | |
| 145 | expect(ok).toContain(res.status); | |
| 146 | }); | |
| 147 | ||
| 148 | it("POST /:owner/:repo/settings/environments/:envId/delete requires auth", async () => { | |
| 149 | const res = await app.request( | |
| 150 | "/alice/project/settings/environments/env-1/delete", | |
| 151 | { | |
| 152 | method: "POST", | |
| 153 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 154 | body: "", | |
| 155 | } | |
| 156 | ); | |
| 157 | expect(ok).toContain(res.status); | |
| 158 | }); | |
| 159 | ||
| 160 | it("POST /:owner/:repo/deployments/:id/approve requires auth", async () => { | |
| 161 | const res = await app.request( | |
| 162 | "/alice/project/deployments/dep-1/approve", | |
| 163 | { | |
| 164 | method: "POST", | |
| 165 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 166 | body: "", | |
| 167 | } | |
| 168 | ); | |
| 169 | expect(ok).toContain(res.status); | |
| 170 | }); | |
| 171 | ||
| 172 | it("POST /:owner/:repo/deployments/:id/reject requires auth", async () => { | |
| 173 | const res = await app.request( | |
| 174 | "/alice/project/deployments/dep-1/reject", | |
| 175 | { | |
| 176 | method: "POST", | |
| 177 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 178 | body: "", | |
| 179 | } | |
| 180 | ); | |
| 181 | expect(ok).toContain(res.status); | |
| 182 | }); | |
| 183 | ||
| 184 | it("bearer auth with bogus token on settings POST returns 401", async () => { | |
| 185 | const res = await app.request("/alice/project/settings/environments", { | |
| 186 | method: "POST", | |
| 187 | headers: { | |
| 188 | "content-type": "application/x-www-form-urlencoded", | |
| 189 | authorization: "Bearer glct_definitely-not-valid", | |
| 190 | }, | |
| 191 | body: "name=staging", | |
| 192 | }); | |
| 193 | // 401 from requireAuth with invalid bearer; 404/503 tolerated pre-route. | |
| 194 | expect([401, 404, 503]).toContain(res.status); | |
| 195 | }); | |
| 196 | }); |