543 lines
36 KiB
TypeScript
543 lines
36 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.each([
|
|
["1.14.0", "Do"],
|
|
["1.14.7", "Do"],
|
|
["1.25.5", "Do"],
|
|
["1.26.4", "do"],
|
|
["1.27.3", "do"],
|
|
["28.0.0", "do"],
|
|
["28.1.3", "do"],
|
|
] as const)("uses the supported Gitea merge field for %s", async (version, expectedKey) => {
|
|
const bodies: Record<string, unknown>[] = [];
|
|
const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
if (String(input).endsWith("/version")) return new Response(JSON.stringify({ version }), { status: 200 });
|
|
bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
|
return 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);
|
|
expect(bodies[0]?.[expectedKey]).toBe("merge");
|
|
expect(bodies[0]?.[expectedKey === "Do" ? "do" : "Do"]).toBeUndefined();
|
|
});
|
|
|
|
it.each(["1.24.6", "29.0.0", "not-a-version", ""])("fails closed for unsupported Gitea merge version %s", async (version) => {
|
|
const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version")
|
|
? new Response(JSON.stringify({ version }), { 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) }))
|
|
.rejects.toMatchObject({ code: "GITEA_VERSION_UNSUPPORTED" });
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
});
|
|
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: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", event: "workflow_dispatch", 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_FULL_VALIDATION_REQUIRED");
|
|
expect(() => assertSuccessfulActionRuns([run(1, "success", "stale-sha")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
|
});
|
|
});
|