Files
projectbrain-Public/backend/test/gitea-integration.test.ts
T

520 lines
34 KiB
TypeScript

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<string, string>).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<GiteaApiError>({ 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": '<https://untrusted.invalid/steal>; 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<string, unknown>;
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<string, unknown> } } };
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("<!doctype html>");
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");
});
});