CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pages.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 | * Block C3 — Pages unit + route tests. | |
| 3 | * | |
| 4 | * These tests run without a live database. Route tests therefore accept the | |
| 5 | * whole class of graceful-degradation responses (404 / 302 / 303 / 503): | |
| 6 | * 503 is emitted when the DB proxy throws, 404 when the repo row isn't found, | |
| 7 | * and 302/303 when auth middleware redirects to /login. | |
| 8 | */ | |
| 9 | ||
| 10 | import { describe, it, expect } from "bun:test"; | |
| 11 | import app from "../app"; | |
| 12 | import { | |
| 13 | contentTypeFor, | |
| 14 | resolvePagesPath, | |
| 15 | onPagesPush, | |
| 16 | } from "../lib/pages"; | |
| 17 | ||
| 18 | describe("lib/pages — contentTypeFor", () => { | |
| 19 | it("returns text/html for .html", () => { | |
| 20 | expect(contentTypeFor("index.html")).toContain("text/html"); | |
| 21 | }); | |
| 22 | ||
| 23 | it("returns text/css for .css", () => { | |
| 24 | expect(contentTypeFor("site.css")).toContain("text/css"); | |
| 25 | }); | |
| 26 | ||
| 27 | it("returns application/javascript for .js", () => { | |
| 28 | expect(contentTypeFor("app.js")).toContain("javascript"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("returns image/svg+xml for .svg", () => { | |
| 32 | expect(contentTypeFor("logo.svg")).toBe("image/svg+xml"); | |
| 33 | }); | |
| 34 | ||
| 35 | it("returns image/png for .png", () => { | |
| 36 | expect(contentTypeFor("pic.PNG")).toBe("image/png"); | |
| 37 | }); | |
| 38 | ||
| 39 | it("returns image/jpeg for .jpg and .jpeg", () => { | |
| 40 | expect(contentTypeFor("a.jpg")).toBe("image/jpeg"); | |
| 41 | expect(contentTypeFor("b.jpeg")).toBe("image/jpeg"); | |
| 42 | }); | |
| 43 | ||
| 44 | it("returns application/json for .json", () => { | |
| 45 | expect(contentTypeFor("data.json")).toContain("application/json"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("returns application/octet-stream for unknown extensions", () => { | |
| 49 | expect(contentTypeFor("mystery.xyz")).toBe("application/octet-stream"); | |
| 50 | }); | |
| 51 | ||
| 52 | it("returns application/octet-stream for files with no extension", () => { | |
| 53 | expect(contentTypeFor("Makefile")).toBe("application/octet-stream"); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("lib/pages — resolvePagesPath", () => { | |
| 58 | it("returns index.html for empty url rest at root", () => { | |
| 59 | expect(resolvePagesPath("", "/", "index.html")).toEqual(["index.html"]); | |
| 60 | }); | |
| 61 | ||
| 62 | it("returns index.html for a trailing slash", () => { | |
| 63 | expect(resolvePagesPath("/", "/", "index.html")).toEqual(["index.html"]); | |
| 64 | }); | |
| 65 | ||
| 66 | it("probes both foo.html and foo/index.html for extensionless urls", () => { | |
| 67 | const paths = resolvePagesPath("about", "/", "index.html"); | |
| 68 | expect(paths).toContain("about.html"); | |
| 69 | expect(paths).toContain("about/index.html"); | |
| 70 | expect(paths[0]).toBe("about.html"); | |
| 71 | }); | |
| 72 | ||
| 73 | it("serves a file directly when it has an extension", () => { | |
| 74 | expect(resolvePagesPath("assets/app.css", "/", "index.html")).toEqual([ | |
| 75 | "assets/app.css", | |
| 76 | ]); | |
| 77 | }); | |
| 78 | ||
| 79 | it("applies sourceDir as a prefix", () => { | |
| 80 | const paths = resolvePagesPath("about", "/docs", "index.html"); | |
| 81 | expect(paths[0]).toBe("docs/about.html"); | |
| 82 | expect(paths[1]).toBe("docs/about/index.html"); | |
| 83 | }); | |
| 84 | ||
| 85 | it("serves the index inside a nested directory when url ends with slash", () => { | |
| 86 | expect(resolvePagesPath("blog/", "/", "index.html")).toEqual([ | |
| 87 | "blog/index.html", | |
| 88 | ]); | |
| 89 | }); | |
| 90 | ||
| 91 | it("strips path-traversal segments", () => { | |
| 92 | const paths = resolvePagesPath("../../etc/passwd", "/", "index.html"); | |
| 93 | // .. entries are dropped; the remaining "etc/passwd" is treated as a | |
| 94 | // pretty URL because it has no file extension. | |
| 95 | expect(paths).not.toContain("../etc/passwd"); | |
| 96 | expect(paths).not.toContain("../../etc/passwd"); | |
| 97 | for (const p of paths) { | |
| 98 | expect(p.startsWith("..")).toBe(false); | |
| 99 | expect(p).not.toContain("../"); | |
| 100 | } | |
| 101 | expect(paths[0]).toBe("etc/passwd.html"); | |
| 102 | }); | |
| 103 | ||
| 104 | it("strips leading slashes on the url rest", () => { | |
| 105 | expect(resolvePagesPath("/about.html", "/", "index.html")).toEqual([ | |
| 106 | "about.html", | |
| 107 | ]); | |
| 108 | }); | |
| 109 | ||
| 110 | it("normalises source dir with or without leading / trailing slash", () => { | |
| 111 | expect(resolvePagesPath("", "docs/", "index.html")).toEqual([ | |
| 112 | "docs/index.html", | |
| 113 | ]); | |
| 114 | expect(resolvePagesPath("", "/docs/", "index.html")).toEqual([ | |
| 115 | "docs/index.html", | |
| 116 | ]); | |
| 117 | }); | |
| 118 | }); | |
| 119 | ||
| 120 | describe("lib/pages — onPagesPush", () => { | |
| 121 | it("never throws, even with a bogus repositoryId and no DB", async () => { | |
| 122 | // No DATABASE_URL in the test env => db proxy throws. The helper must | |
| 123 | // swallow that and return normally. | |
| 124 | await expect( | |
| 125 | onPagesPush({ | |
| 126 | ownerLogin: "alice", | |
| 127 | repoName: "project", | |
| 128 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 129 | ref: "refs/heads/gh-pages", | |
| 130 | newSha: "0".repeat(40), | |
| 131 | triggeredByUserId: null, | |
| 132 | }) | |
| 133 | ).resolves.toBeUndefined(); | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | describe("routes/pages — guards", () => { | |
| 138 | it("GET /:owner/:repo/pages/ 404s when repo does not exist", async () => { | |
| 139 | const res = await app.request("/alice/project/pages/"); | |
| 140 | // 404 when repo row not found, 503 when DB is unreachable. | |
| 141 | expect([404, 503]).toContain(res.status); | |
| 142 | }); | |
| 143 | ||
| 144 | it("GET /:owner/:repo/pages/foo 404s when repo does not exist", async () => { | |
| 145 | const res = await app.request("/nobody/nothing/pages/foo.html"); | |
| 146 | expect([404, 503]).toContain(res.status); | |
| 147 | }); | |
| 148 | ||
| 149 | it("GET /:owner/:repo/settings/pages requires auth (or is not yet mounted)", async () => { | |
| 150 | const res = await app.request( | |
| 151 | "/alice/project/settings/pages", | |
| 152 | { redirect: "manual" } | |
| 153 | ); | |
| 154 | // When mounted: anonymous -> redirected to /login. | |
| 155 | // When not yet wired into app.tsx (integration step handled by owner): | |
| 156 | // the global 404 handler answers instead. | |
| 157 | expect([302, 303, 404, 503]).toContain(res.status); | |
| 158 | if (res.status === 302 || res.status === 303) { | |
| 159 | const loc = res.headers.get("location") || ""; | |
| 160 | expect(loc).toContain("/login"); | |
| 161 | } | |
| 162 | }); | |
| 163 | ||
| 164 | it("POST /:owner/:repo/settings/pages without auth redirects to /login or 404s", async () => { | |
| 165 | const res = await app.request("/alice/project/settings/pages", { | |
| 166 | method: "POST", | |
| 167 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| 168 | body: "enabled=1&source_branch=gh-pages&source_dir=/", | |
| 169 | redirect: "manual", | |
| 170 | }); | |
| 171 | expect([302, 303, 404, 503]).toContain(res.status); | |
| 172 | if (res.status === 302 || res.status === 303) { | |
| 173 | const loc = res.headers.get("location") || ""; | |
| 174 | expect(loc).toContain("/login"); | |
| 175 | } | |
| 176 | }); | |
| 177 | ||
| 178 | it("POST /:owner/:repo/settings/pages/redeploy without auth redirects to /login or 404s", async () => { | |
| 179 | const res = await app.request( | |
| 180 | "/alice/project/settings/pages/redeploy", | |
| 181 | { | |
| 182 | method: "POST", | |
| 183 | redirect: "manual", | |
| 184 | } | |
| 185 | ); | |
| 186 | expect([302, 303, 404, 503]).toContain(res.status); | |
| 187 | if (res.status === 302 || res.status === 303) { | |
| 188 | const loc = res.headers.get("location") || ""; | |
| 189 | expect(loc).toContain("/login"); | |
| 190 | } | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | describe("routes/pages — direct route tests (no app mount)", () => { | |
| 195 | // These test the exported Hono app directly so we don't depend on the | |
| 196 | // owner wiring up app.tsx. This mirrors what the integration step will | |
| 197 | // yield once the parent agent mounts pagesRoute. | |
| 198 | it("direct GET /:owner/:repo/settings/pages without auth redirects to /login", async () => { | |
| 199 | const { default: pagesRoute } = await import("../routes/pages"); | |
| 200 | const res = await pagesRoute.request( | |
| 201 | "/alice/project/settings/pages", | |
| 202 | { redirect: "manual" } | |
| 203 | ); | |
| 204 | expect([302, 303, 503]).toContain(res.status); | |
| 205 | if (res.status === 302 || res.status === 303) { | |
| 206 | const loc = res.headers.get("location") || ""; | |
| 207 | expect(loc).toContain("/login"); | |
| 208 | } | |
| 209 | }); | |
| 210 | ||
| 211 | it("direct POST /:owner/:repo/settings/pages without auth redirects to /login", async () => { | |
| 212 | const { default: pagesRoute } = await import("../routes/pages"); | |
| 213 | const res = await pagesRoute.request("/alice/project/settings/pages", { | |
| 214 | method: "POST", | |
| 215 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| 216 | body: "enabled=1&source_branch=gh-pages&source_dir=/", | |
| 217 | redirect: "manual", | |
| 218 | }); | |
| 219 | expect([302, 303, 503]).toContain(res.status); | |
| 220 | if (res.status === 302 || res.status === 303) { | |
| 221 | const loc = res.headers.get("location") || ""; | |
| 222 | expect(loc).toContain("/login"); | |
| 223 | } | |
| 224 | }); | |
| 225 | ||
| 226 | it("direct GET /:owner/:repo/pages/ 404s when repo does not exist", async () => { | |
| 227 | const { default: pagesRoute } = await import("../routes/pages"); | |
| 228 | const res = await pagesRoute.request("/alice/project/pages/"); | |
| 229 | expect([404, 503]).toContain(res.status); | |
| 230 | }); | |
| 231 | }); |