Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
repo-access.test.ts4.8 KB · 146 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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
/**
 * Unit tests for src/middleware/repo-access.ts#resolveRepoAccess.
 *
 * We stub `../db` with `mock.module` (same pattern as import-verify.test.ts)
 * so we never touch Neon. The fake `db.select(...)` chain returns per-test
 * configurable rows — one for the `repositories` lookup (owner check) and
 * one for the `repo_collaborators` lookup. We inspect which table the query
 * is `.from()`-ing to decide which row to return.
 *
 * See import-verify.test.ts for the reasoning behind the defensive defaults
 * (`mock.module` registrations don't unwind across files in a single
 * `bun test` run, so we reset state in `afterAll`).
 */

import { describe, it, expect, mock, afterAll } from "bun:test";

type RepoRow = { id: string; ownerId: string } | undefined;
type CollabRow = { role: "read" | "write" | "admin" } | undefined;

let _nextRepoRow: RepoRow;
let _nextCollabRow: CollabRow;
// The last Drizzle table handle passed to `.from(...)` — we peek at it when
// `.limit(1)` resolves so we can return the correct shape for this query.
let _lastFrom: any = null;

const _chain: any = {
  from: (table: any) => {
    _lastFrom = table;
    return _chain;
  },
  leftJoin: () => _chain,
  innerJoin: () => _chain,
  rightJoin: () => _chain,
  where: () => _chain,
  orderBy: () => _chain,
  groupBy: () => _chain,
  limit: async () => {
    // Heuristic: the schema defines tables as plain objects whose keys
    // include the column definitions. We distinguish repositories from
    // repo_collaborators by the presence of `ownerId` vs `acceptedAt`.
    const t = _lastFrom;
    if (t && typeof t === "object") {
      if ("acceptedAt" in t || "invitedAt" in t) {
        return _nextCollabRow ? [_nextCollabRow] : [];
      }
      if ("ownerId" in t || "diskPath" in t) {
        return _nextRepoRow ? [_nextRepoRow] : [];
      }
    }
    return [];
  },
};

const _fakeDb = {
  db: {
    select: () => _chain,
    insert: () => _chain,
    update: () => _chain,
    delete: () => _chain,
  },
  getDb: () => ({
    select: () => _chain,
    insert: () => _chain,
    update: () => _chain,
    delete: () => _chain,
  }),
};
mock.module("../db", () => _fakeDb);

afterAll(() => {
  _nextRepoRow = undefined;
  _nextCollabRow = undefined;
  _lastFrom = null;
});

const REPO_ID = "11111111-1111-1111-1111-111111111111";
const OWNER_ID = "22222222-2222-2222-2222-222222222222";
const OTHER_USER_ID = "33333333-3333-3333-3333-333333333333";

describe("resolveRepoAccess", () => {
  it('owner returns "owner"', async () => {
    _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
    _nextCollabRow = undefined;
    const { resolveRepoAccess } = await import("../middleware/repo-access");
    const access = await resolveRepoAccess({
      repoId: REPO_ID,
      userId: OWNER_ID,
      isPublic: false,
    });
    expect(access).toBe("owner");
  });

  it("collaborator with accepted invite returns their role", async () => {
    // Viewer is NOT the owner (so the owner check falls through), but they
    // have an accepted "write" collaborator row.
    _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
    _nextCollabRow = { role: "write" };
    const { resolveRepoAccess } = await import("../middleware/repo-access");
    const access = await resolveRepoAccess({
      repoId: REPO_ID,
      userId: OTHER_USER_ID,
      isPublic: false,
    });
    expect(access).toBe("write");
  });

  it("pending invite (acceptedAt=null) does NOT grant access", async () => {
    // The middleware filters `acceptedAt IS NOT NULL` in the WHERE clause,
    // so a pending row would never be returned by the real DB — we simulate
    // that by returning no collaborator row. The user should fall through
    // to the public/private fallback; here the repo is private, so "none".
    _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
    _nextCollabRow = undefined;
    const { resolveRepoAccess } = await import("../middleware/repo-access");
    const access = await resolveRepoAccess({
      repoId: REPO_ID,
      userId: OTHER_USER_ID,
      isPublic: false,
    });
    expect(access).toBe("none");
  });

  it('public repo + no collaborator row returns "read"', async () => {
    _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
    _nextCollabRow = undefined;
    const { resolveRepoAccess } = await import("../middleware/repo-access");
    const access = await resolveRepoAccess({
      repoId: REPO_ID,
      userId: OTHER_USER_ID,
      isPublic: true,
    });
    expect(access).toBe("read");
  });

  it('private repo + no collaborator row returns "none"', async () => {
    _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
    _nextCollabRow = undefined;
    const { resolveRepoAccess } = await import("../middleware/repo-access");
    const access = await resolveRepoAccess({
      repoId: REPO_ID,
      userId: OTHER_USER_ID,
      isPublic: false,
    });
    expect(access).toBe("none");
  });
});