Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
org-team-roster.test.ts5.1 KB · 128 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/**
 * Regression guard: an organization's team page must not be world-readable.
 *
 * GET /orgs/:org/teams/:team was mounted with `softAuth` and no membership
 * check of any kind, while every sibling org route required auth. It served
 * any anonymous caller:
 *
 *   - the team's full member roster (usernames)
 *   - the team's permission level (read / write / admin)
 *   - the team's repository list
 *
 * The last one is the serious part: that query joins `repositories` with no
 * `isPrivate` filter, so the NAMES of an organization's private repositories
 * leaked to anyone who could guess an org and team name. Private repo names
 * routinely disclose unreleased products, customers, and acquisitions.
 *
 * The policy being restored is not invented here — the people page states it
 * outright in its own comment ("org membership is non-public") and bounces
 * non-members. This route simply did not follow it.
 *
 * Mount only the router under test rather than importing `../app`: that is
 * one singleton shared by the whole test run, and a static import binds
 * whichever `../db` was mocked when some other file first triggered its load.
 */

import { describe, it, expect } from "bun:test";
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import orgRoutes from "../routes/orgs";

const app = new Hono();
app.route("/", orgRoutes);

describe("anonymous access to a team page", () => {
  // The anonymous branch returns before any database call, so this exercises
  // the real handler without needing a DATABASE_URL.

  it("redirects to login instead of rendering the roster", async () => {
    const res = await app.request("/orgs/acme/teams/platform");
    expect(res.status).toBe(302);
    const location = res.headers.get("location") || "";
    expect(location).toStartWith("/login?redirect=");
  });

  it("round-trips the requested team as the post-login target", async () => {
    const res = await app.request("/orgs/acme/teams/platform");
    const location = res.headers.get("location") || "";
    const target = decodeURIComponent(location.split("redirect=")[1] || "");
    expect(target).toBe("/orgs/acme/teams/platform");
  });

  it("encodes the redirect target so it stays a single relative path", async () => {
    // An org or team name carrying a slash or a scheme must not be able to
    // turn the login bounce into an off-origin redirect. lib/safe-redirect
    // is the backstop, but the target should be well-formed to begin with.
    const res = await app.request(
      "/orgs/acme/teams/" + encodeURIComponent("https://evil.example")
    );
    const location = res.headers.get("location") || "";
    expect(location).toStartWith("/login?redirect=");
    expect(location).not.toContain("//evil.example");
  });

  it("does not leak roster or repository markup in the response body", async () => {
    const body = await (await app.request("/orgs/acme/teams/platform")).text();
    expect(body).not.toContain("Members (");
    expect(body).not.toContain("access</span>");
  });
});

// --- the membership check must stay in place -------------------------------
//
// Strip ONLY line comments: a block-comment regex eats route paths, which
// contain a slash followed by a star.

describe("the team route enforces org membership", () => {
  const handler = (() => {
    const text = readFileSync(
      join(import.meta.dir, "..", "routes", "orgs.tsx"),
      "utf8"
    );
    const src = text
      .split("\n")
      .filter((l) => !l.trim().startsWith("//"))
      .join("\n");

    const start = src.indexOf(`orgRoutes.get("/orgs/:org/teams/:team"`);
    expect(start).toBeGreaterThan(-1);
    // Slice by anchor to the end of the handler, not by character count.
    const end = src.indexOf("orgRoutes.", start + 40);
    const body = end > start ? src.slice(start, end) : src.slice(start);
    expect(body.length).toBeGreaterThan(0);
    return body;
  })();

  it("looks the viewer up in orgMembers", () => {
    expect(handler).toContain("orgMembers");
    expect(handler).toContain("eq(orgMembers.userId, user.id)");
  });

  it("bounces anonymous callers before touching the database", () => {
    const anonAt = handler.indexOf("if (!user)");
    const dbAt = handler.indexOf("db.select()");
    expect(anonAt).toBeGreaterThan(-1);
    expect(dbAt).toBeGreaterThan(-1);
    expect(anonAt).toBeLessThan(dbAt);
  });

  it("resolves membership before reading the team or its repos", () => {
    const memberAt = handler.indexOf("eq(orgMembers.userId, user.id)");
    const teamAt = handler.indexOf("from(teams)");
    const reposAt = handler.indexOf("from(teamRepos)");
    expect(teamAt).toBeGreaterThan(memberAt);
    expect(reposAt).toBeGreaterThan(memberAt);
  });

  it("answers a non-member with 404, not 403", () => {
    // 403 would confirm the org and team exist, turning the page into an
    // enumeration oracle for private org structure.
    const memberAt = handler.indexOf("eq(orgMembers.userId, user.id)");
    const after = handler.slice(memberAt);
    expect(after).toContain("c.notFound()");
    expect(after.indexOf("c.notFound()")).toBeLessThan(
      after.indexOf("from(teams)")
    );
  });
});