Blame · Line-by-line history
org-team-roster.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.
| 44b89b0 | 1 | /** |
| 2 | * Regression guard: an organization's team page must not be world-readable. | |
| 3 | * | |
| 4 | * GET /orgs/:org/teams/:team was mounted with `softAuth` and no membership | |
| 5 | * check of any kind, while every sibling org route required auth. It served | |
| 6 | * any anonymous caller: | |
| 7 | * | |
| 8 | * - the team's full member roster (usernames) | |
| 9 | * - the team's permission level (read / write / admin) | |
| 10 | * - the team's repository list | |
| 11 | * | |
| 12 | * The last one is the serious part: that query joins `repositories` with no | |
| 13 | * `isPrivate` filter, so the NAMES of an organization's private repositories | |
| 14 | * leaked to anyone who could guess an org and team name. Private repo names | |
| 15 | * routinely disclose unreleased products, customers, and acquisitions. | |
| 16 | * | |
| 17 | * The policy being restored is not invented here — the people page states it | |
| 18 | * outright in its own comment ("org membership is non-public") and bounces | |
| 19 | * non-members. This route simply did not follow it. | |
| 20 | * | |
| 21 | * Mount only the router under test rather than importing `../app`: that is | |
| 22 | * one singleton shared by the whole test run, and a static import binds | |
| 23 | * whichever `../db` was mocked when some other file first triggered its load. | |
| 24 | */ | |
| 25 | ||
| 26 | import { describe, it, expect } from "bun:test"; | |
| 27 | import { Hono } from "hono"; | |
| 28 | import { readFileSync } from "node:fs"; | |
| 29 | import { join } from "node:path"; | |
| 30 | import orgRoutes from "../routes/orgs"; | |
| 31 | ||
| 32 | const app = new Hono(); | |
| 33 | app.route("/", orgRoutes); | |
| 34 | ||
| 35 | describe("anonymous access to a team page", () => { | |
| 36 | // The anonymous branch returns before any database call, so this exercises | |
| 37 | // the real handler without needing a DATABASE_URL. | |
| 38 | ||
| 39 | it("redirects to login instead of rendering the roster", async () => { | |
| 40 | const res = await app.request("/orgs/acme/teams/platform"); | |
| 41 | expect(res.status).toBe(302); | |
| 42 | const location = res.headers.get("location") || ""; | |
| 43 | expect(location).toStartWith("/login?redirect="); | |
| 44 | }); | |
| 45 | ||
| 46 | it("round-trips the requested team as the post-login target", async () => { | |
| 47 | const res = await app.request("/orgs/acme/teams/platform"); | |
| 48 | const location = res.headers.get("location") || ""; | |
| 49 | const target = decodeURIComponent(location.split("redirect=")[1] || ""); | |
| 50 | expect(target).toBe("/orgs/acme/teams/platform"); | |
| 51 | }); | |
| 52 | ||
| 53 | it("encodes the redirect target so it stays a single relative path", async () => { | |
| 54 | // An org or team name carrying a slash or a scheme must not be able to | |
| 55 | // turn the login bounce into an off-origin redirect. lib/safe-redirect | |
| 56 | // is the backstop, but the target should be well-formed to begin with. | |
| 57 | const res = await app.request( | |
| 58 | "/orgs/acme/teams/" + encodeURIComponent("https://evil.example") | |
| 59 | ); | |
| 60 | const location = res.headers.get("location") || ""; | |
| 61 | expect(location).toStartWith("/login?redirect="); | |
| 62 | expect(location).not.toContain("//evil.example"); | |
| 63 | }); | |
| 64 | ||
| 65 | it("does not leak roster or repository markup in the response body", async () => { | |
| 66 | const body = await (await app.request("/orgs/acme/teams/platform")).text(); | |
| 67 | expect(body).not.toContain("Members ("); | |
| 68 | expect(body).not.toContain("access</span>"); | |
| 69 | }); | |
| 70 | }); | |
| 71 | ||
| 72 | // --- the membership check must stay in place ------------------------------- | |
| 73 | // | |
| 74 | // Strip ONLY line comments: a block-comment regex eats route paths, which | |
| 75 | // contain a slash followed by a star. | |
| 76 | ||
| 77 | describe("the team route enforces org membership", () => { | |
| 78 | const handler = (() => { | |
| 79 | const text = readFileSync( | |
| 80 | join(import.meta.dir, "..", "routes", "orgs.tsx"), | |
| 81 | "utf8" | |
| 82 | ); | |
| 83 | const src = text | |
| 84 | .split("\n") | |
| 85 | .filter((l) => !l.trim().startsWith("//")) | |
| 86 | .join("\n"); | |
| 87 | ||
| 88 | const start = src.indexOf(`orgRoutes.get("/orgs/:org/teams/:team"`); | |
| 89 | expect(start).toBeGreaterThan(-1); | |
| 90 | // Slice by anchor to the end of the handler, not by character count. | |
| 91 | const end = src.indexOf("orgRoutes.", start + 40); | |
| 92 | const body = end > start ? src.slice(start, end) : src.slice(start); | |
| 93 | expect(body.length).toBeGreaterThan(0); | |
| 94 | return body; | |
| 95 | })(); | |
| 96 | ||
| 97 | it("looks the viewer up in orgMembers", () => { | |
| 6ae664d | 98 | expect(handler).toContain("from(orgMembers)"); |
| 44b89b0 | 99 | expect(handler).toContain("eq(orgMembers.userId, user.id)"); |
| 100 | }); | |
| 101 | ||
| 102 | it("bounces anonymous callers before touching the database", () => { | |
| 103 | const anonAt = handler.indexOf("if (!user)"); | |
| 104 | const dbAt = handler.indexOf("db.select()"); | |
| 105 | expect(anonAt).toBeGreaterThan(-1); | |
| 106 | expect(dbAt).toBeGreaterThan(-1); | |
| 107 | expect(anonAt).toBeLessThan(dbAt); | |
| 108 | }); | |
| 109 | ||
| 110 | it("resolves membership before reading the team or its repos", () => { | |
| 111 | const memberAt = handler.indexOf("eq(orgMembers.userId, user.id)"); | |
| 112 | const teamAt = handler.indexOf("from(teams)"); | |
| 113 | const reposAt = handler.indexOf("from(teamRepos)"); | |
| 114 | expect(teamAt).toBeGreaterThan(memberAt); | |
| 115 | expect(reposAt).toBeGreaterThan(memberAt); | |
| 116 | }); | |
| 117 | ||
| 118 | it("answers a non-member with 404, not 403", () => { | |
| 119 | // 403 would confirm the org and team exist, turning the page into an | |
| 120 | // enumeration oracle for private org structure. | |
| 121 | const memberAt = handler.indexOf("eq(orgMembers.userId, user.id)"); | |
| 122 | const after = handler.slice(memberAt); | |
| 123 | expect(after).toContain("c.notFound()"); | |
| 124 | expect(after.indexOf("c.notFound()")).toBeLessThan( | |
| 125 | after.indexOf("from(teams)") | |
| 126 | ); | |
| 127 | }); | |
| 128 | }); |