|
| 1 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 2 | +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| 3 | +import { beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | +import { LoopoverMcp } from "../../src/mcp/server"; |
| 5 | +import { getRepositoryCollaboratorPermission } from "../../src/github/app"; |
| 6 | +import { upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; |
| 7 | +import type { AuthIdentity } from "../../src/auth/security"; |
| 8 | +import { createTestEnv } from "../helpers/d1"; |
| 9 | + |
| 10 | +// #8338: getMaintainerNoise / getAmsMinerCohort / getActivationPreview must use requireRepoAccess |
| 11 | +// (cached maintainer/owner/operator scope), matching their REST mirrors and sibling read tools — not the |
| 12 | +// live-write requireRepoApprovalQueueAccess gate reserved for approval-queue / write tools. |
| 13 | + |
| 14 | +vi.mock("../../src/github/app", async (importOriginal) => ({ |
| 15 | + ...(await importOriginal<typeof import("../../src/github/app")>()), |
| 16 | + getRepositoryCollaboratorPermission: vi.fn(), |
| 17 | + createInstallationToken: vi.fn(async () => "test-installation-token"), |
| 18 | +})); |
| 19 | + |
| 20 | +const mockedPermission = vi.mocked(getRepositoryCollaboratorPermission); |
| 21 | + |
| 22 | +const READ_REPORT_TOOLS = ["loopover_get_maintainer_noise", "loopover_get_ams_miner_cohort", "loopover_get_activation_preview"] as const; |
| 23 | + |
| 24 | +beforeEach(() => { |
| 25 | + mockedPermission.mockReset(); |
| 26 | + // Fail closed on live collaborator lookup — the regression is that these three tools used to deny |
| 27 | + // when this failed even though cached maintainer scope was enough for every sibling read tool. |
| 28 | + mockedPermission.mockRejectedValue(new Error("github collaborator lookup unavailable")); |
| 29 | +}); |
| 30 | + |
| 31 | +async function connect(env: Env, identity?: AuthIdentity) { |
| 32 | + const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer(); |
| 33 | + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| 34 | + await server.connect(serverTransport); |
| 35 | + const client = new Client({ name: "loopover-read-report-gate-test", version: "0.1.0" }, { capabilities: {} }); |
| 36 | + await client.connect(clientTransport); |
| 37 | + return client; |
| 38 | +} |
| 39 | + |
| 40 | +async function seedOwnedRepo(env: Env): Promise<void> { |
| 41 | + await upsertInstallation(env, { |
| 42 | + installation: { |
| 43 | + id: 5, |
| 44 | + account: { login: "owner", id: 1, type: "User" }, |
| 45 | + repository_selection: "selected", |
| 46 | + permissions: { metadata: "read", contents: "read", pull_requests: "read", issues: "read" }, |
| 47 | + events: ["pull_request"], |
| 48 | + }, |
| 49 | + repositories: [{ name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }], |
| 50 | + }); |
| 51 | + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); |
| 52 | +} |
| 53 | + |
| 54 | +describe("MCP read-only maintainer report gate parity (#8338)", () => { |
| 55 | + it("REGRESSION (#8338): cached maintainer scope succeeds even when live collaborator lookup fails", async () => { |
| 56 | + const env = createTestEnv(); |
| 57 | + await seedOwnedRepo(env); |
| 58 | + // Cached COLLABORATOR association grants maintainer scope via canLoginAccessRepo / requireRepoAccess, |
| 59 | + // but is intentionally insufficient for requireRepoApprovalQueueAccess (live write required). |
| 60 | + await upsertPullRequestFromGitHub(env, "owner/repo", { |
| 61 | + number: 7, |
| 62 | + title: "x", |
| 63 | + state: "open", |
| 64 | + user: { login: "reader" }, |
| 65 | + author_association: "COLLABORATOR", |
| 66 | + head: { sha: "sha" }, |
| 67 | + }); |
| 68 | + |
| 69 | + const client = await connect(env, { kind: "session", actor: "reader" } as AuthIdentity); |
| 70 | + for (const name of READ_REPORT_TOOLS) { |
| 71 | + const result = await client.callTool({ name, arguments: { owner: "owner", repo: "repo" } }); |
| 72 | + expect(result.isError, name).toBeFalsy(); |
| 73 | + const text = JSON.stringify(result); |
| 74 | + expect(text, name).toContain("owner/repo"); |
| 75 | + expect(text, name).not.toMatch(/wallet|hotkey|raw trust|payout|reward estimate/i); |
| 76 | + } |
| 77 | + // Live lookup must not be required for these read tools after the fix (sibling write tools still use it). |
| 78 | + expect(mockedPermission).not.toHaveBeenCalled(); |
| 79 | + }); |
| 80 | + |
| 81 | + it("rejects a session with no cached maintainer/owner scope on all three tools", async () => { |
| 82 | + const env = createTestEnv(); |
| 83 | + await seedOwnedRepo(env); |
| 84 | + |
| 85 | + const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); |
| 86 | + for (const name of READ_REPORT_TOOLS) { |
| 87 | + const result = await client.callTool({ name, arguments: { owner: "owner", repo: "repo" } }); |
| 88 | + expect(result.isError, name).toBe(true); |
| 89 | + expect(JSON.stringify(result), name).toMatch(/Forbidden: session cannot access this repository/i); |
| 90 | + } |
| 91 | + }); |
| 92 | + |
| 93 | + it("allows the owning session when live collaborator lookup is unavailable", async () => { |
| 94 | + const env = createTestEnv(); |
| 95 | + await seedOwnedRepo(env); |
| 96 | + |
| 97 | + const client = await connect(env, { kind: "session", actor: "owner" } as AuthIdentity); |
| 98 | + for (const name of READ_REPORT_TOOLS) { |
| 99 | + const result = await client.callTool({ name, arguments: { owner: "owner", repo: "repo" } }); |
| 100 | + expect(result.isError, name).toBeFalsy(); |
| 101 | + } |
| 102 | + expect(mockedPermission).not.toHaveBeenCalled(); |
| 103 | + }); |
| 104 | +}); |
0 commit comments