import { describe, expect, it, vi } from "vitest"; import { loadEnv } from "../src/config/env.js"; import { GiteaClient, GiteaApiError } from "../src/integrations/gitea/client.js"; import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, normalizeRepositoryPath } from "../src/integrations/gitea/policy.js"; import type { GiteaActionRun } from "../src/integrations/gitea/types.js"; import { exchangeAuthorizationCode, exchangeRefreshToken, issueAuthorizationCode, registerOAuthClient, verifyMcpAccessToken } from "../src/security/mcpOAuth.js"; import { createHash, createHmac } from "node:crypto"; import { hasGiteaAdminAccess, verifyGiteaWebhookSignature } from "../src/routes/giteaIntegration.js"; import { assertFeatureDeliveryActionEvidence, assertFeatureDeliveryPlanComplete, canSyncFeatureDeliveryValidation, statusAfterFeatureDeliveryValidationSync } from "../src/repositories/featureDeliveryRepository.js"; import { augmentProjectBrainInitialize, handleProjectBrainSkillMethod, projectBrainSkills } from "../src/mcp/skills.js"; import { isMcpHubServiceToken, mcpHubActor } from "../src/security/mcpServiceAuth.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createProjectBrainMcpServer } from "../src/mcp/server.js"; import { PROJECTBRAIN_WORKSPACE_MIME_TYPE, PROJECTBRAIN_WORKSPACE_URI } from "../src/mcp/widget.js"; import { PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, PROJECTBRAIN_ANALYSIS_WIDGET_URI } from "../src/mcp/analysisWidget.js"; import { findingIssueMarker, findingRiskScore, renderFindingEvidence, repositoryFindingFingerprint } from "../src/repositories/findingRepository.js"; import { canReuseProjectForRepository, isPortfolioManagedPolicy, resolvePortfolioRepositoryStatus } from "../src/services/portfolioControlPlane.js"; import { PortfolioRepositoryStatus } from "@prisma/client"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { PROJECTBRAIN_GITEA_WEBHOOK_EVENTS } from "../src/integrations/gitea/service.js"; import { existingRepositoryFromProjectSetup, giteaRepositoryNameCandidate, giteaRepositoryNameForProject, projectSlugFromInput } from "../src/services/projectRepositoryProvisioning.js"; const env = loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_MCP_ENABLED: "true", PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters", }); describe("Gitea repository policy", () => { it("accepts 58/200 analysis selections, rejects 201, and keeps reconciliation bounded to 50", async () => { const registration = vi.spyOn(McpServer.prototype, "registerTool"); const server = createProjectBrainMcpServer(env, "analysis-selection-contract-test"); try { const analysis = registration.mock.calls.find(([name]) => name === "start_repository_analysis")![1]; const schema = z.object(analysis.inputSchema as z.ZodRawShape); for (const field of ["project_ids", "repository_ids"]) { for (const count of [58, 200, 201]) { const values = Array.from({ length: count }, (_, index) => `repository-${index}`); expect(schema.safeParse({ [field]: values }).success).toBe(count <= 200); } } const reconcile = registration.mock.calls.find(([name]) => name === "reconcile_portfolio_repositories")![1]; const reconcileSchema = z.object(reconcile.inputSchema as z.ZodRawShape); expect(reconcileSchema.safeParse({ repository_full_names: Array.from({ length: 50 }, (_, index) => `Owner/repo-${index}`) }).success).toBe(true); expect(reconcileSchema.safeParse({ repository_full_names: Array.from({ length: 51 }, (_, index) => `Owner/repo-${index}`) }).success).toBe(false); } finally { registration.mockRestore(); await server.close(); } }); it("only realigns policies that still belong to the portfolio control plane", () => { expect(isPortfolioManagedPolicy()).toBe(true); expect(isPortfolioManagedPolicy("system")).toBe(true); expect(isPortfolioManagedPolicy("system:portfolio-control-plane")).toBe(true); expect(isPortfolioManagedPolicy("web:owner")).toBe(false); }); it("normalizes safe paths and blocks traversal, Git internals, and secrets", () => { expect(normalizeRepositoryPath("/src\\app.ts")).toBe("src/app.ts"); expect(() => normalizeRepositoryPath("../outside.txt")).toThrow("REPOSITORY_PATH_FORBIDDEN"); expect(() => normalizeRepositoryPath(".git/config")).toThrow("REPOSITORY_PATH_FORBIDDEN"); expect(() => assertReadablePath(".env")).toThrow("SENSITIVE_PATH_FORBIDDEN"); expect(assertReadablePath(".env.example")).toBe(".env.example"); }); it("allows any bounded non-default work branch and files", () => { expect(assertChatGptBranch("chatgpt/add-search", "main")).toBe("chatgpt/add-search"); expect(assertChatGptBranch("projectbrain/continue-in-web", "main")).toBe("projectbrain/continue-in-web"); expect(assertChatGptBranch("m15-backup-restore-dr", "main")).toBe("m15-backup-restore-dr"); expect(assertChatGptBranch("feature/normal", "main")).toBe("feature/normal"); expect(() => assertChatGptBranch("main", "main")).toThrow("DEFAULT_BRANCH_WRITE_FORBIDDEN"); expect(() => assertFileSize("x".repeat(512 * 1024 + 1))).toThrow("MCP_FILE_TOO_LARGE"); }); it("keeps ProjectBrain workbench writes on non-default branches", () => { expect(assertProjectBrainBranch("projectbrain/add-project-dashboard", "main")).toBe("projectbrain/add-project-dashboard"); expect(assertProjectBrainBranch("chatgpt/continue-in-workbench", "main")).toBe("chatgpt/continue-in-workbench"); expect(assertProjectBrainBranch("feature/normal", "main")).toBe("feature/normal"); expect(() => assertProjectBrainBranch("main", "main")).toThrow("DEFAULT_BRANCH_WRITE_FORBIDDEN"); expect(() => assertProjectBrainBranch("projectbrain/../main", "main")).toThrow("PROJECTBRAIN_BRANCH_FORBIDDEN"); }); it("rejects stale updates and create-versus-update races", () => { expect(() => assertExpectedFileSha("sha-opened", "sha-current")).toThrow("GITEA_FILE_CONFLICT"); expect(() => assertExpectedFileSha(undefined, "sha-created-elsewhere")).toThrow("GITEA_FILE_CONFLICT"); expect(() => assertExpectedFileSha("sha-deleted", undefined)).toThrow("GITEA_FILE_CONFLICT"); expect(assertExpectedFileSha("sha-current", "sha-current")).toBeUndefined(); expect(assertExpectedFileSha(undefined, undefined)).toBeUndefined(); }); }); describe("Gitea webhook verification", () => { it("subscribes linked repositories to push-driven analysis and closed-loop events", () => { expect(PROJECTBRAIN_GITEA_WEBHOOK_EVENTS).toEqual(["push", "pull_request", "issues", "issue_comment"]); }); it("accepts only the HMAC-SHA256 signature of the untouched body", () => { const body = Buffer.from('{"action":"closed","number":7}'); const secret = "a-webhook-secret-with-enough-entropy"; const signature = createHmac("sha256", secret).update(body).digest("hex"); expect(verifyGiteaWebhookSignature(body, signature, secret)).toBe(true); expect(verifyGiteaWebhookSignature(Buffer.from(`${body.toString()} `), signature, secret)).toBe(false); expect(verifyGiteaWebhookSignature(body, "invalid", secret)).toBe(false); }); }); describe("Gitea API client", () => { it("accepts Gitea's empty HTTP 200 merge response without inventing a failed merge", async () => { const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") ? new Response(JSON.stringify({ version: "1.27.3" }), { status: 200 }) : new Response(null, { status: 200 })); const client = new GiteaClient("https://gitea.example.com", "test-token", fetcher as typeof fetch); await expect(client.mergePullRequest("Owner", "Repo", 7, { method: "merge", headCommitId: "a".repeat(40) })).resolves.toBeUndefined(); expect(fetcher).toHaveBeenCalledTimes(2); }); it("creates a private initialized repository through the bounded user endpoint", async () => { const fetcher = vi.fn(async () => new Response(JSON.stringify({ id: 17, name: "new-project", full_name: "Acme/new-project", private: true, archived: false, default_branch: "main", html_url: "https://gitea.example.com/Acme/new-project", updated_at: "2026-09-02T00:00:00Z", owner: { login: "Acme" }, permissions: { admin: true, push: true, pull: true }, }), { status: 201, headers: { "content-type": "application/json" } })); const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); const repository = await client.createRepository({ name: "new-project", description: "Created by ProjectBrain", private: true, }); expect(repository.full_name).toBe("Acme/new-project"); expect(String(fetcher.mock.calls[0][0])).toBe("https://gitea.example.com/api/v1/user/repos"); expect(fetcher.mock.calls[0][1]?.method).toBe("POST"); expect(fetcher.mock.calls[0][1]?.body).toBe(JSON.stringify({ name: "new-project", description: "Created by ProjectBrain", private: true, auto_init: true, default_branch: "main", })); }); it("uses token authentication and encodes repository paths", async () => { const fetcher = vi.fn(async () => new Response(JSON.stringify({ type: "file", path: "src/a b.ts", sha: "abc", size: 1, encoding: "base64", content: "eA==" }), { status: 200, headers: { "content-type": "application/json" } })); const client = new GiteaClient("https://gitea.example.com/", "secret-token", fetcher as typeof fetch); await client.getContents("Acme", "Repo", "src/a b.ts", "main"); const [url, init] = fetcher.mock.calls[0]; expect(String(url)).toContain("/api/v1/repos/Acme/Repo/contents/src/a%20b.ts?ref=main"); expect((init?.headers as Record).Authorization).toBe("token secret-token"); }); it("maps remote failures without exposing the configured token", async () => { const client = new GiteaClient("https://gitea.example.com", "very-secret-token", (async () => new Response(JSON.stringify({ message: "not found" }), { status: 404 })) as typeof fetch); await expect(client.getRepository("owner", "missing")).rejects.toMatchObject({ status: 404, code: "GITEA_HTTP_404", message: "not found" }); }); it("paginates the complete visible repository portfolio", async () => { const fetcher = vi.fn(async (input: string | URL | Request) => { const page = new URL(String(input)).searchParams.get("page"); const count = page === "1" ? 100 : page === "2" ? 3 : 0; return new Response(JSON.stringify(Array.from({ length: count }, (_, index) => ({ id: Number(page) * 1000 + index, name: `repo-${page}-${index}`, full_name: `Acme/repo-${page}-${index}`, private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/repo", updated_at: "2026-08-26T00:00:00Z", owner: { login: "Acme" }, permissions: { push: true }, }))), { status: 200, headers: { "content-type": "application/json" } }); }); const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); expect(await client.listRepositories()).toHaveLength(103); expect(fetcher).toHaveBeenCalledTimes(3); }); it("continues past a server-clamped 50-item page and never follows foreign Link URLs", async () => { const fetcher = vi.fn(async (input: string | URL | Request) => { const url = new URL(String(input)); expect(url.origin).toBe("https://gitea.example.com"); expect(url.pathname).toBe("/api/v1/user/repos"); expect(url.searchParams.get("limit")).toBe("100"); const page = Number(url.searchParams.get("page")); const count = page === 1 ? 50 : page === 2 ? 8 : 0; return new Response(JSON.stringify(Array.from({ length: count }, (_, index) => ({ id: (page - 1) * 50 + index + 1 }))), { headers: { "Link": '; rel="next"', "X-Total-Count": "58" }, }); }); expect(await new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()).toHaveLength(58); expect(fetcher).toHaveBeenCalledTimes(3); }); it("rejects repeated pages rather than returning a partial inventory", async () => { const fetcher = vi.fn(async () => new Response(JSON.stringify([{ id: 1 }]))); await expect(new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()) .rejects.toMatchObject({ code: "GITEA_INVENTORY_NONADVANCING" }); expect(fetcher).toHaveBeenCalledTimes(2); }); it("fails closed when the bounded inventory window is exhausted", async () => { const fetcher = vi.fn(async (input: string | URL | Request) => new Response(JSON.stringify([{ id: Number(new URL(String(input)).searchParams.get("page")) }]))); await expect(new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()) .rejects.toMatchObject({ code: "GITEA_INVENTORY_LIMIT_EXCEEDED" }); expect(fetcher).toHaveBeenCalledTimes(10); }); it("propagates a later-page failure without returning the earlier inventory", async () => { const fetcher = vi.fn(async (input: string | URL | Request) => new URL(String(input)).searchParams.get("page") === "1" ? new Response(JSON.stringify([{ id: 1 }])) : new Response(JSON.stringify({ message: "unavailable" }), { status: 503 })); await expect(new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()) .rejects.toMatchObject({ code: "GITEA_HTTP_503" }); }); it("creates, patches, comments on, and labels issues through bounded API calls", async () => { const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); if (url.endsWith("/comments")) return new Response(JSON.stringify({ id: 4, body: "verified", created_at: "2026-08-26T00:00:00Z" }), { status: 201 }); if (url.endsWith("/labels")) return new Response(JSON.stringify([]), { status: 200 }); return new Response(JSON.stringify({ id: 7, number: 7, title: "Finding", body: "body", state: "open", html_url: "https://gitea.example/issues/7", created_at: "2026-08-26T00:00:00Z", updated_at: "2026-08-26T00:00:00Z" }), { status: init?.method === "PATCH" ? 200 : 201 }); }); const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); await client.createIssue("Acme", "Repo", { title: "Finding", body: "body", labels: [1, 2] }); await client.editIssue("Acme", "Repo", 7, { state: "closed" }); await client.createIssueComment("Acme", "Repo", 7, "verified"); await client.replaceIssueLabels("Acme", "Repo", 7, [1, 2]); expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(["POST", "PATCH", "POST", "PUT"]); expect(fetcher.mock.calls[0][1]?.body).toBe(JSON.stringify({ title: "Finding", body: "body", labels: [1, 2] })); expect(String(fetcher.mock.calls[3][0])).toContain("/issues/7/labels"); }); it("merges an exact pull request head and deletes its encoded work branch", async () => { const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") ? new Response(JSON.stringify({ version: "1.27.3" }), { status: 200, headers: { "content-type": "application/json" } }) : new Response(undefined, { status: 204 })); const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); await client.mergePullRequest("Acme", "Repo", 17, { method: "squash", headCommitId: "a".repeat(40) }); await client.deleteBranch("Acme", "Repo", "feature/a b"); expect(String(fetcher.mock.calls[0][0])).toContain("/version"); expect(String(fetcher.mock.calls[1][0])).toContain("/pulls/17/merge"); expect(fetcher.mock.calls[1][1]?.method).toBe("POST"); expect(fetcher.mock.calls[1][1]?.body).toBe(JSON.stringify({ do: "squash", head_commit_id: "a".repeat(40), force_merge: false, merge_when_checks_succeed: false, delete_branch_after_merge: false, })); expect(String(fetcher.mock.calls[2][0])).toContain("/branches/feature%2Fa%20b"); expect(fetcher.mock.calls[2][1]?.method).toBe("DELETE"); }); it.each([ ["1.25.5", "Do"], ["1.26.4", "do"], ["1.27.3", "do"], ] as const)("uses the versioned merge strategy field for Gitea %s", async (version, strategyKey) => { const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") ? new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } }) : new Response(undefined, { status: 204 })); const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); await client.mergePullRequest("Acme", "Repo", 17, { method: "merge", headCommitId: "b".repeat(40) }); await client.mergePullRequest("Acme", "Repo", 18, { method: "squash", headCommitId: "c".repeat(40) }); expect(fetcher.mock.calls.filter(([input]) => String(input).endsWith("/version"))).toHaveLength(1); const firstBody = JSON.parse(String(fetcher.mock.calls[1][1]?.body)) as Record; expect(firstBody[strategyKey]).toBe("merge"); expect(firstBody[strategyKey === "Do" ? "do" : "Do"]).toBeUndefined(); }); it("waits for every required pull-request status context before merging", async () => { let statusReads = 0; const fetcher = vi.fn(async (input: string | URL | Request) => { const url = String(input); if (url.includes("/branch_protections/main")) return new Response(JSON.stringify({ enable_status_check: true, status_check_contexts: ["Managed validation / full (pull_request)"] }), { status: 200 }); if (url.endsWith("/commits/" + "d".repeat(40) + "/status")) { statusReads += 1; return new Response(JSON.stringify({ state: statusReads === 1 ? "pending" : "success", statuses: [{ context: "Managed validation / full (pull_request)", status: statusReads === 1 ? "pending" : "success" }] }), { status: 200 }); } return new Response(JSON.stringify({ message: "unexpected request" }), { status: 500 }); }); const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); const result = await client.waitForRequiredPullRequestChecks("Acme", "Repo", "main", "d".repeat(40), { timeoutMs: 1_000, pollIntervalMs: 0 }); expect(result).toEqual({ requiredContexts: ["Managed validation / full (pull_request)"], state: "success" }); expect(statusReads).toBe(2); }); }); describe("portfolio onboarding policy", () => { it("distinguishes existing-repository intake from new repository provisioning", () => { expect(existingRepositoryFromProjectSetup({ firstRun: { repositoryProfile: { repository: "Acme/existing" } } })).toBe("Acme/existing"); expect(existingRepositoryFromProjectSetup({ firstRun: { repositoryProfile: { preferredStack: "TypeScript" } } })).toBeUndefined(); expect(projectSlugFromInput(undefined, "My New Project")).toBe("my-new-project"); expect(giteaRepositoryNameForProject("My Invalid / Project")).toBe("my-invalid-project"); expect(giteaRepositoryNameCandidate("my-project", 2)).toBe("my-project-2"); }); it("enables automatic reconciliation with a one-minute default", () => { const configured = loadEnv({ NODE_ENV: "test", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", }); expect(configured.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES).toBe(true); expect(configured.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS).toBe(60); }); it("accepts a separate least-privilege token for issue writes", () => { const configured = loadEnv({ ...process.env, NODE_ENV: "test", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_GITEA_WRITE_TOKEN: "issue-write-token-with-twenty-characters", }); expect(configured.PROJECTBRAIN_GITEA_WRITE_TOKEN).toBe("issue-write-token-with-twenty-characters"); }); it("keeps explicit exclusions durable while a real link always wins", () => { expect(resolvePortfolioRepositoryStatus(false)).toBe(PortfolioRepositoryStatus.discovered); expect(resolvePortfolioRepositoryStatus(false, PortfolioRepositoryStatus.excluded)).toBe(PortfolioRepositoryStatus.excluded); expect(resolvePortfolioRepositoryStatus(true, PortfolioRepositoryStatus.excluded)).toBe(PortfolioRepositoryStatus.onboarded); }); it("does not reuse a project slug that already belongs to another repository", () => { expect(canReuseProjectForRepository([], "Jens", "ProjectBrain")).toBe(true); expect(canReuseProjectForRepository([{ owner: "jens", repository: "projectbrain" }], "Jens", "ProjectBrain")).toBe(true); expect(canReuseProjectForRepository([{ owner: "Other", repository: "ProjectBrain" }], "Jens", "ProjectBrain")).toBe(false); expect(canReuseProjectForRepository([{ owner: "Jens", repository: "Different" }], "Jens", "ProjectBrain")).toBe(false); }); }); describe("durable finding identity", () => { it("deduplicates repository findings case-insensitively and keeps rule subjects distinct", () => { const first = repositoryFindingFingerprint("Acme", "Repo", "DEFAULT_BRANCH_UNPROTECTED", "main"); expect(repositoryFindingFingerprint("acme", "repo", "DEFAULT_BRANCH_UNPROTECTED", "main")).toBe(first); expect(repositoryFindingFingerprint("acme", "repo", "DEFAULT_BRANCH_UNPROTECTED", "develop")).not.toBe(first); expect(findingIssueMarker({ id: "finding-1", fingerprint: first })).toContain("projectbrain:finding=finding-1"); }); it("keeps workflow subjects distinct and ranks actionable evidence deterministically", () => { expect(repositoryFindingFingerprint("Acme", "Repo", "DEFAULT_BRANCH_ACTION_FAILED", "main:ci.yml")) .not.toBe(repositoryFindingFingerprint("Acme", "Repo", "DEFAULT_BRANCH_ACTION_FAILED", "main:deploy.yml")); expect(findingRiskScore({ severity: "critical", code: "DEFAULT_BRANCH_ACTION_FAILED", message: "failed", evidence: { scope: "default_branch", consecutiveFailures: 2 } })) .toBeGreaterThan(findingRiskScore({ severity: "critical", code: "SCHEDULED_ACTION_FAILED", message: "failed", evidence: { scope: "scheduled", consecutiveFailures: 2 } })); expect(findingRiskScore({ severity: "warning", code: "SCHEDULED_ACTION_FAILED", message: "failed", evidence: { scope: "scheduled", consecutiveFailures: 2 } })) .toBeGreaterThan(findingRiskScore({ severity: "info", code: "TOPIC_BRANCH_ACTION_FAILED", message: "failed", evidence: { scope: "pull_request", consecutiveFailures: 1 } })); }); it("renders the exact Gitea run evidence for managed issues", () => { const markdown = renderFindingEvidence({ workflow: "ci.yml", branch: "main", event: "push", conclusion: "failure", consecutiveFailures: 3, runUrl: "https://gitea.example/actions/runs/7" }); expect(markdown).toContain("[open run](https://gitea.example/actions/runs/7)"); expect(markdown).toContain("ci.yml"); expect(markdown).toContain("main"); }); }); describe("MCP OAuth 2.1 PKCE", () => { it("registers a public client, exchanges a PKCE code, and refreshes access", () => { const redirectUri = "https://chatgpt.com/connector/callback"; const client = registerOAuthClient(env, { redirectUris: [redirectUri], clientName: "ChatGPT" }); const verifier = "a".repeat(48); const challenge = createHash("sha256").update(verifier).digest("base64url"); const code = issueAuthorizationCode(env, { clientId: client.clientId, redirectUri, codeChallenge: challenge, scope: "mcp:tools", subject: "owner", credentialVersion: 1, audience: "https://projectbrain.example.com/mcp" }); const pair = exchangeAuthorizationCode(env, { code, clientId: client.clientId, redirectUri, codeVerifier: verifier }); expect(verifyMcpAccessToken(env, pair.accessToken)).toMatchObject({ subject: "owner", credentialVersion: 1, scope: "mcp:tools", audience: "https://projectbrain.example.com/mcp" }); expect(verifyMcpAccessToken(env, exchangeRefreshToken(env, pair.refreshToken, client.clientId).accessToken).subject).toBe("owner"); expect(() => exchangeAuthorizationCode(env, { code, clientId: client.clientId, redirectUri, codeVerifier: verifier })).toThrow("OAUTH_CODE_ALREADY_USED"); }); it("rejects redirect substitution and an invalid PKCE verifier", () => { const client = registerOAuthClient(env, { redirectUris: ["https://chatgpt.com/connector/callback"] }); expect(() => registerOAuthClient(env, { redirectUris: ["http://attacker.example/callback"] })).toThrow("OAUTH_REDIRECT_INVALID"); const code = issueAuthorizationCode(env, { clientId: client.clientId, redirectUri: client.redirectUris[0], codeChallenge: createHash("sha256").update("correct".repeat(8)).digest("base64url"), scope: "mcp:tools", subject: "owner", credentialVersion: 1, audience: "https://projectbrain.example.com/mcp" }); expect(() => exchangeAuthorizationCode(env, { code, clientId: client.clientId, redirectUri: client.redirectUris[0], codeVerifier: "wrong".repeat(10) })).toThrow("OAUTH_PKCE_INVALID"); }); it("requires HTTPS for an enabled production MCP origin", () => { expect(() => loadEnv({ NODE_ENV: "production", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", PROJECTBRAIN_AUTH_SECRET: "production-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_MCP_ENABLED: "true", PROJECTBRAIN_PUBLIC_URL: "http://projectbrain.example.com", PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters" })).toThrow("must use HTTPS"); }); }); describe("Gitea administration authorization", () => { it("allows only the ProjectBrain owner role", () => { expect(hasGiteaAdminAccess("owner")).toBe(true); expect(hasGiteaAdminAccess("member")).toBe(false); expect(hasGiteaAdminAccess()).toBe(false); }); }); describe("ProjectBrain MCP Hub and Skills integration", () => { it("compares the Hub token safely and constrains the audit actor", () => { const secret = "projectbrain-hub-token-with-more-than-32-characters"; expect(isMcpHubServiceToken(secret, secret)).toBe(true); expect(isMcpHubServiceToken(secret, `${secret}x`)).toBe(false); expect(isMcpHubServiceToken(secret, "wrong-token-with-more-than-32-characters")).toBe(false); expect(mcpHubActor("chatgpt-web")).toBe("mcp-hub:chatgpt-web"); expect(mcpHubActor("../../spoofed actor")).toBe("mcp-hub:service"); }); it("advertises digest-verified native ProjectBrain skills", () => { const listed = handleProjectBrainSkillMethod("skills/list", {}) as { skills: typeof projectBrainSkills }; expect(listed.skills).toHaveLength(4); const selected = listed.skills[0]; expect(handleProjectBrainSkillMethod("skills/get", { uri: selected.uri })).toEqual({ skill: selected }); const resource = handleProjectBrainSkillMethod("resources/read", { uri: selected.uri }) as { contents: Array<{ text: string }> }; expect(resource.contents[0].text).toContain("# ProjectBrain operator"); const initialized = augmentProjectBrainInitialize({ result: { capabilities: {} } }) as { result: { capabilities: { extensions: Record } } }; expect(initialized.result.capabilities.extensions).toHaveProperty("io.modelcontextprotocol/skills"); }); it("serves every advertised ChatGPT UI template as a versioned MCP Apps resource", async () => { const server = createProjectBrainMcpServer(env, "template-contract-test"); const client = new Client({ name: "template-contract-test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await client.connect(clientTransport); const [listedTools, listedResources] = await Promise.all([client.listTools(), client.listResources()]); expect(listedTools.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([ "get_portfolio_control_plane", "list_portfolio_repositories", "reconcile_portfolio_repositories", "set_portfolio_repository_exclusion", "list_repository_findings", "get_repository_finding", "create_finding_issue", "start_finding_investigation", "get_finding_investigation", "save_finding_investigation", "create_delivery_from_finding", "verify_finding_resolution", "apply_feature_changeset", "merge_feature_delivery", ])); const changeset = listedTools.tools.find((tool) => tool.name === "apply_feature_changeset"); const merge = listedTools.tools.find((tool) => tool.name === "merge_feature_delivery"); expect(changeset?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: false }); expect(merge?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: true }); const advertisedUris = listedTools.tools.flatMap((tool) => { const meta = tool._meta as { ui?: { resourceUri?: unknown }; "openai/outputTemplate"?: unknown } | undefined; return [meta?.ui?.resourceUri, meta?.["openai/outputTemplate"]].filter((uri): uri is string => typeof uri === "string"); }); expect(advertisedUris).toEqual([PROJECTBRAIN_WORKSPACE_URI, PROJECTBRAIN_WORKSPACE_URI, PROJECTBRAIN_ANALYSIS_WIDGET_URI, PROJECTBRAIN_ANALYSIS_WIDGET_URI]); expect(listedResources.resources).toEqual(expect.arrayContaining([ expect.objectContaining({ uri: PROJECTBRAIN_WORKSPACE_URI, mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE }), expect.objectContaining({ uri: PROJECTBRAIN_ANALYSIS_WIDGET_URI, mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE }), ])); for (const uri of new Set(advertisedUris)) { const resource = await client.readResource({ uri }); expect(resource.contents).toHaveLength(1); expect(resource.contents[0]).toMatchObject({ uri, mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE }); expect("text" in resource.contents[0] ? resource.contents[0].text : "").toContain(""); if (uri === PROJECTBRAIN_ANALYSIS_WIDGET_URI) { const text = "text" in resource.contents[0] ? resource.contents[0].text : ""; expect(text).toContain("projectbrain.analysis.status"); expect(text).toContain("projectbrain.analysis.results"); } expect(resource.contents[0]._meta).toMatchObject({ ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } }, }); } } finally { await client.close(); await server.close(); } }); }); describe("ChatGPT feature delivery approval", () => { const completePlan = { problem: "Users cannot carry an idea through to a reviewed repository change.", desiredOutcome: "A persisted and review-gated feature delivery.", goals: ["Keep scope explicit"], acceptanceCriteria: ["Owner approval is required"], implementationPlan: ["Create an isolated branch"], validationCommands: ["pnpm test"], }; it("accepts complete plans and rejects incomplete approval contracts", () => { expect(assertFeatureDeliveryPlanComplete(completePlan)).toBeUndefined(); expect(() => assertFeatureDeliveryPlanComplete({ ...completePlan, acceptanceCriteria: [] })).toThrow("FEATURE_DELIVERY_PLAN_INCOMPLETE"); expect(() => assertFeatureDeliveryPlanComplete({ ...completePlan, validationCommands: [] })).toThrow("FEATURE_DELIVERY_PLAN_INCOMPLETE"); }); it("accepts only successful Gitea Actions evidence for the current branch head", () => { const evidence = { provider: "gitea_actions" as const, runId: 42, runNumber: 7, url: "https://gitea.example.com/runs/42", workflowPath: "ci.yml@refs/heads/chatgpt/feature", headSha: "current-sha", conclusion: "success" as const, verifiedAt: new Date().toISOString() }; expect(assertFeatureDeliveryActionEvidence(evidence, "current-sha")).toBeUndefined(); expect(() => assertFeatureDeliveryActionEvidence(evidence, "newer-sha")).toThrow("FEATURE_DELIVERY_CI_EVIDENCE_REQUIRED"); expect(() => assertFeatureDeliveryActionEvidence(undefined, "current-sha")).toThrow("FEATURE_DELIVERY_CI_EVIDENCE_REQUIRED"); }); it("refreshes exact-head validation evidence without closing an open pull request", () => { expect(canSyncFeatureDeliveryValidation("executing")).toBe(true); expect(canSyncFeatureDeliveryValidation("validating")).toBe(true); expect(canSyncFeatureDeliveryValidation("pull_request_open")).toBe(true); expect(canSyncFeatureDeliveryValidation("merged")).toBe(false); expect(statusAfterFeatureDeliveryValidationSync("executing")).toBe("validating"); expect(statusAfterFeatureDeliveryValidationSync("pull_request_open")).toBe("pull_request_open"); }); it("requires every Gitea Actions workflow for the exact branch head to succeed", () => { const run = (id: number, conclusion: string, headSha = "current-sha"): GiteaActionRun => ({ id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "push", head_sha: headSha, head_branch: "chatgpt/feature", status: "completed", conclusion, html_url: `https://gitea.example.com/runs/${id}`, }); expect(assertSuccessfulActionRuns([run(1, "success"), run(2, "success")], "chatgpt/feature", "current-sha")).toHaveLength(2); expect(() => assertSuccessfulActionRuns([run(1, "success"), run(2, "failure")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); expect(() => assertSuccessfulActionRuns([run(1, "success", "stale-sha")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); }); });