Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import type { GiteaActionJob } from "../src/integrations/gitea/types.js";
|
||||
import { compactGiteaRuntime, createGiteaActivityRuntimeLoader, repositoryFromActionJob } from "../src/services/activityRuntime.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_GITEA_URL: "https://gitea.example.com",
|
||||
PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters",
|
||||
});
|
||||
|
||||
const job = (input: Partial<GiteaActionJob> = {}): GiteaActionJob => ({
|
||||
id: 42,
|
||||
html_url: "https://gitea.example.com/Jens/projectbrain/actions/runs/21/jobs/42",
|
||||
run_id: 21,
|
||||
name: "quality",
|
||||
labels: ["ubuntu-latest"],
|
||||
run_attempt: 1,
|
||||
head_sha: "abc123",
|
||||
head_branch: "main",
|
||||
status: "in_progress",
|
||||
runner_id: 9,
|
||||
runner_name: "itworx-security-runner",
|
||||
steps: [
|
||||
{ name: "Checkout", number: 1, status: "completed", conclusion: "success" },
|
||||
{ name: "Test", number: 2, status: "in_progress" },
|
||||
],
|
||||
created_at: "2026-08-28T19:00:00Z",
|
||||
started_at: "2026-08-28T19:01:00Z",
|
||||
completed_at: "1970-01-01T00:00:00Z",
|
||||
...input,
|
||||
});
|
||||
|
||||
describe("compact activity runtime", () => {
|
||||
it("keeps older running jobs visible ahead of a full page of newer queued jobs", () => {
|
||||
const jobs = [job({ id: 1 }), job({ id: 2 }), ...Array.from({ length: 30 }, (_, i) => job({ id: i + 100, status: "queued" }))];
|
||||
const snapshot = compactGiteaRuntime([], jobs, "2026-09-10T00:00:00Z");
|
||||
expect(snapshot.jobs.slice(0, 12).slice(0, 2).map((item) => item.id)).toEqual([2, 1]);
|
||||
expect(snapshot.counts).toEqual({ running: 2, runnable: 30, dependencyBlocked: 0 });
|
||||
expect(snapshot.jobs).toHaveLength(32);
|
||||
});
|
||||
it("extracts a repository safely from a Gitea job URL", () => {
|
||||
expect(repositoryFromActionJob(job({ html_url: "https://gitea.example.com/Space%20Owner/Repo/actions/runs/1/jobs/2" }))).toBe("Space Owner/Repo");
|
||||
expect(repositoryFromActionJob(job({ html_url: "not-a-url" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns only compact runner and current-step data", () => {
|
||||
const snapshot = compactGiteaRuntime([
|
||||
{ id: 9, name: "runner", status: "online", busy: true, disabled: false, ephemeral: false, labels: [{ id: 1, name: "ubuntu-latest", type: "custom" }] },
|
||||
], [job()], "2026-08-28T19:02:00Z");
|
||||
|
||||
expect(snapshot.runners[0]).toEqual({ id: 9, name: "runner", status: "online", busy: true, disabled: false, labels: ["ubuntu-latest"] });
|
||||
expect(snapshot.jobs[0]).toMatchObject({ repository: "Jens/projectbrain", currentStep: "Test", queueState: "running", completedSteps: 1, totalSteps: 2 });
|
||||
expect(snapshot.jobs[0].completedAt).toBeUndefined();
|
||||
expect(JSON.stringify(snapshot)).not.toContain("head_sha");
|
||||
expect(compactGiteaRuntime([], [job()], "2026-08-28T19:03:00Z").stateVersion)
|
||||
.toBe(compactGiteaRuntime([], [job()], "2026-08-28T19:04:00Z").stateVersion);
|
||||
});
|
||||
|
||||
it("derives a busy runner from an active job when user-level runner discovery omits it", () => {
|
||||
const snapshot = compactGiteaRuntime([], [job()], "2026-08-28T19:02:00Z");
|
||||
expect(snapshot.runners).toEqual([{ id: 9, name: "itworx-security-runner", status: "observed", busy: true, disabled: false, labels: ["ubuntu-latest"] }]);
|
||||
});
|
||||
|
||||
it("does not present dependency-blocked jobs as runnable work", () => {
|
||||
const snapshot = compactGiteaRuntime([], [job({ status: "waiting", runner_id: 0, runner_name: undefined })], "2026-08-28T19:02:00Z");
|
||||
expect(snapshot.jobs[0].queueState).toBe("dependency-blocked");
|
||||
expect(snapshot.counts).toEqual({ running: 0, runnable: 0, dependencyBlocked: 1 });
|
||||
});
|
||||
|
||||
it("keeps every active job visible and bounds only completed history", () => {
|
||||
const completed = Array.from({ length: 35 }, (_, index) => job({ id: 100 + index, status: "completed", conclusion: "success" }));
|
||||
const active = Array.from({ length: 38 }, (_, index) => job({ id: 10 + index, status: index < 3 ? "in_progress" : "queued", runner_id: index < 3 ? 9 : 0, runner_name: index < 3 ? "runner" : undefined }));
|
||||
const snapshot = compactGiteaRuntime([], [...completed, ...active], "2026-08-28T19:02:00Z");
|
||||
expect(snapshot.jobs).toHaveLength(48);
|
||||
expect(snapshot.jobs.filter((item) => item.queueState !== "completed")).toHaveLength(38);
|
||||
expect(snapshot.jobs.filter((item) => item.queueState === "completed")).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("coalesces simultaneous refreshes and serves a stale snapshot after a transient failure", async () => {
|
||||
let clock = new Date("2026-08-28T19:00:00Z");
|
||||
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [
|
||||
{ id: 9, name: "runner", status: "online", busy: true, disabled: false, ephemeral: false, labels: [] },
|
||||
] });
|
||||
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "in_progress"
|
||||
? { total_count: 1, jobs: [job()] }
|
||||
: input.status === undefined ? { total_count: 1, jobs: [job()] } : { total_count: 0, jobs: [] });
|
||||
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock });
|
||||
|
||||
const [first, simultaneous] = await Promise.all([load(), load()]);
|
||||
expect(first.stateVersion).toBe(simultaneous.stateVersion);
|
||||
expect(listUserActionRunners).toHaveBeenCalledTimes(1);
|
||||
expect(listUserActionJobs).toHaveBeenCalledTimes(4);
|
||||
expect(first.counts).toEqual({ running: 1, runnable: 0, dependencyBlocked: 0 });
|
||||
|
||||
clock = new Date("2026-08-28T19:00:16Z");
|
||||
listUserActionRunners.mockRejectedValue(new Error("temporary upstream failure"));
|
||||
listUserActionJobs.mockRejectedValue(new Error("temporary upstream failure"));
|
||||
const stale = await load();
|
||||
expect(stale).toMatchObject({ availability: "stale", warning: "temporary upstream failure" });
|
||||
expect(stale.jobs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a live snapshot and derives counts when one filtered query fails", async () => {
|
||||
const waitingJob = job({ id: 43, status: "waiting", runner_id: 0, runner_name: undefined });
|
||||
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] });
|
||||
const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => {
|
||||
if (input.status === "pending") throw new Error("unsupported status filter");
|
||||
if (input.status === undefined) return { total_count: 1, jobs: [waitingJob] };
|
||||
return { total_count: 0, jobs: [] };
|
||||
});
|
||||
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } });
|
||||
|
||||
const snapshot = await load();
|
||||
expect(snapshot.availability).toBe("live");
|
||||
expect(snapshot.warning).toContain("Partial Gitea monitoring failure");
|
||||
expect(snapshot.counts).toEqual({ running: 0, runnable: 0, dependencyBlocked: 1 });
|
||||
expect(snapshot.jobs[0].queueState).toBe("dependency-blocked");
|
||||
});
|
||||
|
||||
it("uses Gitea's documented pending filter for dependency-blocked jobs", async () => {
|
||||
const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] });
|
||||
const listUserActionJobs = vi.fn().mockResolvedValue({ total_count: 0, jobs: [] });
|
||||
const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } });
|
||||
|
||||
await load();
|
||||
|
||||
expect(listUserActionJobs).toHaveBeenCalledWith({ status: "pending", limit: 50 });
|
||||
expect(listUserActionJobs).not.toHaveBeenCalledWith({ status: "waiting", limit: 50 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { createPortfolioAnalysisJob } from "../src/repositories/analysisJobRepository.js";
|
||||
import { refreshPortfolioCatalog } from "../src/services/portfolioControlPlane.js";
|
||||
import { registerAnalysisJobRoutes } from "../src/routes/analysisJobs.js";
|
||||
import Fastify from "fastify";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
inventory: vi.fn(), links: vi.fn(), findJob: vi.fn(), findFirstJob: vi.fn(), createJob: vi.fn(),
|
||||
catalog: vi.fn(), upsert: vi.fn(), unavailable: vi.fn(),
|
||||
}));
|
||||
vi.mock("../src/integrations/gitea/service.js", () => ({
|
||||
getGiteaClient: () => ({ listRepositories: mocks.inventory }),
|
||||
listLinkedRepositories: mocks.links,
|
||||
}));
|
||||
vi.mock("../src/db/prisma.js", () => ({ prisma: {
|
||||
analysisJob: { findUnique: mocks.findJob, findFirst: mocks.findFirstJob, create: mocks.createJob },
|
||||
portfolioRepository: { findMany: mocks.catalog, upsert: mocks.upsert, updateMany: mocks.unavailable },
|
||||
} }));
|
||||
|
||||
const env = loadEnv({ NODE_ENV: "test", PROJECTBRAIN_DATA_SOURCE: "database",
|
||||
DATABASE_URL: "postgresql://fixture:fixture@localhost:5432/fixture",
|
||||
PROJECTBRAIN_OWNER_USERNAME: "fixture-owner", PROJECTBRAIN_OWNER_PASSWORD: "fixture-only-password",
|
||||
PROJECTBRAIN_AUTH_SECRET: "fixture-only-auth-secret-at-least-32-characters" });
|
||||
const repositories = (count: number) => Array.from({ length: count }, (_, index) => ({
|
||||
id: index + 1, name: `repo-${index}`, full_name: `Owner/repo-${index}`,
|
||||
owner: { login: "Owner" }, default_branch: "main", permissions: { pull: true },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.links.mockResolvedValue([]);
|
||||
mocks.catalog.mockResolvedValue([]);
|
||||
mocks.findJob.mockResolvedValue(null);
|
||||
mocks.findFirstJob.mockResolvedValue(null);
|
||||
mocks.createJob.mockImplementation(async ({ data }) => ({
|
||||
id: "fixture-job", status: "queued", progressTotal: data.progressTotal, progressCurrent: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
describe("bounded complete portfolio analysis", () => {
|
||||
it.each([58, 200, 201])("enforces the same %i-selection boundary at the project HTTP route", async (count) => {
|
||||
mocks.links.mockResolvedValue(repositories(count).map((repository) => ({
|
||||
id: String(repository.id), projectId: "fixture-project", projectSlug: "fixture-project",
|
||||
projectName: "Fixture", owner: "Owner", repository: repository.name,
|
||||
fullName: repository.full_name, defaultBranch: "main", accessMode: "read_only",
|
||||
})));
|
||||
const app = Fastify();
|
||||
app.decorateRequest("authUser", null);
|
||||
app.addHook("onRequest", async (request) => {
|
||||
request.authUser = { id: "fixture-owner", username: "fixture-owner", role: "owner", credentialVersion: 1 };
|
||||
});
|
||||
await registerAnalysisJobRoutes(app, { env });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/projects/fixture-project/analysis-jobs",
|
||||
payload: { repositoryIds: Array.from({ length: count }, (_, index) => String(index + 1)) } });
|
||||
if (count <= 200) {
|
||||
expect(response.statusCode).toBe(202);
|
||||
expect(response.json().data.job.progressTotal).toBe(count);
|
||||
expect(mocks.createJob.mock.calls[0][0].data.items.create).toHaveLength(count);
|
||||
} else {
|
||||
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(mocks.createJob).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
it.each([51, 58, 200])("persists all %i targets without truncation", async (count) => {
|
||||
mocks.inventory.mockResolvedValue(repositories(count));
|
||||
const result = await createPortfolioAnalysisJob(env, "fixture-actor", {});
|
||||
expect(result.job.progressTotal).toBe(count);
|
||||
const data = mocks.createJob.mock.calls[0][0].data;
|
||||
expect(data.items.create).toHaveLength(count);
|
||||
expect(data.requestJson.repositoryIds).toHaveLength(count);
|
||||
expect(new Set(data.items.create.map((item: { sequence: number }) => item.sequence)).size).toBe(count);
|
||||
});
|
||||
|
||||
it("rejects 201 targets before job persistence", async () => {
|
||||
mocks.inventory.mockResolvedValue(repositories(201));
|
||||
await expect(createPortfolioAnalysisJob(env, "fixture-actor", {})).rejects.toThrow("ANALYSIS_REPOSITORY_LIMIT_EXCEEDED");
|
||||
expect(mocks.createJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist a job or mark cached repositories unavailable after incomplete inventory", async () => {
|
||||
mocks.inventory.mockRejectedValue(new Error("GITEA_INVENTORY_LIMIT_EXCEEDED"));
|
||||
await expect(createPortfolioAnalysisJob(env, "fixture-actor", {})).rejects.toThrow("GITEA_INVENTORY_LIMIT_EXCEEDED");
|
||||
await expect(refreshPortfolioCatalog(env)).rejects.toThrow("GITEA_INVENTORY_LIMIT_EXCEEDED");
|
||||
expect(mocks.createJob).not.toHaveBeenCalled();
|
||||
expect(mocks.upsert).not.toHaveBeenCalled();
|
||||
expect(mocks.unavailable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chooseAnalysisStrategy, findingFingerprint, groupFindingRootCauses } from "../src/services/analysisStrategy.js";
|
||||
|
||||
describe("analysis strategy", () => {
|
||||
it("prioritizes changed-area analysis inside the budget", () => {
|
||||
expect(chooseAnalysisStrategy({ changedPaths: ["src/a.ts", "src/b.ts"], now: 100, lastFullScanAt: new Date(0).toISOString() })).toMatchObject({ strategy: "diff", estimatedScope: "changed-area" });
|
||||
});
|
||||
|
||||
it("uses targeted analysis while a full scan is fresh", () => {
|
||||
expect(chooseAnalysisStrategy({ now: 100_000, lastFullScanAt: new Date(99_000).toISOString(), activeFindingCount: 2, staleAfterMs: 10_000 })).toMatchObject({ strategy: "targeted" });
|
||||
expect(chooseAnalysisStrategy({ now: 100_000, lastFullScanAt: new Date(0).toISOString(), staleAfterMs: 10_000 }).strategy).toBe("full");
|
||||
});
|
||||
|
||||
it("normalizes fingerprints and groups repeated root causes", () => {
|
||||
expect(findingFingerprint({ ruleCode: "CI_FAILED", path: "src\\a123.ts", subject: "run 42" })).toBe("ci_failed|src/a#.ts|run #");
|
||||
expect(groupFindingRootCauses([{ fingerprint: "a", ruleCode: "CI", subject: "workflow", rootCause: "runner" }, { fingerprint: "b", ruleCode: "CI", subject: "workflow", rootCause: "runner" }])).toEqual([{ rootCause: "runner", fingerprints: ["a", "b"] }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { canAccessProject } from "../src/security/authStore.js";
|
||||
import { parseAutonomyClaimInput } from "../src/routes/autonomy.js";
|
||||
import { isOwnerRequest, parseWorkGraphLeaseInput } from "../src/routes/operatingSystem.js";
|
||||
import { hashPassword, verifyPassword } from "../src/security/password.js";
|
||||
import { apiPaths, authSessionDataSchema, successEnvelopeSchema } from "@projectbrain/contracts";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
const ownerPassword = "correct-horse-battery-staple";
|
||||
const env = () => loadEnv({
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PROJECTBRAIN_DATA_SOURCE: "fixture",
|
||||
PROJECTBRAIN_OWNER_USERNAME: "owner",
|
||||
PROJECTBRAIN_OWNER_PASSWORD: ownerPassword,
|
||||
PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters",
|
||||
PROJECTBRAIN_SESSION_TTL_HOURS: "1",
|
||||
PROJECTBRAIN_TRUST_PROXY: "false",
|
||||
});
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
const makeApp = async () => {
|
||||
const app = await buildApp({ env: env() });
|
||||
apps.push(app);
|
||||
return app;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
const login = async (app: Awaited<ReturnType<typeof buildApp>>) => {
|
||||
const response = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "owner", password: ownerPassword } });
|
||||
const cookie = response.cookies.find((item) => item.name === "projectbrain_session");
|
||||
return { response, cookie: cookie ? `${cookie.name}=${cookie.value}` : "", csrfToken: response.json().data?.csrfToken as string };
|
||||
};
|
||||
|
||||
describe("authentication boundary", () => {
|
||||
it("rejects known insecure production bootstrap credentials", () => {
|
||||
expect(() => loadEnv({ NODE_ENV: "production", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "fixture", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "adminadminadmin", PROJECTBRAIN_AUTH_SECRET: "production-secret-with-at-least-thirty-two-characters" })).toThrow(/insecure default/);
|
||||
});
|
||||
|
||||
it("requires a complete, database-backed Entra configuration", () => {
|
||||
const base = { NODE_ENV: "test", PROJECTBRAIN_DATA_SOURCE: "fixture", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters" };
|
||||
expect(() => loadEnv({ ...base, PROJECTBRAIN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111" })).toThrow(/must be configured together/);
|
||||
expect(() => loadEnv({ ...base, PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", PROJECTBRAIN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111", PROJECTBRAIN_ENTRA_CLIENT_ID: "22222222-2222-4222-8222-222222222222", PROJECTBRAIN_ENTRA_CLIENT_SECRET: "a-test-secret-value" })).toThrow(/database/);
|
||||
const blank = loadEnv({ ...base, PROJECTBRAIN_ENTRA_TENANT_ID: "", PROJECTBRAIN_ENTRA_CLIENT_ID: "", PROJECTBRAIN_ENTRA_CLIENT_SECRET: "" });
|
||||
expect(blank.PROJECTBRAIN_ENTRA_TENANT_ID).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks the production session cookie Secure", async () => {
|
||||
const productionEnv = loadEnv({ NODE_ENV: "production", DATABASE_URL: "postgresql://example.invalid/projectbrain", LOG_LEVEL: "silent", PROJECTBRAIN_DATA_SOURCE: "fixture", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, PROJECTBRAIN_AUTH_SECRET: "production-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_SESSION_TTL_HOURS: "1", PROJECTBRAIN_TRUST_PROXY: "false" });
|
||||
const app = await buildApp({ env: productionEnv });
|
||||
apps.push(app);
|
||||
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: ownerPassword } });
|
||||
expect(response.headers["set-cookie"]).toContain("Secure");
|
||||
}, 10_000);
|
||||
|
||||
it("hashes passwords with a random salt and rejects malformed hashes", async () => {
|
||||
const first = await hashPassword(ownerPassword);
|
||||
const second = await hashPassword(ownerPassword);
|
||||
expect(first).not.toBe(second);
|
||||
expect(await verifyPassword(ownerPassword, first)).toBe(true);
|
||||
expect(await verifyPassword("incorrect-password-value", first)).toBe(false);
|
||||
expect(await verifyPassword(ownerPassword, "not-a-supported-hash")).toBe(false);
|
||||
});
|
||||
|
||||
it("denies fixture project access to a non-owner identity", async () => {
|
||||
expect(await canAccessProject(env(), { id: "member-1", username: "member", role: "member", credentialVersion: 1 }, "widefrog", false)).toBe(false);
|
||||
});
|
||||
it("keeps portfolio-wide operating-system access owner-only", () => {
|
||||
expect(isOwnerRequest({ authUser: { id: "owner-1", username: "owner", role: "owner", credentialVersion: 1 } } as never)).toBe(true);
|
||||
expect(isOwnerRequest({ authUser: { id: "member-1", username: "member", role: "member", credentialVersion: 1 } } as never)).toBe(false);
|
||||
});
|
||||
it("keeps only deliberate health, readiness and login bootstrap routes public", async () => {
|
||||
const app = await makeApp();
|
||||
expect((await app.inject({ method: "GET", url: "/api/health" })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: "GET", url: "/api/system/readiness" })).statusCode).toBe(200);
|
||||
const metrics = await app.inject({ method: "GET", url: "/api/metrics" });
|
||||
expect(metrics.statusCode).toBe(200);
|
||||
expect(metrics.body).toContain("projectbrain_http_requests_total");
|
||||
expect(metrics.body).toContain("projectbrain_database_ready 1");
|
||||
const entraConfig = await app.inject({ method: "GET", url: apiPaths.auth.entraConfig });
|
||||
expect(entraConfig.statusCode).toBe(200);
|
||||
expect(entraConfig.json().data).toEqual({ enabled: false, provider: "microsoft-entra" });
|
||||
expect((await app.inject({ method: "GET", url: apiPaths.auth.entraStart })).statusCode).toBe(503);
|
||||
expect((await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart })).statusCode).toBe(401);
|
||||
const protectedResponse = await app.inject({ method: "GET", url: "/api/projects" });
|
||||
expect(protectedResponse.statusCode).toBe(401);
|
||||
expect(protectedResponse.json().error.code).toBe("AUTHENTICATION_REQUIRED");
|
||||
});
|
||||
|
||||
it("rejects unsafe incoming correlation IDs and emits a safe replacement", async () => {
|
||||
const app = await makeApp();
|
||||
const response = await app.inject({ method: "GET", url: "/api/health", headers: { "x-request-id": "unsafe\nrequest" } });
|
||||
expect(response.headers["x-request-id"]).toMatch(/^req-[0-9a-f-]+$/);
|
||||
});
|
||||
|
||||
it("redacts credentials and project content from standard logs", async () => {
|
||||
const chunks: string[] = [];
|
||||
const logStream = new PassThrough();
|
||||
logStream.on("data", (chunk) => chunks.push(chunk.toString()));
|
||||
const loggedEnv = { ...env(), LOG_LEVEL: "info" as const };
|
||||
const app = await buildApp({ env: loggedEnv, logStream });
|
||||
apps.push(app);
|
||||
await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "owner", password: "secret-project-content" } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const output = chunks.join("");
|
||||
expect(output).not.toContain("secret-project-content");
|
||||
expect(output).not.toContain(ownerPassword);
|
||||
});
|
||||
|
||||
it("creates a secure server-side session and exposes no password", async () => {
|
||||
const app = await makeApp();
|
||||
const { response, cookie, csrfToken } = await login(app);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(cookie).toContain("projectbrain_session=");
|
||||
expect(response.headers["set-cookie"]).toContain("HttpOnly");
|
||||
expect(response.headers["set-cookie"]).toContain("SameSite=Lax");
|
||||
expect(csrfToken.length).toBeGreaterThan(20);
|
||||
expect(JSON.stringify(response.json())).not.toContain(ownerPassword);
|
||||
expect(successEnvelopeSchema(authSessionDataSchema).safeParse(response.json()).success).toBe(true);
|
||||
const session = await app.inject({ method: "GET", url: apiPaths.auth.session, headers: { cookie } });
|
||||
expect(session.statusCode).toBe(200);
|
||||
expect(session.headers["set-cookie"]).toContain("Max-Age=3600");
|
||||
expect(session.json().data.user.username).toBe("owner");
|
||||
expect(successEnvelopeSchema(authSessionDataSchema).safeParse(session.json()).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid credentials and enforces login rate limiting", async () => {
|
||||
const app = await makeApp();
|
||||
const attempts = [];
|
||||
for (let index = 0; index < 6; index += 1) attempts.push(await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: "incorrect-password-value" } }));
|
||||
expect(attempts[0].statusCode).toBe(401);
|
||||
expect(attempts[5].statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it("rejects malformed login and an incorrect current password", async () => {
|
||||
const app = await makeApp();
|
||||
expect((await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "short" } })).statusCode).toBe(400);
|
||||
const { cookie, csrfToken } = await login(app);
|
||||
const response = await app.inject({ method: "POST", url: "/api/auth/rotate-password", headers: { cookie, "x-csrf-token": csrfToken }, payload: { currentPassword: "incorrect-password-value", newPassword: "a-different-secure-owner-password" } });
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("requires CSRF for mutations and accepts the session token", async () => {
|
||||
const app = await makeApp();
|
||||
const { cookie, csrfToken } = await login(app);
|
||||
const missing = await app.inject({ method: "POST", url: "/api/projects", headers: { cookie }, payload: { name: "Secure Project", description: "A securely created project." } });
|
||||
expect(missing.statusCode).toBe(403);
|
||||
expect(missing.json().error.code).toBe("CSRF_TOKEN_INVALID");
|
||||
const accepted = await app.inject({ method: "POST", url: "/api/projects", headers: { cookie, "x-csrf-token": csrfToken }, payload: { name: "Secure Project", description: "A securely created project." } });
|
||||
expect(accepted.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("does not accept a caller-supplied actor for durable work-graph commands", async () => {
|
||||
expect(parseWorkGraphLeaseInput({ actor: "system:owner", leaseMinutes: 10 })).toEqual({ leaseMinutes: 10 });
|
||||
expect(parseAutonomyClaimInput({ actor: "system:owner", runId: "scheduled-run" })).toEqual({ runId: "scheduled-run" });
|
||||
});
|
||||
|
||||
it("revokes a session on logout", async () => {
|
||||
const app = await makeApp();
|
||||
const { cookie, csrfToken } = await login(app);
|
||||
expect((await app.inject({ method: "POST", url: "/api/auth/logout", headers: { cookie, "x-csrf-token": csrfToken } })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: "GET", url: "/api/projects", headers: { cookie } })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rotates credentials and revokes every existing session", async () => {
|
||||
const app = await makeApp();
|
||||
const { cookie, csrfToken } = await login(app);
|
||||
const rotated = await app.inject({ method: "POST", url: "/api/auth/rotate-password", headers: { cookie, "x-csrf-token": csrfToken }, payload: { currentPassword: ownerPassword, newPassword: "a-different-secure-owner-password" } });
|
||||
expect(rotated.statusCode).toBe(200);
|
||||
expect((await app.inject({ method: "GET", url: "/api/projects", headers: { cookie } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: ownerPassword } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: "a-different-secure-owner-password" } })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("emits baseline security headers", async () => {
|
||||
const app = await makeApp();
|
||||
const response = await app.inject({ method: "GET", url: "/api/health" });
|
||||
expect(response.headers["content-security-policy"]).toContain("default-src 'self'");
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
||||
expect(response.headers["x-frame-options"]).toBe("SAMEORIGIN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { compareAutoModeProjectRows, createProjectBrainMcpServer } from "../src/mcp/autonomyServer.js";
|
||||
import { autonomyRetryDelayMinutes, canResumeAutonomyLease, classifyAutonomyBlocker, enqueueAutonomyTaskInState, evaluateAutonomyRunBudget, isAutonomyLeaseOwner, normalizeAutonomyState, parseRoadmapMarkdown, releaseActiveAutonomyLease, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js";
|
||||
import { autonomyHeartbeatSlotId, compactAutonomyState, resolveAutonomyHeartbeat, summarizeAutonomyProject } from "../src/services/autonomyRuntime.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("Auto Mode roadmap parser", () => {
|
||||
it("turns markdown phases and list items into ordered durable work", () => {
|
||||
const roadmap = parseRoadmapMarkdown(`# Product roadmap
|
||||
|
||||
## Fundament
|
||||
Maak de deliverylaag hervatbaar.
|
||||
- [x] Bestaande status importeren
|
||||
- [ ] Leasecontract toevoegen
|
||||
|
||||
## Product
|
||||
Voltooi de gebruikersflow.
|
||||
1. Auto toggle bouwen
|
||||
2. Voortgang tonen`);
|
||||
|
||||
expect(roadmap.title).toBe("Product roadmap");
|
||||
expect(roadmap.phases).toHaveLength(2);
|
||||
expect(roadmap.phases[0]).toMatchObject({ title: "Fundament", objective: "Maak de deliverylaag hervatbaar.", status: "active" });
|
||||
expect(roadmap.phases[0].items.map((item) => [item.title, item.status])).toEqual([
|
||||
["Bestaande status importeren", "completed"],
|
||||
["Leasecontract toevoegen", "pending"],
|
||||
]);
|
||||
expect(roadmap.phases[1].status).toBe("pending");
|
||||
expect(roadmap.phases[1].items.map((item) => item.title)).toEqual(["Auto toggle bouwen", "Voortgang tonen"]);
|
||||
});
|
||||
|
||||
it("accepts a flat task list by creating one execution phase", () => {
|
||||
const roadmap = parseRoadmapMarkdown("- Analyseer repo\n- Bouw oplossing\n- Valideer resultaat", "MCP automation");
|
||||
|
||||
expect(roadmap.title).toBe("MCP automation");
|
||||
expect(roadmap.phases).toHaveLength(1);
|
||||
expect(roadmap.phases[0].title).toBe("Execution");
|
||||
expect(roadmap.phases[0].items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("allows an empty phase so the brainstorm loop can discover its work", () => {
|
||||
const roadmap = parseRoadmapMarkdown("# Roadmap\n\n## Quality convergence\nZoek autonoom naar resterende gaten.");
|
||||
|
||||
expect(roadmap.phases[0].items).toEqual([]);
|
||||
expect(roadmap.phases[0].brainstormConverged).toBe(false);
|
||||
expect(roadmap.phases[0].objective).toBe("Zoek autonoom naar resterende gaten.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto Mode state safety", () => {
|
||||
it("keeps an hourly ChatGPT Chat executor fresh between scheduled runs", () => {
|
||||
expect(env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS).toBe(5_400);
|
||||
});
|
||||
|
||||
it("stores a user assignment durably even while Auto Mode is off", () => {
|
||||
const current = normalizeAutonomyState({}, "owner");
|
||||
const queued = enqueueAutonomyTaskInState(current, { instruction: "Verbeter de mobiele navigatie\nmet regressietests." }, "owner");
|
||||
|
||||
expect(queued.state.status).toBe("off");
|
||||
expect(queued.task).toMatchObject({
|
||||
title: "Verbeter de mobiele navigatie",
|
||||
instruction: "Verbeter de mobiele navigatie\nmet regressietests.",
|
||||
source: "user_assignment",
|
||||
status: "pending",
|
||||
});
|
||||
expect(queued.state.roadmap?.phases[0].items).toEqual([queued.task]);
|
||||
expect(queued.state.events.at(-1)?.type).toBe("autonomy.task.enqueued");
|
||||
});
|
||||
|
||||
it("prioritizes a new user assignment in the current claimable phase instead of a phase already passed", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Eerder\n- [ ] Eerste opdracht\n\n## Huidig\n- [ ] Lopend werk");
|
||||
roadmap.phases[0].id = "projectbrain-opdrachten-old";
|
||||
roadmap.phases[0].status = "completed";
|
||||
roadmap.phases[1].status = "active";
|
||||
roadmap.currentPhaseIndex = 1;
|
||||
const current = normalizeAutonomyState({ enabled: true, status: "running", roadmap }, "owner");
|
||||
|
||||
const queued = enqueueAutonomyTaskInState(current, { instruction: "Nieuwe opdracht" }, "owner");
|
||||
|
||||
expect(queued.state.status).toBe("running");
|
||||
expect(queued.state.roadmap?.phases).toHaveLength(2);
|
||||
expect(queued.state.roadmap?.phases[0].status).toBe("completed");
|
||||
expect(queued.state.roadmap?.phases[1].items[0]).toEqual(queued.task);
|
||||
expect(queued.state.roadmap?.phases[1].items[1].title).toBe("Lopend werk");
|
||||
});
|
||||
|
||||
it("derives the public status from durable facts instead of stale stored copy", () => {
|
||||
expect(normalizeAutonomyState({ enabled: true, status: "running" }).status).toBe("waiting_roadmap");
|
||||
expect(normalizeAutonomyState({}).profile).toBe("normal");
|
||||
expect(normalizeAutonomyState({ profile: "unsupported" }).profile).toBe("normal");
|
||||
const disabled = normalizeAutonomyState({ enabled: false, status: "running", activeLease: { token: "old", kind: "implement", phaseId: "phase" } });
|
||||
expect(disabled.status).toBe("off");
|
||||
expect(disabled.activeLease).toBeUndefined();
|
||||
});
|
||||
|
||||
it("releases in-progress work safely when Auto Mode is disabled", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk");
|
||||
roadmap.phases[0].status = "executing";
|
||||
roadmap.phases[0].items[0].status = "in_progress";
|
||||
const released = releaseActiveAutonomyLease({
|
||||
version: 1,
|
||||
profile: "normal",
|
||||
enabled: true,
|
||||
status: "running",
|
||||
autoBrainstorm: true,
|
||||
maxBrainstormIterations: 5,
|
||||
maxConsecutiveFailures: 3,
|
||||
leaseMinutes: 30,
|
||||
maxWorkItemsPerRun: 8,
|
||||
maxRunMinutes: 45,
|
||||
runs: [],
|
||||
roadmap,
|
||||
activeLease: { token: "lease", kind: "implement", actor: "codex", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() },
|
||||
completedLeases: [],
|
||||
events: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: "owner",
|
||||
}, "owner");
|
||||
|
||||
expect(released.activeLease).toBeUndefined();
|
||||
expect(released.roadmap?.phases[0]).toMatchObject({ status: "active", items: [expect.objectContaining({ status: "pending" })] });
|
||||
expect(released.events.at(-1)?.type).toBe("autonomy.lease.released");
|
||||
});
|
||||
|
||||
it("separates claimable work from a fresh or expired executor lease", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk");
|
||||
const base = normalizeAutonomyState({ enabled: true, roadmap });
|
||||
const fresh = summarizeAutonomyProject({
|
||||
projectId: "project-id", projectSlug: "project", projectName: "Project",
|
||||
autonomy: { ...base, activeLease: { token: "lease", kind: "implement", actor: "executor", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: "2026-09-02T10:00:00.000Z", expiresAt: "2026-09-02T10:30:00.000Z" } },
|
||||
}, Date.parse("2026-09-02T10:15:00.000Z"));
|
||||
const expired = summarizeAutonomyProject({
|
||||
projectId: "project-id", projectSlug: "project", projectName: "Project",
|
||||
autonomy: { ...base, activeLease: { token: "lease", kind: "implement", actor: "executor", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: "2026-09-02T10:00:00.000Z", expiresAt: "2026-09-02T10:30:00.000Z" } },
|
||||
}, Date.parse("2026-09-02T10:31:00.000Z"));
|
||||
|
||||
expect(fresh).toMatchObject({ activeWorkKind: "implement", pendingWorkCount: 1, lifecycle: "running" });
|
||||
expect(expired.activeWorkKind).toBeUndefined();
|
||||
expect(expired.lifecycle).toBe("ready");
|
||||
});
|
||||
|
||||
it("defaults and bounds the continuous executor run budget", () => {
|
||||
expect(normalizeAutonomyState({})).toMatchObject({ maxWorkItemsPerRun: 8, maxRunMinutes: 45 });
|
||||
expect(normalizeAutonomyState({ maxWorkItemsPerRun: 100, maxRunMinutes: 2 })).toMatchObject({ maxWorkItemsPerRun: 20, maxRunMinutes: 5 });
|
||||
});
|
||||
|
||||
it("only lets the owning scheduled run resume a live lease", () => {
|
||||
const lease = {
|
||||
token: "lease",
|
||||
kind: "implement" as const,
|
||||
actor: "chatgpt-web",
|
||||
runId: "run-1",
|
||||
roadmapId: "roadmap",
|
||||
phaseId: "phase",
|
||||
createdAt: "2026-09-02T10:00:00.000Z",
|
||||
expiresAt: "2026-09-02T10:30:00.000Z",
|
||||
};
|
||||
|
||||
expect(canResumeAutonomyLease(lease, "chatgpt-web", "run-1")).toBe(true);
|
||||
expect(canResumeAutonomyLease(lease, "chatgpt-web", "run-2")).toBe(false);
|
||||
expect(canResumeAutonomyLease(lease, "another-executor", "run-1")).toBe(false);
|
||||
expect(canResumeAutonomyLease(lease, "chatgpt-web")).toBe(false);
|
||||
expect(isAutonomyLeaseOwner(lease, "chatgpt-web")).toBe(true);
|
||||
expect(isAutonomyLeaseOwner(lease, "another-executor")).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces item and elapsed-time budgets from durable run state", () => {
|
||||
const base = normalizeAutonomyState({
|
||||
maxWorkItemsPerRun: 2,
|
||||
maxRunMinutes: 10,
|
||||
runs: [{ id: "item-run", actor: "chatgpt-web", startedAt: "2026-09-03T10:00:00.000Z", reportedWorkItems: 2 }],
|
||||
});
|
||||
expect(evaluateAutonomyRunBudget(base, "chatgpt-web", "item-run", Date.parse("2026-09-03T10:01:00.000Z"))).toMatchObject({ allowed: false, reason: "item_limit" });
|
||||
|
||||
const timed = normalizeAutonomyState({
|
||||
maxWorkItemsPerRun: 8,
|
||||
maxRunMinutes: 10,
|
||||
runs: [{ id: "time-run", actor: "chatgpt-web", startedAt: "2026-09-03T10:00:00.000Z", reportedWorkItems: 1 }],
|
||||
});
|
||||
expect(evaluateAutonomyRunBudget(timed, "chatgpt-web", "time-run", Date.parse("2026-09-03T10:10:00.000Z"))).toMatchObject({ allowed: false, reason: "time_limit" });
|
||||
expect(evaluateAutonomyRunBudget(timed, "other-actor", "time-run", Date.parse("2026-09-03T10:01:00.000Z"))).toMatchObject({ allowed: false, reason: "owner_mismatch" });
|
||||
});
|
||||
|
||||
it("classifies technical failures as retryable and reserves human blockers for real owner input", () => {
|
||||
expect(classifyAutonomyBlocker("Untrusted non-HTTPS Gitea server URL")).toBe("retryable");
|
||||
expect(classifyAutonomyBlocker("Runner unavailable during validation")).toBe("retryable");
|
||||
expect(classifyAutonomyBlocker("Missing credential required for production deployment")).toBe("human");
|
||||
expect(classifyAutonomyBlocker("Owner decision required between two product directions")).toBe("human");
|
||||
expect(classifyAutonomyBlocker("Temporary provider failure", "human")).toBe("retryable");
|
||||
});
|
||||
|
||||
it("backs off repeated technical failures without requiring a human decision", () => {
|
||||
expect(autonomyRetryDelayMinutes(1)).toBe(15);
|
||||
expect(autonomyRetryDelayMinutes(2)).toBe(30);
|
||||
expect(autonomyRetryDelayMinutes(3)).toBe(60);
|
||||
expect(autonomyRetryDelayMinutes(20)).toBeLessThanOrEqual(1_440);
|
||||
});
|
||||
|
||||
it("makes a due retryable blocker claimable without clearing failure counters", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk");
|
||||
roadmap.status = "blocked";
|
||||
roadmap.phases[0].status = "blocked";
|
||||
roadmap.phases[0].items[0].status = "blocked";
|
||||
roadmap.phases[0].items[0].attempts = 2;
|
||||
const blocked = normalizeAutonomyState({
|
||||
enabled: true,
|
||||
roadmap,
|
||||
blocker: { at: "2026-09-03T10:00:00.000Z", retryAt: "2026-09-03T11:00:00.000Z", kind: "retryable", phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, reason: "Temporary provider failure" },
|
||||
});
|
||||
|
||||
const retried = retryAutonomyBlockerIfDue(blocked, Date.parse("2026-09-03T11:00:00.000Z"));
|
||||
|
||||
expect(retried).toMatchObject({ status: "running", roadmap: { status: "active", phases: [{ status: "active", items: [{ status: "pending", attempts: 2 }] }] } });
|
||||
expect(retried.blocker).toBeUndefined();
|
||||
expect(retried.events.at(-1)?.type).toBe("autonomy.blocker.retry_ready");
|
||||
});
|
||||
|
||||
it("fences heartbeat ownership between overlapping scheduled runs", () => {
|
||||
const current = { runId: "new-run", status: "working", startedAt: new Date("2026-09-03T10:00:00.000Z") };
|
||||
expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "old-run", status: "idle" })).toMatchObject({ accepted: false });
|
||||
expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", status: "degraded" })).toMatchObject({ accepted: false, reason: "run_owner_mismatch" });
|
||||
expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "new-run", status: "idle" })).toMatchObject({ accepted: true, startedAt: current.startedAt });
|
||||
expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:10:00.000Z"))).toMatchObject({ accepted: false, reason: "active_run" });
|
||||
expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T11:00:00.000Z"))).toMatchObject({ accepted: true, startedAt: new Date("2026-09-03T11:00:00.000Z") });
|
||||
expect(resolveAutonomyHeartbeat({ ...current, status: "idle" }, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:10:00.000Z"))).toMatchObject({ accepted: true, startedAt: new Date("2026-09-03T10:10:00.000Z") });
|
||||
expect(resolveAutonomyHeartbeat({ ...current, activeProjectId: "project-a" }, { instanceId: "ordinary-chat", runId: "new-run", status: "working", activeProjectId: "project-b" })).toMatchObject({ accepted: false, reason: "active_project" });
|
||||
});
|
||||
|
||||
it("allows takeover after a short project's configured run window plus grace", () => {
|
||||
const current = { runId: "old-run", status: "working", startedAt: new Date("2026-09-03T10:00:00.000Z") };
|
||||
const takeoverAfterMs = 25 * 60_000;
|
||||
expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:24:00.000Z"), takeoverAfterMs)).toMatchObject({ accepted: false, reason: "active_run" });
|
||||
expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:26:00.000Z"), takeoverAfterMs)).toMatchObject({ accepted: true });
|
||||
});
|
||||
|
||||
it("isolates concurrent ordinary ChatGPT executors by project", () => {
|
||||
expect(autonomyHeartbeatSlotId("mediarr")).toBe("project:mediarr");
|
||||
expect(autonomyHeartbeatSlotId("rombuilder")).toBe("project:rombuilder");
|
||||
expect(autonomyHeartbeatSlotId("mediarr")).not.toBe(autonomyHeartbeatSlotId("rombuilder"));
|
||||
expect(autonomyHeartbeatSlotId()).toBe("primary");
|
||||
});
|
||||
|
||||
it("projects large durable roadmaps into bounded MCP context", () => {
|
||||
const roadmap = parseRoadmapMarkdown(`## Current\n${Array.from({ length: 80 }, (_, index) => `- [ ] Item ${index + 1} ${"detail ".repeat(30)}`).join("\n")}\n## Later\n${Array.from({ length: 80 }, (_, index) => `- [ ] Later ${index + 1}`).join("\n")}`);
|
||||
const state = normalizeAutonomyState({
|
||||
enabled: true,
|
||||
roadmap,
|
||||
events: Array.from({ length: 30 }, (_, index) => ({ id: `event-${index}`, at: "2026-09-04T10:00:00.000Z", type: "test", actor: "chatgpt", summary: "evidence ".repeat(200) })),
|
||||
});
|
||||
for (const item of state.roadmap!.phases[0].items.slice(0, 40)) item.status = "completed";
|
||||
|
||||
const compact = compactAutonomyState(state, { itemLimit: 10, eventLimit: 3 });
|
||||
const serialized = JSON.stringify(compact);
|
||||
|
||||
expect(compact.roadmap?.currentPhase?.items).toHaveLength(10);
|
||||
expect(compact.roadmap?.currentPhase).toMatchObject({ totalItems: 80, itemWindowStart: 37, itemsReturned: 10, hasMoreItems: true });
|
||||
expect(compact.roadmap?.currentPhase?.items[3]).toMatchObject({ title: expect.stringContaining("Item 41"), status: "pending" });
|
||||
expect(compact.events).toHaveLength(3);
|
||||
expect(serialized).not.toContain("sourceMarkdown");
|
||||
expect(serialized.length).toBeLessThan(12_000);
|
||||
expect(serialized.length).toBeLessThan(JSON.stringify(state).length / 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto Mode MCP surface", () => {
|
||||
it("orders claimable projects fairly before considering backlog size", () => {
|
||||
const projects = [
|
||||
{ name: "huge-recent", status: "running", pending_work_items: 201, last_run_at: "2026-09-05T09:00:00.000Z" },
|
||||
{ name: "small-old", status: "running", pending_work_items: 4, last_run_at: "2026-09-04T09:00:00.000Z" },
|
||||
{ name: "never-run", status: "running", pending_work_items: 2 },
|
||||
{ name: "busy", status: "running", pending_work_items: 50, active_work_kind: "implement", last_run_at: "2026-09-01T09:00:00.000Z" },
|
||||
{ name: "blocked", status: "blocked", pending_work_items: 500, last_run_at: "2026-08-01T09:00:00.000Z" },
|
||||
].sort(compareAutoModeProjectRows);
|
||||
|
||||
expect(projects.map((project) => project.name)).toEqual(["never-run", "small-old", "huge-recent", "busy", "blocked"]);
|
||||
});
|
||||
|
||||
it("advertises the complete durable roadmap controller to MCP clients", async () => {
|
||||
const server = createProjectBrainMcpServer(env, "auto-contract-test");
|
||||
const client = new Client({ name: "auto-contract-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const advertisedTools = (await client.listTools()).tools;
|
||||
const tools = advertisedTools.map((tool) => tool.name);
|
||||
|
||||
expect(tools).toEqual(expect.arrayContaining([
|
||||
"list_auto_mode_projects",
|
||||
"heartbeat_auto_executor",
|
||||
"get_auto_mode_state",
|
||||
"configure_auto_mode",
|
||||
"import_auto_roadmap",
|
||||
"resume_auto_mode",
|
||||
"claim_auto_work",
|
||||
"report_auto_work",
|
||||
]));
|
||||
expect(advertisedTools.find((tool) => tool.name === "claim_auto_work")?.inputSchema).toMatchObject({
|
||||
required: expect.arrayContaining(["project_id", "run_id"]),
|
||||
});
|
||||
expect(advertisedTools.find((tool) => tool.name === "configure_auto_mode")?.inputSchema).toMatchObject({
|
||||
properties: expect.objectContaining({ max_work_items_per_run: expect.any(Object), max_run_minutes: expect.any(Object), max_recovery_minutes: expect.any(Object), pause_after_current: expect.any(Object), scheduling_policy: expect.any(Object) }),
|
||||
});
|
||||
expect(advertisedTools.find((tool) => tool.name === "get_auto_mode_state")?.inputSchema).toMatchObject({
|
||||
properties: expect.objectContaining({ view: expect.any(Object), item_limit: expect.any(Object), event_limit: expect.any(Object) }),
|
||||
});
|
||||
expect(advertisedTools.find((tool) => tool.name === "report_auto_work")?.inputSchema).toMatchObject({
|
||||
properties: expect.objectContaining({ blocker_type: expect.any(Object), retry_after_minutes: expect.any(Object) }),
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { autonomyRetryDelayMinutes, classifyAutonomyBlocker } from "../src/services/autonomyRecovery.js";
|
||||
|
||||
describe("Auto Mode recovery classification", () => {
|
||||
it("keeps ordinary infrastructure failures retryable even when a client requests human escalation", () => {
|
||||
expect(classifyAutonomyBlocker("Gitea runner timed out while starting validation.", "human")).toBe("retryable");
|
||||
expect(classifyAutonomyBlocker("Untrusted non-HTTPS Gitea server URL blocked security bootstrap.", "human")).toBe("retryable");
|
||||
expect(classifyAutonomyBlocker("Provider returned HTTP 502 Bad Gateway.", "human")).toBe("retryable");
|
||||
});
|
||||
|
||||
it("requires human attention only when durable evidence says a real owner action is needed", () => {
|
||||
expect(classifyAutonomyBlocker("Missing credential required for the production deployment.", "retryable")).toBe("human");
|
||||
expect(classifyAutonomyBlocker("Owner decision is required before choosing between destructive reset and preserving production data.")).toBe("human");
|
||||
expect(classifyAutonomyBlocker("Approval is required for this irreversible operation.")).toBe("human");
|
||||
});
|
||||
|
||||
it("defaults unknown operational failures to retryable instead of creating approval chores", () => {
|
||||
expect(classifyAutonomyBlocker("Unexpected validation orchestration failure.", "human")).toBe("retryable");
|
||||
});
|
||||
|
||||
it("uses bounded exponential retry delays", () => {
|
||||
expect(autonomyRetryDelayMinutes(1)).toBe(15);
|
||||
expect(autonomyRetryDelayMinutes(2)).toBe(30);
|
||||
expect(autonomyRetryDelayMinutes(3)).toBe(60);
|
||||
expect(autonomyRetryDelayMinutes(7)).toBe(960);
|
||||
expect(autonomyRetryDelayMinutes(100)).toBe(960);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"CI failed. No approval is required; repair the same branch.",
|
||||
"No destructive operation was performed. Runner is unavailable.",
|
||||
"This is not a credentials or approval requirement. Validation failed.",
|
||||
])("does not mistake negated human decisions for blockers: %s", (reason) => {
|
||||
expect(classifyAutonomyBlocker(reason, "human")).toBe("retryable");
|
||||
});
|
||||
|
||||
it("retains a real decision even beside negated or technical context", () => {
|
||||
expect(classifyAutonomyBlocker("No CI output is available, but missing credentials prevent deployment.")).toBe("human");
|
||||
expect(classifyAutonomyBlocker("Runner failed. Owner authorization is required to change branch protection.")).toBe("human");
|
||||
expect(classifyAutonomyBlocker("No changes were made, owner authorization is required.")).toBe("human");
|
||||
expect(classifyAutonomyBlocker("This is not a CI issue and credentials are required.")).toBe("human");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reconcileAutonomyPlan } from "../src/services/autonomyReplanner.js";
|
||||
import { normalizeAutonomyState } from "../src/repositories/autonomyRepository.js";
|
||||
|
||||
const state = () => normalizeAutonomyState({ enabled: true, roadmap: { id: "roadmap-1", title: "Release", status: "active", currentPhaseIndex: 0, importedAt: "2026-09-12T10:00:00.000Z", phases: [{ id: "phase-1", title: "Build", objective: "Ship", status: "executing", items: [{ id: "item-1", title: "Feature", source: "delivery", deliveryId: "delivery-1", status: "in_progress", attempts: 1 }] }] } });
|
||||
|
||||
describe("autonomy reality reconciliation", () => {
|
||||
it("preserves work completed by an external merge", () => {
|
||||
const result = reconcileAutonomyPlan(state(), [{ itemId: "item-1", deliveryId: "delivery-1", status: "pull_request_open", mergeCommitSha: "abc123", validationEvidence: "head123" }]);
|
||||
const item = result.state.roadmap?.phases[0]?.items[0];
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.completionEvidence).toEqual(expect.arrayContaining(["delivery:delivery-1", "merge:abc123", "head123"]));
|
||||
});
|
||||
|
||||
it("adds bounded recovery work for superseded delivery state", () => {
|
||||
const result = reconcileAutonomyPlan(state(), [{ itemId: "item-1", deliveryId: "delivery-1", status: "superseded", supersededBy: "delivery-2" }]);
|
||||
expect(result.repairItems).toHaveLength(1);
|
||||
expect(result.state.roadmap?.phases[0]?.items).toHaveLength(2);
|
||||
expect(result.state.roadmap?.phases[0]?.items[1]?.source).toBe("recovery");
|
||||
});
|
||||
|
||||
it("replans a previously completed item when its delivery is superseded", () => {
|
||||
const completed = state();
|
||||
completed.roadmap!.phases[0].items[0].status = "completed";
|
||||
const result = reconcileAutonomyPlan(completed, [{ itemId: "item-1", deliveryId: "delivery-1", status: "merged", supersededBy: "delivery-2" }]);
|
||||
expect(result.repairItems).toHaveLength(1);
|
||||
expect(result.state.roadmap?.phases[0]?.items[0]).toMatchObject({ status: "completed", supersededBy: "delivery-2" });
|
||||
});
|
||||
|
||||
it("clears an external wait when the observed delivery becomes available again", () => {
|
||||
const waiting = state();
|
||||
const item = waiting.roadmap?.phases[0]?.items[0];
|
||||
if (!item) throw new Error("test item missing");
|
||||
item.waitingExternal = true;
|
||||
item.lastError = "Provider unavailable";
|
||||
const result = reconcileAutonomyPlan(waiting, [{ itemId: item.id, deliveryId: "delivery-1", status: "executing" }]);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.state.roadmap?.phases[0]?.items[0]).toMatchObject({ waitingExternal: false, status: "in_progress" });
|
||||
expect(result.state.roadmap?.phases[0]?.items[0]?.lastError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chooseNextAutonomyWork, planParallelAutonomyWork } from "../src/services/autonomyScheduler.js";
|
||||
import { parseRoadmapMarkdown } from "../src/repositories/autonomyRepository.js";
|
||||
|
||||
describe("smart autonomy scheduler", () => {
|
||||
it("chooses the independent item that unlocks the most downstream work", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B\n- [ ] C\n- [ ] D");
|
||||
const [a, b, c, d] = roadmap.phases[0].items;
|
||||
c.dependsOn = [a.id];
|
||||
d.dependsOn = [a.id];
|
||||
b.priority = 80;
|
||||
const selected = chooseNextAutonomyWork(roadmap.phases, 0);
|
||||
expect(selected?.item.id).toBe(a.id);
|
||||
expect(selected?.downstreamCount).toBe(2);
|
||||
});
|
||||
|
||||
it("does not group likely path-conflicting work in parallel", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B\n- [ ] C");
|
||||
const [a, b, c] = roadmap.phases[0].items;
|
||||
a.touchedPaths = ["src/shared/project.ts"];
|
||||
b.touchedPaths = ["src/shared/project.ts"];
|
||||
c.touchedPaths = ["src/other.ts"];
|
||||
const candidates = chooseNextAutonomyWork(roadmap.phases, 0);
|
||||
expect(candidates?.item).toBeTruthy();
|
||||
const all = roadmap.phases[0].items.map((item) => ({ item, phase: roadmap.phases[0], phaseIndex: 0, score: 1, downstreamCount: 0, conflictKeys: (item.touchedPaths ?? []).map((path) => `path:${path}`) }));
|
||||
const groups = planParallelAutonomyWork(all);
|
||||
expect(groups.some((group) => group.includes(a.id) && group.includes(b.id))).toBe(false);
|
||||
});
|
||||
|
||||
it("can deliberately favor downstream impact when a command changes scheduling policy", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B\n- [ ] C\n- [ ] D");
|
||||
const [a, b, c, d] = roadmap.phases[0].items;
|
||||
c.dependsOn = [a.id];
|
||||
d.dependsOn = [a.id];
|
||||
b.priority = 100;
|
||||
expect(chooseNextAutonomyWork(roadmap.phases, 0, "downstream-impact")?.item.id).toBe(a.id);
|
||||
});
|
||||
|
||||
it("does not claim work while its external dependency is unavailable", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B");
|
||||
roadmap.phases[0].items[0].waitingExternal = true;
|
||||
expect(chooseNextAutonomyWork(roadmap.phases, 0)?.item.id).toBe(roadmap.phases[0].items[1].id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAutonomyState, parseRoadmapMarkdown, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js";
|
||||
import { addDiscoveredTasks, isAutonomyItemReady, orderAutonomyRepairs, workPrompt } from "../src/services/autonomyWork.js";
|
||||
|
||||
describe("autonomous repair sequencing", () => {
|
||||
it("runs an existing prerequisite before retrying a priority user assignment", () => {
|
||||
const phase = parseRoadmapMarkdown("## CI\n- Gate Ruff\n- Fix Ruff errors").phases[0];
|
||||
const [gate, repair] = phase.items;
|
||||
gate.source = "user_assignment";
|
||||
expect(addDiscoveredTasks(phase, ["Fix Ruff errors"])).toEqual([]);
|
||||
expect(orderAutonomyRepairs(phase, gate, ["Fix Ruff errors"])).toBe(true);
|
||||
expect(isAutonomyItemReady(phase, gate)).toBe(false);
|
||||
expect(isAutonomyItemReady(phase, repair)).toBe(true);
|
||||
repair.status = "completed";
|
||||
expect(isAutonomyItemReady(phase, gate)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects circular repair dependencies and does not duplicate discovered work", () => {
|
||||
const phase = parseRoadmapMarkdown("## CI\n- Gate\n- Repair").phases[0];
|
||||
orderAutonomyRepairs(phase, phase.items[0], ["Repair"]);
|
||||
expect(() => orderAutonomyRepairs(phase, phase.items[1], ["Gate"])).toThrow("AUTO_MODE_DEPENDENCY_CYCLE");
|
||||
expect(addDiscoveredTasks(phase, ["Repair", " repair ", "New test"])).toEqual(["New test"]);
|
||||
});
|
||||
|
||||
it("recovers historical technical human blockers with a bounded retry time", () => {
|
||||
const roadmap = parseRoadmapMarkdown("## CI\n- Repair");
|
||||
roadmap.status = "blocked";
|
||||
roadmap.phases[0].status = "blocked";
|
||||
const state = normalizeAutonomyState({ enabled: true, roadmap, maxRecoveryMinutes: 120, blocker: { kind: "human", at: "2026-09-01T23:00:00Z", recoveryStartedAt: "2026-09-01T23:00:00Z", reason: "CI validation failed. No approval is required." } });
|
||||
expect(state.blocker?.kind).toBe("retryable");
|
||||
expect(retryAutonomyBlockerIfDue(state, Date.parse("2026-09-02T00:00:00Z")).status).toBe("running");
|
||||
});
|
||||
|
||||
it("resumes the recorded delivery through merge rather than suggesting a new branch", () => {
|
||||
const phase = parseRoadmapMarkdown("## Build\n- Feature").phases[0];
|
||||
phase.items[0].deliveryId = "existing-delivery";
|
||||
const prompt = workPrompt("implement", phase, phase.items[0]);
|
||||
expect(prompt).toContain("Resume delivery 'existing-delivery'");
|
||||
expect(prompt).toContain("merge_feature_delivery");
|
||||
expect(prompt).toContain("Always report delivery_id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAutonomyState } from "../src/repositories/autonomyRepository.js";
|
||||
import { canonicalLifecycleFromAutonomy } from "../src/services/canonicalLifecycle.js";
|
||||
|
||||
describe("backend canonical lifecycle projection", () => {
|
||||
it("uses the same seven-state semantics as the product contract", () => {
|
||||
expect(canonicalLifecycleFromAutonomy(normalizeAutonomyState({ enabled: false, status: "off" }))).toBe("ready");
|
||||
expect(canonicalLifecycleFromAutonomy(normalizeAutonomyState({
|
||||
enabled: true,
|
||||
status: "blocked",
|
||||
blocker: { kind: "human", reason: "Owner decision required", at: "2026-09-01T00:00:00Z" },
|
||||
}))).toBe("needs_user");
|
||||
expect(canonicalLifecycleFromAutonomy(normalizeAutonomyState({
|
||||
enabled: true,
|
||||
status: "blocked",
|
||||
blocker: { kind: "retryable", reason: "Runner timeout", at: "2026-09-01T00:00:00Z" },
|
||||
}))).toBe("recovering");
|
||||
const completed = normalizeAutonomyState({ enabled: true, status: "completed" });
|
||||
expect(canonicalLifecycleFromAutonomy({ ...completed, status: "completed" })).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { createSession } from "../src/security/authStore.js";
|
||||
import { hashPassword } from "../src/security/password.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { apiPaths } from "@projectbrain/contracts";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
|
||||
const databaseDescribe = databaseUrl ? describe : describe.skip;
|
||||
const ownerPassword = "database-owner-password-value";
|
||||
const coreLoopIds = {
|
||||
capture: [] as string[], review: [] as string[], truth: [] as string[], update: [] as string[],
|
||||
handoff: [] as string[], task: [] as string[], runImport: [] as string[],
|
||||
};
|
||||
const testEnv = () => loadEnv({
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
DATABASE_URL: databaseUrl,
|
||||
PROJECTBRAIN_DATA_SOURCE: "database",
|
||||
PROJECTBRAIN_OWNER_USERNAME: "database-owner",
|
||||
PROJECTBRAIN_OWNER_PASSWORD: ownerPassword,
|
||||
PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters",
|
||||
PROJECTBRAIN_SESSION_TTL_HOURS: "1",
|
||||
PROJECTBRAIN_TRUST_PROXY: "false",
|
||||
});
|
||||
const entraEnv = () => loadEnv({
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
DATABASE_URL: databaseUrl,
|
||||
PROJECTBRAIN_DATA_SOURCE: "database",
|
||||
PROJECTBRAIN_OWNER_USERNAME: "database-owner",
|
||||
PROJECTBRAIN_OWNER_PASSWORD: ownerPassword,
|
||||
PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters",
|
||||
PROJECTBRAIN_SESSION_TTL_HOURS: "1",
|
||||
PROJECTBRAIN_TRUST_PROXY: "false",
|
||||
PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com",
|
||||
PROJECTBRAIN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111",
|
||||
PROJECTBRAIN_ENTRA_CLIENT_ID: "22222222-2222-4222-8222-222222222222",
|
||||
PROJECTBRAIN_ENTRA_CLIENT_SECRET: "database-entra-test-secret",
|
||||
});
|
||||
|
||||
databaseDescribe("database authentication persistence", () => {
|
||||
beforeAll(async () => {
|
||||
await prisma.oidcLoginAttempt.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
await prisma.externalIdentity.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
await prisma.authSession.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
await prisma.projectMembership.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
await prisma.user.deleteMany({ where: { username: { in: ["database-owner", "database-member"] } } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.oidcLoginAttempt.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
await prisma.externalIdentity.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
const markedTasks = await prisma.codexTask.findMany({ where: { goal: "Execute the database core-loop proof" }, select: { id: true } });
|
||||
await prisma.codexRunImport.deleteMany({ where: { OR: [{ sourceRun: "core-loop-database-proof" }, { codexTaskId: { in: markedTasks.map((item) => item.id) } }] } });
|
||||
await prisma.reviewQueueItem.deleteMany({ where: { OR: [{ source: "core-loop-database-proof" }, { title: { contains: "Core-loop database proof" } }] } });
|
||||
await prisma.codexTask.deleteMany({ where: { goal: "Execute the database core-loop proof" } });
|
||||
await prisma.handoffBundle.deleteMany({ where: { title: "Core-loop database proof handoff" } });
|
||||
const markedCaptures = await prisma.captureItem.findMany({ where: { content: "Core-loop database proof source material" }, select: { linkedReviewItemId: true } });
|
||||
const markedReviewIds = markedCaptures.flatMap((item) => item.linkedReviewItemId ? [item.linkedReviewItemId] : []);
|
||||
const markedTruth = await prisma.acceptedTruthItem.findMany({ where: { sourceReviewItemId: { in: markedReviewIds } }, select: { id: true } });
|
||||
await prisma.projectUpdate.deleteMany({ where: { sourceTruthItemId: { in: markedTruth.map((item) => item.id) } } });
|
||||
await prisma.acceptedTruthItem.deleteMany({ where: { id: { in: markedTruth.map((item) => item.id) } } });
|
||||
await prisma.reviewQueueItem.deleteMany({ where: { id: { in: markedReviewIds } } });
|
||||
await prisma.captureItem.deleteMany({ where: { content: "Core-loop database proof source material" } });
|
||||
await prisma.codexRunImport.deleteMany({ where: { id: { in: coreLoopIds.runImport } } });
|
||||
await prisma.codexTask.deleteMany({ where: { id: { in: coreLoopIds.task } } });
|
||||
await prisma.handoffBundle.deleteMany({ where: { id: { in: coreLoopIds.handoff } } });
|
||||
await prisma.projectUpdate.deleteMany({ where: { id: { in: coreLoopIds.update } } });
|
||||
await prisma.acceptedTruthItem.deleteMany({ where: { id: { in: coreLoopIds.truth } } });
|
||||
await prisma.reviewQueueItem.deleteMany({ where: { id: { in: coreLoopIds.review } } });
|
||||
await prisma.captureItem.deleteMany({ where: { id: { in: coreLoopIds.capture } } });
|
||||
await prisma.idempotencyRecord.deleteMany({ where: { key: { contains: "core-loop-database-proof" } } });
|
||||
await prisma.operatingLogEntry.deleteMany({ where: { title: "Durable idempotency restart proof" } });
|
||||
await prisma.idempotencyRecord.deleteMany({ where: { key: { contains: "database-restart-replay" } } });
|
||||
await prisma.authSession.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
await prisma.projectMembership.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } });
|
||||
await prisma.user.deleteMany({ where: { username: { in: ["database-owner", "database-member"] } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("keeps a server-side owner session valid across API restart", async () => {
|
||||
const firstApp = await buildApp({ env: testEnv() });
|
||||
const login = await firstApp.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } });
|
||||
expect(login.statusCode).toBe(200);
|
||||
const cookie = login.cookies.find((item) => item.name === "projectbrain_session");
|
||||
expect(cookie).toBeDefined();
|
||||
const cookieHeader = `${cookie!.name}=${cookie!.value}`;
|
||||
await firstApp.close();
|
||||
|
||||
const restartedApp = await buildApp({ env: testEnv() });
|
||||
const response = await restartedApp.inject({ method: "GET", url: "/api/projects", headers: { cookie: cookieHeader } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.length).toBeGreaterThan(0);
|
||||
await restartedApp.close();
|
||||
}, 15_000);
|
||||
|
||||
it("creates a one-time PKCE Entra link attempt without persisting raw state", async () => {
|
||||
const app = await buildApp({ env: entraEnv() });
|
||||
const login = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "database-owner", password: ownerPassword } });
|
||||
const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session");
|
||||
const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`;
|
||||
const csrfToken = login.json().data.csrfToken as string;
|
||||
const response = await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart, headers: { cookie, "x-csrf-token": csrfToken } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const authorizationUrl = new URL(response.json().data.authorizationUrl);
|
||||
expect(authorizationUrl.hostname).toBe("login.microsoftonline.com");
|
||||
expect(authorizationUrl.pathname).toContain("11111111-1111-4111-8111-111111111111");
|
||||
expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(authorizationUrl.searchParams.get("nonce")?.length).toBeGreaterThan(30);
|
||||
const state = authorizationUrl.searchParams.get("state")!;
|
||||
const attempt = await prisma.oidcLoginAttempt.findUnique({ where: { stateHash: createHash("sha256").update(state).digest("hex") } });
|
||||
expect(attempt?.purpose).toBe("link");
|
||||
expect(attempt?.returnTo).toBe("/settings");
|
||||
expect(attempt?.stateHash).not.toBe(state);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("validates the Entra ID token and turns a linked identity into a ProjectBrain session", async () => {
|
||||
const app = await buildApp({ env: entraEnv() });
|
||||
const login = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "database-owner", password: ownerPassword } });
|
||||
const localCookie = login.cookies.find((item) => item.name === "projectbrain_session")!;
|
||||
const linkStart = await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart, headers: { cookie: `${localCookie.name}=${localCookie.value}`, "x-csrf-token": login.json().data.csrfToken } });
|
||||
const authorizationUrl = new URL(linkStart.json().data.authorizationUrl);
|
||||
const state = authorizationUrl.searchParams.get("state")!;
|
||||
const nonce = authorizationUrl.searchParams.get("nonce")!;
|
||||
const tenantId = "11111111-1111-4111-8111-111111111111";
|
||||
const clientId = "22222222-2222-4222-8222-222222222222";
|
||||
const objectId = "33333333-3333-4333-8333-333333333333";
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const publicJwk = { ...await exportJWK(publicKey), kid: "entra-test-key", alg: "RS256", use: "sig" };
|
||||
const idToken = await new SignJWT({ tid: tenantId, oid: objectId, nonce, name: "Entra Owner", preferred_username: "owner@example.com" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "entra-test-key" })
|
||||
.setIssuer(`https://login.microsoftonline.com/${tenantId}/v2.0`)
|
||||
.setAudience(clientId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
if (url.endsWith("/oauth2/v2.0/token")) return new Response(JSON.stringify({ id_token: idToken }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
if (url.endsWith("/discovery/v2.0/keys")) return new Response(JSON.stringify({ keys: [publicJwk] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
return new Response("not found", { status: 404 });
|
||||
}));
|
||||
try {
|
||||
const callback = await app.inject({ method: "GET", url: `${apiPaths.auth.entraCallback}?code=one-time-code&state=${encodeURIComponent(state)}` });
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(callback.headers.location).toBe("https://projectbrain.example.com/settings");
|
||||
const entraCookie = callback.cookies.find((item) => item.name === "projectbrain_session");
|
||||
expect(entraCookie).toBeDefined();
|
||||
const identity = await prisma.externalIdentity.findUnique({ where: { provider_tenantId_subject: { provider: "microsoft-entra", tenantId, subject: objectId } } });
|
||||
expect(identity?.preferredUsername).toBe("owner@example.com");
|
||||
expect((await app.inject({ method: "GET", url: apiPaths.auth.session, headers: { cookie: `${entraCookie!.name}=${entraCookie!.value}` } })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: "GET", url: `${apiPaths.auth.entraCallback}?code=replay&state=${encodeURIComponent(state)}` })).headers.location).toContain("expired_attempt");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
await app.close();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("returns 403 until a member receives project access", async () => {
|
||||
const app = await buildApp({ env: testEnv() });
|
||||
const member = await prisma.user.create({ data: { username: "database-member", passwordHash: await hashPassword("database-member-password"), role: "member" } });
|
||||
const session = await createSession(testEnv(), { id: member.id, username: member.username, role: "member", credentialVersion: member.credentialVersion });
|
||||
const cookie = `projectbrain_session=${app.signCookie(session.token)}`;
|
||||
|
||||
const projectCreateDenied = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
headers: { cookie, "x-csrf-token": session.csrfToken },
|
||||
payload: { name: "Unauthorized project", description: "A member must not provision a Gitea repository through project creation." },
|
||||
});
|
||||
expect(projectCreateDenied.statusCode).toBe(403);
|
||||
expect(projectCreateDenied.json().error.code).toBe("PROJECT_CREATION_FORBIDDEN");
|
||||
|
||||
const denied = await app.inject({ method: "GET", url: "/api/projects/widefrog", headers: { cookie } });
|
||||
expect(denied.statusCode).toBe(403);
|
||||
expect(denied.json().error.code).toBe("PROJECT_ACCESS_DENIED");
|
||||
|
||||
const project = await prisma.project.findUniqueOrThrow({ where: { slug: "widefrog" } });
|
||||
await prisma.projectMembership.create({ data: { projectId: project.id, userId: member.id, role: "reader" } });
|
||||
const allowed = await app.inject({ method: "GET", url: "/api/projects/widefrog", headers: { cookie } });
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
|
||||
const writeDenied = await app.inject({ method: "PATCH", url: "/api/projects/widefrog", headers: { cookie, "x-csrf-token": session.csrfToken }, payload: { name: "Forbidden update" } });
|
||||
expect(writeDenied.statusCode).toBe(403);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("replays a core-loop mutation durably after API restart", async () => {
|
||||
const firstApp = await buildApp({ env: testEnv() });
|
||||
const login = await firstApp.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } });
|
||||
const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session");
|
||||
const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`;
|
||||
const csrfToken = login.json().data.csrfToken as string;
|
||||
const headers = { cookie, "x-csrf-token": csrfToken, "idempotency-key": "database-restart-replay" };
|
||||
const payload = { type: "project update", title: "Durable idempotency restart proof", summary: "This entry must be created once across API restarts.", source: "database-test" };
|
||||
|
||||
const created = await firstApp.inject({ method: "POST", url: "/api/projects/widefrog/operating-log", headers, payload });
|
||||
expect(created.statusCode).toBe(201);
|
||||
await firstApp.close();
|
||||
|
||||
const restartedApp = await buildApp({ env: testEnv() });
|
||||
const replayed = await restartedApp.inject({ method: "POST", url: "/api/projects/widefrog/operating-log", headers, payload });
|
||||
expect(replayed.statusCode).toBe(201);
|
||||
expect(replayed.json().data.id).toBe(created.json().data.id);
|
||||
expect(await prisma.operatingLogEntry.count({ where: { title: payload.title } })).toBe(1);
|
||||
expect(await prisma.idempotencyRecord.count({ where: { key: { contains: "database-restart-replay" } } })).toBe(1);
|
||||
await restartedApp.close();
|
||||
}, 15_000);
|
||||
|
||||
it("executes the complete governed core loop in PostgreSQL mode", async () => {
|
||||
const app = await buildApp({ env: testEnv() });
|
||||
const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } });
|
||||
const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session");
|
||||
const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`;
|
||||
const csrfToken = login.json().data.csrfToken as string;
|
||||
let operation = 0;
|
||||
const mutate = (method: "POST" | "PATCH", url: string, payload: Record<string, unknown> = {}) => app.inject({
|
||||
method, url, payload,
|
||||
headers: { cookie, "x-csrf-token": csrfToken, "idempotency-key": `core-loop-database-proof-${++operation}` },
|
||||
});
|
||||
|
||||
const capture = await mutate("POST", "/api/projects/widefrog/capture-items", { type: "note", content: "Core-loop database proof source material", source: "database-integration-test" });
|
||||
expect(capture.statusCode).toBe(201);
|
||||
coreLoopIds.capture.push(capture.json().data.id);
|
||||
|
||||
expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${capture.json().data.id}/classify`)).statusCode).toBe(200);
|
||||
const queued = await mutate("POST", `/api/projects/widefrog/capture-items/${capture.json().data.id}/send-to-review`);
|
||||
expect(queued.statusCode).toBe(201);
|
||||
const reviewId = queued.json().data.createdEntityId as string;
|
||||
coreLoopIds.review.push(reviewId);
|
||||
|
||||
const concurrentReviewDecisions = await Promise.all([
|
||||
mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`, { reason: "Accepted by database core-loop proof A" }),
|
||||
mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`, { reason: "Accepted by database core-loop proof B" }),
|
||||
]);
|
||||
expect(concurrentReviewDecisions.map((response) => response.statusCode).sort()).toEqual([200, 409]);
|
||||
expect(concurrentReviewDecisions.find((response) => response.statusCode === 409)!.json().error.code).toBe("REVIEW_ALREADY_DECIDED");
|
||||
const materialized = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/materialize-truth`);
|
||||
expect(materialized.statusCode).toBe(200);
|
||||
const truthId = materialized.json().data.createdEntityId as string;
|
||||
coreLoopIds.truth.push(truthId);
|
||||
|
||||
const conversionCapture = await mutate("POST", "/api/projects/widefrog/capture-items", { type: "note", content: "Convert this reviewed decision into a durable truth artifact", source: "database-conversion-test" });
|
||||
expect(conversionCapture.statusCode).toBe(201);
|
||||
coreLoopIds.capture.push(conversionCapture.json().data.id);
|
||||
expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${conversionCapture.json().data.id}/classify`)).statusCode).toBe(200);
|
||||
const conversionQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${conversionCapture.json().data.id}/send-to-review`);
|
||||
expect(conversionQueue.statusCode).toBe(201);
|
||||
const conversionReviewId = conversionQueue.json().data.createdEntityId as string;
|
||||
coreLoopIds.review.push(conversionReviewId);
|
||||
const converted = await mutate("POST", `/api/projects/widefrog/review-queue/${conversionReviewId}/convert`, { target: "decision", reason: "Database artifact proof" });
|
||||
expect(converted.statusCode).toBe(200);
|
||||
const convertedTruthId = converted.json().data.createdEntityId as string;
|
||||
const convertedTruth = await prisma.acceptedTruthItem.findFirst({ where: { slug: convertedTruthId } });
|
||||
expect(convertedTruth).toMatchObject({ kind: "decision", status: "converted" });
|
||||
coreLoopIds.truth.push(convertedTruth!.id);
|
||||
const duplicateConversion = await mutate("POST", `/api/projects/widefrog/review-queue/${conversionReviewId}/convert`, { target: "decision" });
|
||||
expect(duplicateConversion.statusCode).toBe(409);
|
||||
expect(duplicateConversion.json().error.code).toBe("REVIEW_ALREADY_DECIDED");
|
||||
|
||||
const prepared = await mutate("POST", `/api/projects/widefrog/accepted-truth/${truthId}/project-updates`);
|
||||
expect(prepared.statusCode).toBe(200);
|
||||
const updateId = prepared.json().data.createdEntityId as string;
|
||||
coreLoopIds.update.push(updateId);
|
||||
const concurrentUpdateApplications = await Promise.all([
|
||||
mutate("PATCH", `/api/projects/widefrog/project-updates/${updateId}/apply`),
|
||||
mutate("PATCH", `/api/projects/widefrog/project-updates/${updateId}/apply`),
|
||||
]);
|
||||
expect(concurrentUpdateApplications.map((response) => response.statusCode).sort()).toEqual([200, 409]);
|
||||
expect(concurrentUpdateApplications.find((response) => response.statusCode === 409)!.json().error.code).toBe("PROJECT_UPDATE_ALREADY_APPLIED");
|
||||
|
||||
const handoff = await mutate("POST", "/api/projects/widefrog/handoff-bundles", { target: "codex-task", title: "Core-loop database proof handoff", includeAcceptedTruth: true, includeProjectUpdates: true, includeOperatingLog: true, autoSelectContext: true });
|
||||
expect(handoff.statusCode).toBe(201);
|
||||
coreLoopIds.handoff.push(handoff.json().data.id);
|
||||
expect(handoff.json().data.sourceTruthItemIds).toContain(truthId);
|
||||
|
||||
const task = await mutate("POST", `/api/projects/widefrog/handoff-bundles/${handoff.json().data.id}/codex-task`, { goal: "Execute the database core-loop proof", validationCommands: ["pnpm validate:all"] });
|
||||
expect(task.statusCode).toBe(201);
|
||||
coreLoopIds.task.push(task.json().data.id);
|
||||
|
||||
const imported = await mutate("POST", `/api/projects/widefrog/codex-tasks/${task.json().data.id}/import-output`, { sourceRun: "core-loop-database-proof", summary: "Execution feedback returned to governance.", outputText: "Changed files: none. Validation passed. Review this evidence." });
|
||||
expect(imported.statusCode).toBe(201);
|
||||
coreLoopIds.runImport.push(imported.json().data.importDto.id);
|
||||
coreLoopIds.review.push(imported.json().data.mutation.createdEntityId);
|
||||
|
||||
const publicId = (value: string) => ({ OR: [{ id: value }, { slug: value }, { readableId: value }] });
|
||||
expect(await prisma.captureItem.count({ where: publicId(capture.json().data.id) })).toBe(1);
|
||||
expect(await prisma.acceptedTruthItem.count({ where: { AND: [publicId(truthId), { status: "accepted" }] } })).toBe(1);
|
||||
expect(await prisma.projectUpdate.count({ where: { AND: [publicId(updateId), { status: "applied" }] } })).toBe(1);
|
||||
expect(await prisma.codexTask.count({ where: { AND: [publicId(task.json().data.id), { status: "imported" }] } })).toBe(1);
|
||||
expect(await prisma.reviewQueueItem.count({ where: publicId(imported.json().data.mutation.createdEntityId) })).toBe(1);
|
||||
await app.close();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork } from "../src/repositories/autonomyRepository.js";
|
||||
import { claimAutonomyWork } from "../src/services/autonomyRuntimeControl.js";
|
||||
import { createOrResumeAutonomyDelivery } from "../src/services/autonomyDelivery.js";
|
||||
import { createFeatureDelivery, getFeatureDelivery, openFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery } from "../src/repositories/featureDeliveryRepository.js";
|
||||
import { refreshDeliveryFromGitea } from "../src/services/deliveryReconciliation.js";
|
||||
import { createProject } from "../src/repositories/projectsRepository.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createProjectBrainMcpServer } from "../src/mcp/server.js";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { GiteaApiError, GiteaClient } from "../src/integrations/gitea/client.js";
|
||||
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
|
||||
const databaseDescribe = databaseUrl ? describe : describe.skip;
|
||||
const slugs: string[] = [];
|
||||
|
||||
databaseDescribe("autonomy database write isolation", () => {
|
||||
it("preserves progress and the active lease when the same roadmap is imported again", async () => {
|
||||
const slug = `autonomy-import-${Date.now()}`;
|
||||
slugs.push(slug);
|
||||
await createProject({ id: slug, name: "Roadmap import proof", description: "Isolated database regression" });
|
||||
await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test");
|
||||
const markdown = "## Build\n- Implement feature";
|
||||
await importAutonomyRoadmap(slug, { markdown }, "database-test");
|
||||
const claim = await claimAutonomyWork(slug, "database-test");
|
||||
const repeated = await importAutonomyRoadmap(slug, { markdown: `\n${markdown}\n` }, "database-test");
|
||||
expect(repeated.roadmap).toEqual(claim.state.roadmap);
|
||||
expect(repeated.activeLease).toEqual(claim.state.activeLease);
|
||||
await expect(importAutonomyRoadmap(slug, { markdown: "## Replacement\n- New task" }, "database-test")).rejects.toThrow("AUTO_MODE_WORK_ACTIVE");
|
||||
expect((await getAutonomyState(slug)).activeLease?.token).toBe(claim.work!.token);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(async () => {
|
||||
if (slugs.length) {
|
||||
await prisma.deliveryPlan.deleteMany({ where: { project: { slug: { in: slugs } } } });
|
||||
await prisma.project.deleteMany({ where: { slug: { in: slugs } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("upgrades legacy autonomy state and preserves unrelated workspace notes", async () => {
|
||||
const slug = `autonomy-legacy-${Date.now()}`;
|
||||
slugs.push(slug);
|
||||
await createProject({ id: slug, name: "Autonomy legacy proof", description: "Isolated database regression" });
|
||||
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
|
||||
await prisma.workspaceState.update({ where: { projectId: project.id }, data: { notesJson: { autonomy: {}, unrelated: { value: "preserve me" } } } });
|
||||
const state = await configureAutonomy(slug, { enabled: true }, "database-test");
|
||||
expect(state.enabled).toBe(true);
|
||||
const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId: project.id } });
|
||||
expect(workspace.notesJson).toMatchObject({ unrelated: { value: "preserve me" }, autonomy: { enabled: true } });
|
||||
});
|
||||
|
||||
it("advances the CAS version even when the clock does not advance", async () => {
|
||||
const slug = `autonomy-clock-${Date.now()}`;
|
||||
slugs.push(slug);
|
||||
await createProject({ id: slug, name: "Autonomy clock proof", description: "Isolated database regression" });
|
||||
const first = await configureAutonomy(slug, { enabled: true }, "database-test");
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.parse(first.updatedAt));
|
||||
const second = await configureAutonomy(slug, { enabled: false }, "database-test");
|
||||
expect(Date.parse(second.updatedAt)).toBeGreaterThan(Date.parse(first.updatedAt));
|
||||
expect(second.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("resumes a failed implementation and executes its existing repair prerequisite first", async () => {
|
||||
const slug = `autonomy-repair-${Date.now()}`;
|
||||
slugs.push(slug);
|
||||
await createProject({ id: slug, name: "Autonomy repair proof", description: "Isolated database regression" });
|
||||
await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test");
|
||||
await importAutonomyRoadmap(slug, { markdown: "## CI\n- Add gate\n- Fix source lint" }, "database-test");
|
||||
const first = await claimAutonomyWork(slug, "database-test", "repair-run");
|
||||
const report = await reportAutonomyWork(slug, { token: first.work!.token, outcome: "blocked", summary: "CI failed; no approval is required.", discoveredTasks: ["Fix source lint"] }, "database-test");
|
||||
expect(report.state.status).toBe("running");
|
||||
expect(report.addedTasks).toEqual([]);
|
||||
const repair = await claimAutonomyWork(slug, "database-test", "repair-run");
|
||||
expect(repair.work!.itemTitle).toBe("Fix source lint");
|
||||
await expect(reportAutonomyWork(slug, { token: repair.work!.token, outcome: "success", summary: "Unproven success" }, "database-test")).rejects.toThrow("AUTO_MODE_DELIVERY_REQUIRED");
|
||||
expect((await getAutonomyState(slug)).activeLease?.token).toBe(repair.work!.token);
|
||||
});
|
||||
|
||||
it("retains one delivery across retries, repairs an open PR and invalidates stale evidence", async () => {
|
||||
const slug = `autonomy-delivery-${Date.now()}`;
|
||||
slugs.push(slug);
|
||||
await createProject({ id: slug, name: "Delivery repair proof", description: "Isolated database regression" });
|
||||
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
|
||||
await prisma.giteaRepositoryLink.create({ data: { projectId: project.id, owner: "AutonomyProof", repository: slug, defaultBranch: "main", linkedBy: "database-test" } });
|
||||
await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test");
|
||||
await importAutonomyRoadmap(slug, { markdown: "## Build\n- Implement feature" }, "database-test");
|
||||
const claim = await claimAutonomyWork(slug, "database-test", "delivery-run");
|
||||
const plan = { title: "Implement feature", intent: "Complete feature", problem: "Broken flow", desiredOutcome: "Working flow", goals: ["repair"], acceptanceCriteria: ["tests pass"], implementationPlan: ["implement", "validate", "merge"], validationCommands: ["pnpm test"], riskLevel: "low" as const };
|
||||
const delivery = await createOrResumeAutonomyDelivery(slug, plan, "database-test");
|
||||
expect((await createOrResumeAutonomyDelivery(slug, { ...plan, title: "Unnecessary replacement" }, "database-test")).id).toBe(delivery.id);
|
||||
await startFeatureDelivery(slug, delivery.id, "chatgpt/existing", "database-test");
|
||||
await reportAutonomyWork(slug, { token: claim.work!.token, outcome: "failed", summary: "CI failed on existing branch" }, "database-test");
|
||||
const resumed = await claimAutonomyWork(slug, "database-test", "delivery-run");
|
||||
expect(resumed.work!.deliveryId).toBe(delivery.id);
|
||||
const evidence = { provider: "gitea_actions" as const, runId: 1, runNumber: 1, url: "https://gitea.example.com/runs/1", workflowPath: ".gitea/workflows/ci.yml", headSha: "a".repeat(40), conclusion: "success" as const, verifiedAt: new Date().toISOString() };
|
||||
await recordFeatureDeliveryActionValidation(slug, delivery.id, evidence, "database-test");
|
||||
await openFeatureDeliveryPullRequest(slug, delivery.id, { number: 1, url: "https://gitea.example.com/pulls/1", title: "Feature" }, "database-test");
|
||||
await expect(reportAutonomyWork(slug, { token: resumed.work!.token, outcome: "success", summary: "PR opened" }, "database-test")).rejects.toThrow("AUTO_MODE_DELIVERY_NOT_MERGED");
|
||||
expect((await getAutonomyState(slug)).activeLease?.token).toBe(resumed.work!.token);
|
||||
const head = "b".repeat(40);
|
||||
let remoteMerged = false;
|
||||
vi.spyOn(GiteaClient.prototype, "getPullRequest").mockImplementation(async () => ({ number: 1, title: "Feature", html_url: "https://gitea.example.com/pulls/1", state: remoteMerged ? "closed" : "open", merged: remoteMerged, merge_commit_sha: remoteMerged ? "c".repeat(40) : undefined, head: { ref: "chatgpt/existing", sha: head }, base: { ref: "main", sha: "d".repeat(40) }, mergeable: true }));
|
||||
vi.spyOn(GiteaClient.prototype, "getBranch").mockResolvedValue({ name: "chatgpt/existing", commit: { id: head, message: "Repair" }, protected: false });
|
||||
vi.spyOn(GiteaClient.prototype, "getContents").mockResolvedValue({ type: "file", path: "src/feature.ts", name: "feature.ts", sha: "file-sha", size: 4, encoding: "base64", content: "dGVzdA==" });
|
||||
const write = vi.spyOn(GiteaClient.prototype, "updateFile").mockResolvedValue({});
|
||||
vi.spyOn(GiteaClient.prototype, "listActionRuns").mockResolvedValue({ total_count: 1, workflow_runs: [{ id: 2, run_number: 2, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" }] });
|
||||
vi.spyOn(GiteaClient.prototype, "getBranchProtection").mockResolvedValue({ enable_status_check: true, status_check_contexts: ["CI / test"] });
|
||||
vi.spyOn(GiteaClient.prototype, "getCombinedCommitStatus").mockResolvedValue({ state: "success", statuses: [{ context: "CI / test", status: "success" }] });
|
||||
const merge = vi.spyOn(GiteaClient.prototype, "mergePullRequest").mockImplementation(async () => { remoteMerged = true; });
|
||||
const server = createProjectBrainMcpServer(loadEnv({ NODE_ENV: "test", DATABASE_URL: databaseUrl, PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "database-proof-password", PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", PROJECTBRAIN_GITEA_TOKEN: "test-gitea-token-with-at-least-twenty-characters" }), "database-test");
|
||||
const client = new Client({ name: "delivery-recovery-proof", version: "1" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const written = await client.callTool({ name: "write_feature_file", arguments: { project_id: slug, delivery_id: delivery.id, branch: "chatgpt/existing", path: "src/feature.ts", content: "fixed", message: "Repair failing validation", expected_sha: "file-sha" } });
|
||||
expect(written.isError, JSON.stringify(written.content)).not.toBe(true);
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
const repaired = await getFeatureDelivery(slug, delivery.id);
|
||||
expect(repaired.spec.status).toBe("pull_request_open");
|
||||
expect(repaired.spec.pullRequest?.number).toBe(1);
|
||||
expect(repaired.spec.validationEvidence).toBeUndefined();
|
||||
expect(repaired.spec.validationResults).toEqual([]);
|
||||
const synced = await client.callTool({ name: "sync_feature_validation", arguments: { project_id: slug, delivery_id: delivery.id } });
|
||||
expect(synced.isError, JSON.stringify(synced.content)).not.toBe(true);
|
||||
const merged = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } });
|
||||
expect(merged.isError, JSON.stringify(merged.content)).not.toBe(true);
|
||||
expect(merge).toHaveBeenCalledTimes(1);
|
||||
const dbPlan = await prisma.deliveryPlan.findFirstOrThrow({ where: { projectId: project.id, slug: delivery.id } });
|
||||
// Simulate a previous response/webhook interruption after Gitea merged.
|
||||
await prisma.deliveryPlan.update({ where: { id: dbPlan.id }, data: { status: "pull_request_open" } });
|
||||
const replay = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } });
|
||||
expect(replay.isError, JSON.stringify(replay.content)).not.toBe(true);
|
||||
expect(merge).toHaveBeenCalledTimes(1);
|
||||
await client.close();
|
||||
await server.close();
|
||||
const completed = await reportAutonomyWork(slug, { token: resumed.work!.token, outcome: "success", summary: "Validated branch merged" }, "database-test");
|
||||
expect(completed.state.roadmap!.phases[0].items[0].status).toBe("completed");
|
||||
});
|
||||
|
||||
it("retires a removed branch only after proving its recorded work is included in main", async () => {
|
||||
const slug = `autonomy-integrated-${Date.now()}`;
|
||||
slugs.push(slug);
|
||||
await createProject({ id: slug, name: "Integration proof", description: "Isolated database regression" });
|
||||
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
|
||||
await prisma.giteaRepositoryLink.create({ data: { projectId: project.id, owner: "AutonomyProof", repository: slug, defaultBranch: "main", linkedBy: "database-test" } });
|
||||
const delivery = await createFeatureDelivery(slug, { title: "Integrated work", intent: "Repair", problem: "Bug", desiredOutcome: "Fixed", goals: ["fix"], acceptanceCriteria: ["verified"], implementationPlan: ["fix"], validationCommands: ["test"], riskLevel: "low" }, "database-test");
|
||||
await startFeatureDelivery(slug, delivery.id, "chatgpt/integrated", "database-test");
|
||||
await recordFeatureDeliveryProgress(slug, delivery.id, { headSha: "a".repeat(40) }, "database-test");
|
||||
vi.spyOn(GiteaClient.prototype, "getBranch").mockImplementation(async (_owner, _repository, branch) => {
|
||||
if (branch !== "main") throw new GiteaApiError(404, "GITEA_HTTP_404", "Branch removed");
|
||||
return { name: "main", commit: { id: "b".repeat(40) } };
|
||||
});
|
||||
const compare = vi.spyOn(GiteaClient.prototype, "compareCommits").mockResolvedValueOnce({ total_commits: 1 }).mockResolvedValueOnce({ total_commits: 0 });
|
||||
const client = new GiteaClient("https://gitea.example.com", "test-token");
|
||||
expect((await refreshDeliveryFromGitea(client, slug, delivery.id)).spec.status).toBe("executing");
|
||||
const integrated = await refreshDeliveryFromGitea(client, slug, delivery.id);
|
||||
expect(integrated.spec.status).toBe("superseded");
|
||||
expect(integrated.spec.validationEvidence).toBeUndefined();
|
||||
expect(compare).toHaveBeenLastCalledWith("AutonomyProof", slug, "b".repeat(40), "a".repeat(40));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { createProject } from "../src/repositories/projectsRepository.js";
|
||||
import { reconcilePortfolioRepositories } from "../src/services/portfolioControlPlane.js";
|
||||
import { PROJECT_REPOSITORY_PROVISION_ACTION, provisionProjectRepository } from "../src/services/projectRepositoryProvisioning.js";
|
||||
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
|
||||
const databaseDescribe = databaseUrl ? describe : describe.skip;
|
||||
const owner = "ProjectBrainSyncProof";
|
||||
const createdProjectSlugs: string[] = [];
|
||||
|
||||
const env = () => loadEnv({
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
DATABASE_URL: databaseUrl,
|
||||
PROJECTBRAIN_DATA_SOURCE: "database",
|
||||
PROJECTBRAIN_OWNER_USERNAME: "database-owner",
|
||||
PROJECTBRAIN_OWNER_PASSWORD: "database-owner-password-value",
|
||||
PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters",
|
||||
PROJECTBRAIN_GITEA_URL: "https://gitea.example.com",
|
||||
PROJECTBRAIN_GITEA_TOKEN: "database-gitea-token-with-twenty-characters",
|
||||
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: "true",
|
||||
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: "60",
|
||||
});
|
||||
|
||||
const repositoryPayload = (name: string) => ({
|
||||
id: name.length * 100,
|
||||
name,
|
||||
full_name: `${owner}/${name}`,
|
||||
description: `Repository sync proof for ${name}`,
|
||||
private: true,
|
||||
archived: false,
|
||||
default_branch: "main",
|
||||
html_url: `https://gitea.example.com/${owner}/${name}`,
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
owner: { login: owner },
|
||||
permissions: { admin: true, push: true, pull: true },
|
||||
});
|
||||
|
||||
const removeProofData = async () => {
|
||||
await prisma.portfolioRepository.deleteMany({ where: { owner } });
|
||||
if (createdProjectSlugs.length) await prisma.project.deleteMany({ where: { slug: { in: createdProjectSlugs } } });
|
||||
};
|
||||
|
||||
databaseDescribe("bidirectional ProjectBrain and Gitea repository sync", () => {
|
||||
beforeAll(removeProofData);
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await removeProofData();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("provisions and links one initialized Gitea repository for a new ProjectBrain project", async () => {
|
||||
const slug = `pb-to-gitea-${Date.now().toString(36)}`;
|
||||
createdProjectSlugs.push(slug);
|
||||
await createProject({
|
||||
id: slug,
|
||||
name: "ProjectBrain to Gitea proof",
|
||||
description: "Verifies durable repository provisioning from a ProjectBrain project.",
|
||||
primaryGoal: "Create exactly one linked repository",
|
||||
});
|
||||
const remote = repositoryPayload(slug);
|
||||
let repositoryExists = false;
|
||||
const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
if (url.endsWith("/api/v1/user")) return new Response(JSON.stringify({ id: 1, login: owner }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
if (url.endsWith("/api/v1/user/repos") && init?.method === "POST") {
|
||||
repositoryExists = true;
|
||||
return new Response(JSON.stringify(remote), { status: 201, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
if (url.endsWith(`/api/v1/repos/${owner}/${slug}`) && repositoryExists) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } });
|
||||
return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
|
||||
const first = await provisionProjectRepository(env(), slug, "database:provisioning-proof");
|
||||
const second = await provisionProjectRepository(env(), slug, "database:provisioning-proof");
|
||||
|
||||
expect(first).toMatchObject({ status: "ready", created: true, repository: { fullName: `${owner}/${slug}`, isPrimary: true } });
|
||||
expect(second).toMatchObject({ status: "ready", created: false, repository: { fullName: `${owner}/${slug}` } });
|
||||
expect(fetcher.mock.calls.filter(([input, init]) => String(input).endsWith("/api/v1/user/repos") && init?.method === "POST")).toHaveLength(1);
|
||||
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
|
||||
expect(await prisma.giteaRepositoryLink.count({ where: { projectId: project.id } })).toBe(1);
|
||||
expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "succeeded" } })).toBe(1);
|
||||
expect(await prisma.portfolioRepository.findUnique({ where: { owner_repository: { owner, repository: slug } } })).toMatchObject({ projectId: project.id, status: "onboarded" });
|
||||
}, 15_000);
|
||||
|
||||
it("discovers a new Gitea repository and creates its linked ProjectBrain project", async () => {
|
||||
const name = `gitea-to-pb-${Date.now().toString(36)}`;
|
||||
createdProjectSlugs.push(name);
|
||||
const remote = repositoryPayload(name);
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
if (url.includes("/api/v1/user/repos")) {
|
||||
const page = new URL(url).searchParams.get("page");
|
||||
return new Response(JSON.stringify(page === "1" ? [remote] : []), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
if (url.endsWith(`/api/v1/repos/${owner}/${name}`)) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } });
|
||||
return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
|
||||
}));
|
||||
|
||||
const reconciliation = await reconcilePortfolioRepositories(env(), "database:discovery-proof", {
|
||||
dryRun: false,
|
||||
repositoryFullNames: [`${owner}/${name}`],
|
||||
});
|
||||
|
||||
expect(reconciliation).toMatchObject({ candidateCount: 1, onboardedCount: 1, failedCount: 0 });
|
||||
const project = await prisma.project.findUniqueOrThrow({ where: { slug: name } });
|
||||
expect(await prisma.giteaRepositoryLink.findUnique({ where: { owner_repository: { owner, repository: name } } })).toMatchObject({ projectId: project.id, syncStatus: "ready" });
|
||||
expect(await prisma.portfolioRepository.findUnique({ where: { owner_repository: { owner, repository: name } } })).toMatchObject({ projectId: project.id, status: "onboarded" });
|
||||
}, 15_000);
|
||||
|
||||
it("resumes an ambiguous create without issuing a duplicate repository mutation", async () => {
|
||||
const slug = `pb-retry-${Date.now().toString(36)}`;
|
||||
createdProjectSlugs.push(slug);
|
||||
await createProject({
|
||||
id: slug,
|
||||
name: "ProjectBrain repository retry proof",
|
||||
description: "Verifies that an uncertain Gitea create is resumed without a duplicate repository.",
|
||||
primaryGoal: "Resume one durable provisioning operation",
|
||||
});
|
||||
const remote = repositoryPayload(slug);
|
||||
let repositoryExists = false;
|
||||
const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
if (url.endsWith("/api/v1/user")) return new Response(JSON.stringify({ id: 1, login: owner }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
if (url.endsWith("/api/v1/user/repos") && init?.method === "POST") {
|
||||
repositoryExists = true;
|
||||
return new Response(JSON.stringify({ message: "upstream result unknown" }), { status: 503, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
if (url.endsWith(`/api/v1/repos/${owner}/${slug}`) && repositoryExists) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } });
|
||||
return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
|
||||
await expect(provisionProjectRepository(env(), slug, "database:retry-proof")).rejects.toMatchObject({ status: 503, code: "GITEA_HTTP_503" });
|
||||
const resumed = await provisionProjectRepository(env(), slug, "database:retry-proof");
|
||||
|
||||
expect(resumed).toMatchObject({ status: "ready", created: false, repository: { fullName: `${owner}/${slug}` } });
|
||||
expect(fetcher.mock.calls.filter(([input, init]) => String(input).endsWith("/api/v1/user/repos") && init?.method === "POST")).toHaveLength(1);
|
||||
const project = await prisma.project.findUniqueOrThrow({ where: { slug } });
|
||||
expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "failed" } })).toBe(1);
|
||||
expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "succeeded" } })).toBe(1);
|
||||
expect(await prisma.giteaRepositoryLink.count({ where: { projectId: project.id } })).toBe(1);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findReusableDeliveryPullRequest } from "../src/integrations/gitea/policy.js";
|
||||
import type { GiteaPullRequest } from "../src/integrations/gitea/types.js";
|
||||
|
||||
const head = "a".repeat(40);
|
||||
const merged: GiteaPullRequest = {
|
||||
id: 31, number: 31, title: "M18 evidence", state: "closed", merged: true,
|
||||
html_url: "https://example.test/pulls/31", created_at: "2026-09-07T00:00:00Z", updated_at: "2026-09-08T00:00:00Z",
|
||||
merge_commit_sha: "b".repeat(40), head: { ref: "chatgpt/evidence", sha: head }, base: { ref: "main", sha: "b".repeat(40) },
|
||||
};
|
||||
const select = (prs: GiteaPullRequest[]) => findReusableDeliveryPullRequest(prs, "chatgpt/evidence", "main", head);
|
||||
|
||||
describe("existing delivery PR reconciliation", () => {
|
||||
it("adopts the exact already-merged PR instead of opening an empty replacement", () => {
|
||||
expect(select([merged])).toBe(merged);
|
||||
});
|
||||
it("reuses an exact open review", () => {
|
||||
const open = { ...merged, merged: false, state: "open", merge_commit_sha: undefined };
|
||||
expect(select([open])).toBe(open);
|
||||
});
|
||||
it.each([
|
||||
{ ...merged, head: { ...merged.head, sha: "c".repeat(40) } },
|
||||
{ ...merged, head: { ...merged.head, ref: "chatgpt/other" } },
|
||||
{ ...merged, base: { ...merged.base, ref: "other" } },
|
||||
{ ...merged, merged: false },
|
||||
])("does not adopt stale, foreign or abandoned reviews", (pr) => {
|
||||
expect(select([pr])).toBeUndefined();
|
||||
});
|
||||
it("rejects ambiguous matching reviews", () => {
|
||||
expect(() => select([merged, { ...merged, id: 32, number: 32 }])).toThrow("AMBIGUOUS");
|
||||
});
|
||||
it("requires actual merge commit evidence", () => {
|
||||
expect(() => select([{ ...merged, merge_commit_sha: undefined }])).toThrow("MERGE_EVIDENCE_REQUIRED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertFeatureDeliveryTransition, canTransitionFeatureDeliveryStatus } from "../src/repositories/featureDeliveryRepository.js";
|
||||
|
||||
describe("feature delivery lifecycle invariants", () => {
|
||||
it("allows only forward operational progress and explicit repair paths", () => {
|
||||
expect(canTransitionFeatureDeliveryStatus("executing", "validating")).toBe(true);
|
||||
expect(canTransitionFeatureDeliveryStatus("pull_request_open", "merged")).toBe(true);
|
||||
expect(canTransitionFeatureDeliveryStatus("merged", "verified")).toBe(true);
|
||||
expect(canTransitionFeatureDeliveryStatus("verified", "released")).toBe(true);
|
||||
expect(canTransitionFeatureDeliveryStatus("released", "executing")).toBe(false);
|
||||
expect(canTransitionFeatureDeliveryStatus("verified", "awaiting_approval")).toBe(false);
|
||||
expect(canTransitionFeatureDeliveryStatus("superseded", "approved")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a transition that would reactivate superseded or released work", () => {
|
||||
expect(() => assertFeatureDeliveryTransition("superseded", "executing")).toThrow("FEATURE_DELIVERY_INVALID_TRANSITION");
|
||||
expect(() => assertFeatureDeliveryTransition("released", "approved")).toThrow("FEATURE_DELIVERY_INVALID_TRANSITION");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
oidcLoginAttempt: { deleteMany: vi.fn(), create: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
|
||||
externalIdentity: { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn(), deleteMany: vi.fn() },
|
||||
user: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||
transaction: vi.fn(async (input: unknown) => Array.isArray(input) ? Promise.all(input) : undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../src/db/prisma.js", () => ({ prisma: { oidcLoginAttempt: mocks.oidcLoginAttempt, externalIdentity: mocks.externalIdentity, user: mocks.user, $transaction: mocks.transaction } }));
|
||||
const { oidcLoginAttempt, externalIdentity, user } = mocks;
|
||||
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { consumeEntraAttempt, createEntraAuthorization, exchangeEntraCode, getEntraLinkStatus, isEntraConfigured, linkEntraIdentity, resolveEntraUser, unlinkEntraIdentity } from "../src/security/entraOidc.js";
|
||||
|
||||
const tenantId = "11111111-1111-4111-8111-111111111111";
|
||||
const clientId = "22222222-2222-4222-8222-222222222222";
|
||||
const baseInput = {
|
||||
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_PUBLIC_URL: "https://projectbrain.example.com",
|
||||
PROJECTBRAIN_ENTRA_TENANT_ID: tenantId, PROJECTBRAIN_ENTRA_CLIENT_ID: clientId, PROJECTBRAIN_ENTRA_CLIENT_SECRET: "unit-test-client-secret",
|
||||
};
|
||||
const env = () => loadEnv(baseInput);
|
||||
const profile = { tenantId, subject: "33333333-3333-4333-8333-333333333333", displayName: "Entra Owner", preferredUsername: "owner@example.com" };
|
||||
const storedUser = { id: "owner-id", username: "owner", role: "owner" as const, credentialVersion: 1, disabledAt: null };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
oidcLoginAttempt.deleteMany.mockResolvedValue({ count: 0 });
|
||||
oidcLoginAttempt.create.mockResolvedValue({ id: "attempt-id" });
|
||||
oidcLoginAttempt.delete.mockResolvedValue({ id: "attempt-id" });
|
||||
externalIdentity.update.mockResolvedValue({ id: "identity-id" });
|
||||
externalIdentity.upsert.mockResolvedValue({ id: "identity-id" });
|
||||
externalIdentity.deleteMany.mockResolvedValue({ count: 1 });
|
||||
user.update.mockResolvedValue(storedUser);
|
||||
user.findUniqueOrThrow.mockResolvedValue(storedUser);
|
||||
});
|
||||
|
||||
describe("Microsoft Entra OIDC service", () => {
|
||||
it("creates a tenant-bound authorization URL with PKCE and stores only hashed state", async () => {
|
||||
expect(isEntraConfigured(env())).toBe(true);
|
||||
expect(isEntraConfigured(loadEnv({ ...baseInput, PROJECTBRAIN_ENTRA_TENANT_ID: undefined, PROJECTBRAIN_ENTRA_CLIENT_ID: undefined, PROJECTBRAIN_ENTRA_CLIENT_SECRET: undefined }))).toBe(false);
|
||||
const authorization = new URL(await createEntraAuthorization(env(), { purpose: "login", returnTo: "//evil.example/path" }));
|
||||
expect(authorization.hostname).toBe("login.microsoftonline.com");
|
||||
expect(authorization.pathname).toContain(tenantId);
|
||||
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
const rawState = authorization.searchParams.get("state")!;
|
||||
const stored = oidcLoginAttempt.create.mock.calls[0][0].data;
|
||||
expect(stored.stateHash).not.toBe(rawState);
|
||||
expect(stored.stateHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(stored.returnTo).toBe("/");
|
||||
await expect(createEntraAuthorization(env(), { purpose: "link" })).rejects.toThrow("ENTRA_LINK_USER_REQUIRED");
|
||||
});
|
||||
|
||||
it("consumes valid state exactly once and rejects missing or expired attempts", async () => {
|
||||
oidcLoginAttempt.findUnique.mockResolvedValueOnce(null);
|
||||
expect(await consumeEntraAttempt(env(), "missing-state")).toBeUndefined();
|
||||
oidcLoginAttempt.findUnique.mockResolvedValueOnce({ id: "expired", provider: "microsoft-entra", purpose: "login", userId: null, codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings", expiresAt: new Date(Date.now() - 1_000) });
|
||||
expect(await consumeEntraAttempt(env(), "expired-state")).toBeUndefined();
|
||||
oidcLoginAttempt.findUnique.mockResolvedValueOnce({ id: "valid", provider: "microsoft-entra", purpose: "link", userId: "owner-id", codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings", expiresAt: new Date(Date.now() + 60_000) });
|
||||
await expect(consumeEntraAttempt(env(), "valid-state")).resolves.toEqual({ purpose: "link", userId: "owner-id", codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings" });
|
||||
expect(oidcLoginAttempt.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("validates Microsoft signature, issuer, audience, tenant, nonce and oid", async () => {
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const publicJwk = { ...await exportJWK(publicKey), kid: "unit-key", alg: "RS256", use: "sig" };
|
||||
const sign = (nonce: string, oid = profile.subject) => new SignJWT({ tid: tenantId, oid, nonce, name: profile.displayName, preferred_username: profile.preferredUsername })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "unit-key" }).setIssuer(`https://login.microsoftonline.com/${tenantId}/v2.0`).setAudience(clientId).setIssuedAt().setExpirationTime("5m").sign(privateKey);
|
||||
const idToken = await sign("expected-nonce");
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
return url.endsWith("/token")
|
||||
? new Response(JSON.stringify({ id_token: idToken }), { status: 200, headers: { "content-type": "application/json" } })
|
||||
: new Response(JSON.stringify({ keys: [publicJwk] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}));
|
||||
try {
|
||||
await expect(exchangeEntraCode(env(), "code", { purpose: "login", codeVerifier: "verifier", nonce: "expected-nonce", returnTo: "/" })).resolves.toEqual(profile);
|
||||
await expect(exchangeEntraCode(env(), "code", { purpose: "login", codeVerifier: "verifier", nonce: "wrong-nonce", returnTo: "/" })).rejects.toThrow("ENTRA_NONCE_INVALID");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("resolves, links, reports and unlinks only durable mapped identities", async () => {
|
||||
externalIdentity.findUnique.mockResolvedValueOnce(null);
|
||||
expect(await resolveEntraUser(profile)).toBeUndefined();
|
||||
externalIdentity.findUnique.mockResolvedValueOnce({ id: "identity-id", userId: storedUser.id, user: storedUser });
|
||||
await expect(resolveEntraUser(profile)).resolves.toEqual({ id: storedUser.id, username: "owner", role: "owner", credentialVersion: 1 });
|
||||
externalIdentity.findUnique.mockResolvedValueOnce({ id: "foreign", userId: "other-user" });
|
||||
await expect(linkEntraIdentity(storedUser.id, profile)).rejects.toThrow("ENTRA_IDENTITY_ALREADY_LINKED");
|
||||
externalIdentity.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(linkEntraIdentity(storedUser.id, profile)).resolves.toMatchObject({ id: storedUser.id });
|
||||
externalIdentity.findUnique.mockResolvedValueOnce({ displayName: profile.displayName, preferredUsername: profile.preferredUsername });
|
||||
await expect(getEntraLinkStatus(storedUser.id)).resolves.toEqual({ linked: true, displayName: profile.displayName, preferredUsername: profile.preferredUsername });
|
||||
await unlinkEntraIdentity(storedUser.id);
|
||||
expect(externalIdentity.deleteMany).toHaveBeenCalledWith({ where: { provider: "microsoft-entra", userId: storedUser.id } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApprovalState, DeliveryStatus, DeliveryValidationStatus } from "@prisma/client";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const tx = {
|
||||
deliveryPlan: { create: vi.fn(), update: vi.fn() },
|
||||
initiative: { update: vi.fn() },
|
||||
outboxEvent: { create: vi.fn() },
|
||||
deliveryRun: { create: vi.fn(), update: vi.fn() },
|
||||
deliveryValidation: { create: vi.fn(), deleteMany: vi.fn() },
|
||||
findingInvestigation: { findUnique: vi.fn(), update: vi.fn() },
|
||||
repositoryFinding: { update: vi.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
project: { findFirst: vi.fn() },
|
||||
initiative: { findFirstOrThrow: vi.fn() },
|
||||
deliveryPlan: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
giteaRepositoryLink: { findFirst: vi.fn() },
|
||||
deliveryRun: { findFirst: vi.fn() },
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
return {
|
||||
prisma,
|
||||
tx,
|
||||
createInitiativeForProject: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/db/prisma.js", () => ({ prisma: mocks.prisma }));
|
||||
vi.mock("../src/repositories/initiativesRepository.js", () => ({ createInitiativeForProject: mocks.createInitiativeForProject }));
|
||||
|
||||
import { createFeatureDelivery, startFeatureDelivery, recordFeatureDeliveryActionValidation, reportedValidationResult, type FeatureDeliveryInput } from "../src/repositories/featureDeliveryRepository.js";
|
||||
|
||||
const baseInput = (riskLevel: FeatureDeliveryInput["riskLevel"]): FeatureDeliveryInput => ({
|
||||
title: "Intent-first onboarding",
|
||||
intent: "Make onboarding understandable for nontechnical users.",
|
||||
priority: "normal",
|
||||
problem: "The current onboarding exposes too many technical concepts.",
|
||||
desiredOutcome: "A short intent-first onboarding flow.",
|
||||
users: ["new users"],
|
||||
goals: ["reduce cognitive load"],
|
||||
nonGoals: ["remove technical capabilities"],
|
||||
acceptanceCriteria: ["normal users can create a project without understanding MCP or Git internals"],
|
||||
implementationPlan: ["simplify the primary flow", "preserve technical drill-downs"],
|
||||
risks: ["navigation changes can affect deep links"],
|
||||
validationCommands: ["pnpm test"],
|
||||
riskLevel,
|
||||
});
|
||||
|
||||
const plan = (approvalState: ApprovalState, status: DeliveryStatus) => ({
|
||||
id: "delivery-db-1",
|
||||
slug: "delivery-1",
|
||||
projectId: "project-db-1",
|
||||
initiativeId: "initiative-db-1",
|
||||
title: "Intent-first onboarding",
|
||||
intent: "Make onboarding understandable for nontechnical users.",
|
||||
priority: "normal",
|
||||
problem: "The current onboarding exposes too many technical concepts.",
|
||||
desiredOutcome: "A short intent-first onboarding flow.",
|
||||
usersJson: ["new users"],
|
||||
goalsJson: ["reduce cognitive load"],
|
||||
nonGoalsJson: [],
|
||||
acceptanceCriteriaJson: ["normal users understand the flow"],
|
||||
implementationPlanJson: ["simplify the primary flow"],
|
||||
risksJson: ["navigation changes"],
|
||||
validationCommandsJson: ["pnpm test"],
|
||||
status,
|
||||
approvalState,
|
||||
approvedAt: approvalState === ApprovalState.approved ? new Date("2026-08-31T20:00:00.000Z") : null,
|
||||
approvedBy: approvalState === ApprovalState.approved ? "chatgpt" : null,
|
||||
reviewFeedback: null,
|
||||
version: 1,
|
||||
createdBy: "chatgpt",
|
||||
updatedBy: "chatgpt",
|
||||
createdAt: new Date("2026-08-31T20:00:00.000Z"),
|
||||
updatedAt: new Date("2026-08-31T20:00:00.000Z"),
|
||||
initiative: null,
|
||||
runs: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.prisma.project.findFirst.mockResolvedValue({ id: "project-db-1", slug: "projectbrain" });
|
||||
mocks.createInitiativeForProject.mockResolvedValue({ id: "initiative-1" });
|
||||
mocks.prisma.initiative.findFirstOrThrow.mockResolvedValue({ id: "initiative-db-1", slug: "initiative-1" });
|
||||
mocks.tx.initiative.update.mockResolvedValue({});
|
||||
mocks.tx.outboxEvent.create.mockResolvedValue({});
|
||||
mocks.tx.findingInvestigation.findUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("feature delivery risk authorization", () => {
|
||||
it.each([
|
||||
["low", "AUTO_ALLOWED"],
|
||||
["medium", "AUTO_ALLOWED_WITH_AUDIT"],
|
||||
] as const)("auto-authorizes %s reversible work", async (riskLevel, policyDecision) => {
|
||||
const created = plan(ApprovalState.approved, DeliveryStatus.approved);
|
||||
mocks.tx.deliveryPlan.create.mockResolvedValue(created);
|
||||
|
||||
const result = await createFeatureDelivery("projectbrain", baseInput(riskLevel), "chatgpt");
|
||||
|
||||
expect(result.approvalState).toBe(ApprovalState.approved);
|
||||
expect(result.spec.status).toBe(DeliveryStatus.approved);
|
||||
expect(mocks.tx.deliveryPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
approvalState: ApprovalState.approved,
|
||||
status: DeliveryStatus.approved,
|
||||
}),
|
||||
}));
|
||||
expect(mocks.tx.outboxEvent.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
eventType: "delivery.plan.auto_authorized",
|
||||
payloadJson: expect.objectContaining({ riskLevel, policyDecision }),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it.each(["high", "destructive"] as const)("requires a human decision for %s work", async (riskLevel) => {
|
||||
const created = plan(ApprovalState.pending, DeliveryStatus.awaiting_approval);
|
||||
mocks.tx.deliveryPlan.create.mockResolvedValue(created);
|
||||
|
||||
const result = await createFeatureDelivery("projectbrain", baseInput(riskLevel), "chatgpt");
|
||||
|
||||
expect(result.approvalState).toBe(ApprovalState.pending);
|
||||
expect(result.spec.status).toBe(DeliveryStatus.awaiting_approval);
|
||||
expect(mocks.tx.deliveryPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
approvalState: ApprovalState.pending,
|
||||
status: DeliveryStatus.awaiting_approval,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
}),
|
||||
}));
|
||||
expect(mocks.tx.outboxEvent.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
eventType: "delivery.plan.user_decision_required",
|
||||
payloadJson: expect.objectContaining({ riskLevel, policyDecision: "USER_DECISION_REQUIRED" }),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("fails closed when a pending high-risk delivery tries to start repository execution", async () => {
|
||||
mocks.prisma.deliveryPlan.findFirst.mockResolvedValue(plan(ApprovalState.pending, DeliveryStatus.awaiting_approval));
|
||||
|
||||
await expect(startFeatureDelivery("projectbrain", "delivery-1", "chatgpt/high-risk-change"))
|
||||
.rejects.toThrow("FEATURE_DELIVERY_APPROVAL_REQUIRED");
|
||||
|
||||
expect(mocks.prisma.giteaRepositoryLink.findFirst).not.toHaveBeenCalled();
|
||||
expect(mocks.tx.deliveryRun.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Actions evidence scope", () => {
|
||||
it("records only the observed workflow, preserving separately reported checks", async () => {
|
||||
const current = { ...plan(ApprovalState.approved, DeliveryStatus.validating),
|
||||
validationCommandsJson: ["dotnet test", "CI / build-test (pull_request)", "Live disposable provider smoke"],
|
||||
runs: [{ id: "run-1", validations: [], changes: [] }] };
|
||||
mocks.prisma.deliveryPlan.findFirst.mockResolvedValue(current);
|
||||
mocks.tx.deliveryPlan.update.mockResolvedValue(current);
|
||||
await recordFeatureDeliveryActionValidation("projectbrain", "delivery-1", {
|
||||
provider: "gitea_actions", runId: 9205, runNumber: 500, url: "https://example.test/actions/runs/9205",
|
||||
workflowPath: "managed-validation.yml@refs/heads/chatgpt/work", headSha: "a".repeat(40),
|
||||
conclusion: "success", verifiedAt: "2026-09-08T00:00:00Z",
|
||||
});
|
||||
expect(mocks.tx.deliveryValidation.create).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.tx.deliveryValidation.create).toHaveBeenCalledWith({ data: expect.objectContaining({
|
||||
command: "Gitea Actions: managed-validation.yml@refs/heads/chatgpt/work", status: "passed", headSha: "a".repeat(40),
|
||||
}) });
|
||||
expect(mocks.tx.deliveryValidation.deleteMany).toHaveBeenCalledWith({ where: { deliveryRunId: "run-1", provider: "gitea_actions" } });
|
||||
});
|
||||
|
||||
it.each(["CI / build-test (pull_request)", "Live provider smoke", "dotnet test"])("does not present legacy inferred %s as execution", (command) => {
|
||||
expect(reportedValidationResult({ command, status: DeliveryValidationStatus.passed, provider: "gitea_actions", summary: "managed-validation.yml" }))
|
||||
.toEqual({ command, status: "not_run", summary: expect.stringContaining("Legacy inferred result") });
|
||||
});
|
||||
|
||||
it("preserves explicit reports and directly observed workflow results", () => {
|
||||
expect(reportedValidationResult({ command: "Live provider smoke", status: DeliveryValidationStatus.skipped, provider: null, summary: "Credentials unavailable" }).status).toBe("not_run");
|
||||
expect(reportedValidationResult({ command: "Local contract suite", status: DeliveryValidationStatus.failed, provider: null, summary: "Failed" }).status).toBe("failed");
|
||||
expect(reportedValidationResult({ command: "CI / build-test (pull_request)", status: DeliveryValidationStatus.failed, provider: "gitea_actions", summary: "ci.yml" }).status).toBe("failed");
|
||||
expect(reportedValidationResult({ command: "Gitea Actions: ci.yml", status: DeliveryValidationStatus.passed, provider: "gitea_actions", summary: "ci.yml" }).status).toBe("passed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { assertDeliveryWriteAllowed, canRepairFeatureDelivery } from "../src/services/featureDeliveryFlow.js";
|
||||
import type { FeatureDeliveryDto } from "../src/repositories/featureDeliveryRepository.js";
|
||||
import type { GiteaClient } from "../src/integrations/gitea/client.js";
|
||||
|
||||
describe("repairing a delivery after PR validation fails", () => {
|
||||
const repository = { owner: "owner", repository: "repo", defaultBranch: "main" };
|
||||
const delivery = { approvalState: "approved", spec: { status: "pull_request_open", branch: "chatgpt/existing", pullRequest: { number: 12 } } } as FeatureDeliveryDto;
|
||||
it("allows repair on the existing open PR, while terminal deliveries stay immutable", async () => {
|
||||
const getPullRequest = vi.fn().mockResolvedValue({ state: "open", merged: false, head: { ref: "chatgpt/existing" }, base: { ref: "main" } });
|
||||
await expect(assertDeliveryWriteAllowed(delivery, "chatgpt/existing", { getPullRequest } as unknown as GiteaClient, repository)).resolves.toBeUndefined();
|
||||
expect(getPullRequest).toHaveBeenCalledWith("owner", "repo", 12);
|
||||
expect(canRepairFeatureDelivery("merged")).toBe(false);
|
||||
expect(canRepairFeatureDelivery("failed")).toBe(false);
|
||||
});
|
||||
it("refuses writes if Gitea merged the PR before ProjectBrain saw its webhook", async () => {
|
||||
const getPullRequest = vi.fn().mockResolvedValue({ state: "closed", merged: true });
|
||||
await expect(assertDeliveryWriteAllowed(delivery, "chatgpt/existing", { getPullRequest } as unknown as GiteaClient, repository)).rejects.toThrow("FEATURE_DELIVERY_PULL_REQUEST_NOT_WRITABLE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const repositoryFinding = { findUnique: vi.fn(), findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), create: vi.fn() };
|
||||
const findingOccurrence = { upsert: vi.fn() };
|
||||
const giteaIssueLink = { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn() };
|
||||
const outboxEvent = { create: vi.fn() };
|
||||
const findingAutomationPolicy = { upsert: vi.fn() };
|
||||
const tx = { repositoryFinding, findingOccurrence, giteaIssueLink, outboxEvent };
|
||||
const transaction = vi.fn(async (operation: unknown) => typeof operation === "function" ? operation(tx) : Promise.all(operation as Promise<unknown>[]));
|
||||
const issueClient = { createIssueComment: vi.fn(), editIssue: vi.fn() };
|
||||
const analysisJobItem = { findMany: vi.fn() };
|
||||
const findingInvestigation = { findMany: vi.fn(), update: vi.fn() };
|
||||
return { repositoryFinding, findingOccurrence, giteaIssueLink, outboxEvent, findingAutomationPolicy, transaction, issueClient, analysisJobItem, findingInvestigation };
|
||||
});
|
||||
|
||||
vi.mock("../src/db/prisma.js", () => ({ prisma: {
|
||||
repositoryFinding: mocks.repositoryFinding,
|
||||
findingOccurrence: mocks.findingOccurrence,
|
||||
giteaIssueLink: mocks.giteaIssueLink,
|
||||
outboxEvent: mocks.outboxEvent,
|
||||
findingAutomationPolicy: mocks.findingAutomationPolicy,
|
||||
$transaction: mocks.transaction,
|
||||
analysisJobItem: mocks.analysisJobItem,
|
||||
findingInvestigation: mocks.findingInvestigation,
|
||||
} }));
|
||||
vi.mock("../src/integrations/gitea/service.js", () => ({ getGiteaIssueClient: () => mocks.issueClient }));
|
||||
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { closeAutomaticallyResolvedFindingIssues, finalizeFindingVerificationForJob, reconcileFindingIssueWebhook, reconcileRepositoryFindings } from "../src/repositories/findingRepository.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_GITEA_URL: "https://gitea.example.com",
|
||||
PROJECTBRAIN_GITEA_TOKEN: "read-token-with-at-least-twenty-characters",
|
||||
PROJECTBRAIN_GITEA_WRITE_TOKEN: "write-token-with-at-least-twenty-characters",
|
||||
});
|
||||
|
||||
const legacyFinding = {
|
||||
id: "finding-legacy",
|
||||
fingerprint: "legacy-fingerprint",
|
||||
projectId: "project-1",
|
||||
repositoryLinkId: "link-1",
|
||||
owner: "Acme",
|
||||
repository: "Repo",
|
||||
ruleCode: "LATEST_ACTION_FAILED",
|
||||
subject: "repository",
|
||||
title: "Legacy",
|
||||
message: "Legacy action failure",
|
||||
severity: "critical",
|
||||
confidence: 95,
|
||||
riskScore: 90,
|
||||
status: "issue_open",
|
||||
occurrenceCount: 10,
|
||||
clearCount: 0,
|
||||
firstSeenAt: new Date("2026-08-20T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-08-27T00:00:00Z"),
|
||||
resolvedAt: null,
|
||||
suppressedAt: null,
|
||||
suppressedUntil: null,
|
||||
suppressionReason: null,
|
||||
lastAnalysisJobId: "job-old",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findingOccurrence.upsert.mockResolvedValue({ id: "occurrence-1" });
|
||||
mocks.giteaIssueLink.upsert.mockResolvedValue({ id: "issue-link-1" });
|
||||
mocks.outboxEvent.create.mockResolvedValue({ id: "outbox-1" });
|
||||
});
|
||||
|
||||
describe("finding reconciliation lifecycle", () => {
|
||||
it.each(["DEFAULT_BRANCH_ACTION_FAILED", "DEFAULT_BRANCH_UNPROTECTED", "OPEN_PR_BACKLOG", "TOPIC_BRANCH_ACTION_FAILED"])("does not clear an unevaluated %s finding", async (ruleCode) => {
|
||||
mocks.repositoryFinding.findMany.mockResolvedValue([{ ...legacyFinding, ruleCode, clearCount: 1 }]);
|
||||
for (const evaluatedRuleCodes of [undefined, ["REPOSITORY_ARCHIVED"]]) {
|
||||
await reconcileRepositoryFindings({ jobId: "partial-job", analysisJobItemId: "partial-item",
|
||||
owner: "Acme", repository: "Repo", defaultBranch: "main", findings: [], evaluatedRuleCodes });
|
||||
}
|
||||
expect(mocks.repositoryFinding.update).not.toHaveBeenCalled();
|
||||
expect(mocks.findingOccurrence.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still resolves after two distinct explicitly evaluated clean scans", async () => {
|
||||
const current = { ...legacyFinding, ruleCode: "DEFAULT_BRANCH_ACTION_FAILED" };
|
||||
mocks.repositoryFinding.findMany.mockImplementation(async () => [current]);
|
||||
mocks.repositoryFinding.update.mockImplementation(async ({ data }) => Object.assign(current, data));
|
||||
for (const jobId of ["clean-one", "clean-two"]) {
|
||||
await reconcileRepositoryFindings({ jobId, analysisJobItemId: jobId, owner: "Acme", repository: "Repo",
|
||||
defaultBranch: "main", findings: [], evaluatedRuleCodes: [current.ruleCode] });
|
||||
expect(current.clearCount).toBe(jobId === "clean-one" ? 1 : 2);
|
||||
expect(current.status).toBe(jobId === "clean-one" ? "issue_open" : "resolved");
|
||||
}
|
||||
expect(mocks.findingOccurrence.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["failed", "pending", "running", "cancelled"])("never verifies a %s analysis item even with a clean occurrence", async (status) => {
|
||||
mocks.analysisJobItem.findMany.mockResolvedValue([{ status, findingOccurrences: [{ findingId: "finding-legacy", observed: false }] }]);
|
||||
expect(await finalizeFindingVerificationForJob(env, "job")).toEqual([]);
|
||||
expect(mocks.findingInvestigation.findMany).not.toHaveBeenCalled();
|
||||
expect(mocks.repositoryFinding.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("requires an explicit clean occurrence for the exact finding (present: %s)", async (present) => {
|
||||
mocks.analysisJobItem.findMany.mockResolvedValue([{ status: "succeeded", owner: "Acme", repository: "Repo",
|
||||
findingOccurrences: [{ findingId: present ? "finding-legacy" : "other-finding", observed: false }] }]);
|
||||
mocks.findingInvestigation.findMany.mockResolvedValue([{ id: "investigation", findingId: "finding-legacy",
|
||||
finding: { projectId: null, issueLink: null }, deliveryPlan: null }]);
|
||||
mocks.repositoryFinding.update.mockResolvedValue({});
|
||||
const result = await finalizeFindingVerificationForJob(env, "job");
|
||||
expect(result).toEqual(present ? [{ findingId: "finding-legacy", resolved: true }] : []);
|
||||
expect(mocks.repositoryFinding.update).toHaveBeenCalledTimes(present ? 1 : 0);
|
||||
});
|
||||
it("migrates an active legacy critical in place and preserves its history and issue identity", async () => {
|
||||
mocks.repositoryFinding.findUnique.mockResolvedValueOnce(null);
|
||||
mocks.repositoryFinding.findFirst.mockResolvedValueOnce(legacyFinding);
|
||||
mocks.repositoryFinding.update
|
||||
.mockImplementationOnce(async ({ data }: { data: Record<string, unknown> }) => ({ ...legacyFinding, ...data }))
|
||||
.mockImplementationOnce(async ({ data }: { data: Record<string, unknown> }) => ({ ...legacyFinding, ...data, id: legacyFinding.id }));
|
||||
mocks.repositoryFinding.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const reconciled = await reconcileRepositoryFindings({
|
||||
jobId: "job-new",
|
||||
analysisJobItemId: "item-new",
|
||||
projectId: "project-1",
|
||||
repositoryLinkId: "link-1",
|
||||
owner: "Acme",
|
||||
repository: "Repo",
|
||||
defaultBranch: "main",
|
||||
findings: [{
|
||||
severity: "critical",
|
||||
code: "DEFAULT_BRANCH_ACTION_FAILED",
|
||||
subject: "default:ci.yml",
|
||||
message: "CI failed",
|
||||
evidence: { scope: "default_branch", runUrl: "https://gitea.example/actions/runs/7", consecutiveFailures: 2 },
|
||||
}],
|
||||
});
|
||||
|
||||
expect(reconciled[0].id).toBe("finding-legacy");
|
||||
expect(mocks.repositoryFinding.update.mock.calls[0][0]).toMatchObject({
|
||||
where: { id: "finding-legacy" },
|
||||
data: { ruleCode: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", status: "triaged" },
|
||||
});
|
||||
expect(mocks.repositoryFinding.update.mock.calls[1][0]).toMatchObject({
|
||||
where: { id: "finding-legacy" },
|
||||
data: { occurrenceCount: { increment: 1 }, lastAnalysisJobId: "job-new" },
|
||||
});
|
||||
expect(mocks.findingOccurrence.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { findingId_analysisJobItemId: { findingId: "finding-legacy", analysisJobItemId: "item-new" } },
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not increment occurrence or clear counters twice when an item is retried in the same job", async () => {
|
||||
const current = { ...legacyFinding, fingerprint: "current", ruleCode: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", lastAnalysisJobId: "job-current" };
|
||||
mocks.repositoryFinding.findUnique.mockResolvedValueOnce(current);
|
||||
mocks.repositoryFinding.update.mockImplementationOnce(async ({ data }: { data: Record<string, unknown> }) => ({ ...current, ...data }));
|
||||
mocks.repositoryFinding.findMany.mockResolvedValueOnce([{ ...legacyFinding, id: "missing", lastAnalysisJobId: "job-current" }]);
|
||||
|
||||
await reconcileRepositoryFindings({
|
||||
jobId: "job-current",
|
||||
analysisJobItemId: "item-current",
|
||||
owner: "Acme",
|
||||
repository: "Repo",
|
||||
defaultBranch: "main",
|
||||
findings: [{ severity: "critical", code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", message: "CI failed" }],
|
||||
});
|
||||
|
||||
expect(mocks.repositoryFinding.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.repositoryFinding.update.mock.calls[0][0].data.occurrenceCount).toBe(10);
|
||||
});
|
||||
|
||||
it("closes only resolved linked issues whose project policy enables automatic closure", async () => {
|
||||
const resolved = {
|
||||
...legacyFinding,
|
||||
status: "resolved",
|
||||
resolvedAt: new Date(),
|
||||
issueLink: { id: "issue-link-1", state: "open", issueNumber: 7 },
|
||||
project: { findingAutomationPolicy: { autoCloseResolved: true } },
|
||||
};
|
||||
const detailed = {
|
||||
...resolved,
|
||||
fingerprint: "resolved-fingerprint",
|
||||
repositoryLink: { accessMode: "read_write" },
|
||||
investigations: [],
|
||||
occurrences: [{ observed: true, detailJson: { evidence: { runUrl: "https://gitea.example/actions/runs/7" } }, createdAt: new Date() }],
|
||||
};
|
||||
mocks.repositoryFinding.findMany.mockResolvedValueOnce([resolved]);
|
||||
mocks.repositoryFinding.findUnique.mockResolvedValue(detailed);
|
||||
mocks.repositoryFinding.update.mockResolvedValue(detailed);
|
||||
mocks.issueClient.createIssueComment.mockResolvedValue({ id: 1 });
|
||||
mocks.issueClient.editIssue.mockResolvedValue({ id: 7, number: 7, title: "Finding", body: "body", state: "closed", html_url: "https://gitea.example/issues/7", labels: [], created_at: "2026-08-20T00:00:00Z", updated_at: "2026-08-28T00:00:00Z", closed_at: "2026-08-28T00:00:00Z" });
|
||||
|
||||
await expect(closeAutomaticallyResolvedFindingIssues(env)).resolves.toEqual(["finding-legacy"]);
|
||||
expect(mocks.issueClient.createIssueComment).toHaveBeenCalledOnce();
|
||||
expect(mocks.issueClient.editIssue).toHaveBeenCalledWith("Acme", "Repo", 7, { state: "closed" });
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.repositoryFinding.findMany.mockResolvedValueOnce([{ ...resolved, project: { findingAutomationPolicy: { autoCloseResolved: false } } }]);
|
||||
await expect(closeAutomaticallyResolvedFindingIssues(env)).resolves.toEqual([]);
|
||||
expect(mocks.issueClient.editIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an automatically resolved finding resolved when the issue-close webhook arrives later", async () => {
|
||||
mocks.giteaIssueLink.findUnique.mockResolvedValueOnce({
|
||||
id: "issue-link-1",
|
||||
findingId: "finding-legacy",
|
||||
finding: { ...legacyFinding, status: "suppressed", resolvedAt: new Date("2026-08-28T00:00:00Z") },
|
||||
});
|
||||
mocks.giteaIssueLink.update.mockResolvedValueOnce({ id: "issue-link-1", state: "closed" });
|
||||
mocks.repositoryFinding.update.mockResolvedValueOnce({ ...legacyFinding, status: "resolved", projectId: "project-1" });
|
||||
|
||||
await reconcileFindingIssueWebhook({
|
||||
owner: "Acme",
|
||||
repository: "Repo",
|
||||
issueNumber: 7,
|
||||
state: "closed",
|
||||
title: "Finding",
|
||||
url: "https://gitea.example/issues/7",
|
||||
actor: "projectbrain",
|
||||
});
|
||||
|
||||
expect(mocks.repositoryFinding.update).toHaveBeenCalledWith({
|
||||
where: { id: "finding-legacy" },
|
||||
data: { status: "resolved", suppressedAt: null, suppressedUntil: null, suppressionReason: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("still suppresses a genuinely active finding when its issue is manually closed", async () => {
|
||||
mocks.giteaIssueLink.findUnique.mockResolvedValueOnce({
|
||||
id: "issue-link-1",
|
||||
findingId: "finding-legacy",
|
||||
finding: { ...legacyFinding, status: "issue_open", resolvedAt: null },
|
||||
});
|
||||
mocks.giteaIssueLink.update.mockResolvedValueOnce({ id: "issue-link-1", state: "closed" });
|
||||
mocks.repositoryFinding.update.mockResolvedValueOnce({ ...legacyFinding, status: "suppressed", projectId: "project-1" });
|
||||
|
||||
await reconcileFindingIssueWebhook({
|
||||
owner: "Acme",
|
||||
repository: "Repo",
|
||||
issueNumber: 7,
|
||||
state: "closed",
|
||||
title: "Finding",
|
||||
url: "https://gitea.example/issues/7",
|
||||
actor: "jens",
|
||||
});
|
||||
|
||||
expect(mocks.repositoryFinding.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: "finding-legacy" },
|
||||
data: expect.objectContaining({ status: "suppressed", suppressionReason: "Gitea issue handmatig gesloten door jens" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasGiteaAutonomyCompletionEvidence } from "../src/repositories/autonomyRepository.js";
|
||||
import { autonomyGiteaBranch, isGiteaAutoExecutionBranch } from "../src/services/giteaAutonomyExecution.js";
|
||||
|
||||
describe("Gitea Auto execution contract", () => {
|
||||
it("derives one deterministic atomic-claim branch from a durable work id", () => {
|
||||
expect(autonomyGiteaBranch("PHX-003")).toBe("projectbrain/auto/phx-003");
|
||||
expect(autonomyGiteaBranch(" UI / Polish #4 ")).toBe("projectbrain/auto/ui-polish-4");
|
||||
expect(isGiteaAutoExecutionBranch("projectbrain/auto/phx-003")).toBe(true);
|
||||
expect(isGiteaAutoExecutionBranch("chatgpt/other-work")).toBe(false);
|
||||
});
|
||||
|
||||
it("requires merge, exact head and validation evidence before Gitea completion is authoritative", () => {
|
||||
expect(hasGiteaAutonomyCompletionEvidence([
|
||||
"gitea:auto:merge:abc",
|
||||
"gitea:auto:head:def",
|
||||
])).toBe(false);
|
||||
expect(hasGiteaAutonomyCompletionEvidence([
|
||||
"gitea:auto:merge:abc",
|
||||
"gitea:auto:head:def",
|
||||
"gitea:auto:validation:42",
|
||||
])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import {
|
||||
parsePortfolioFetchId,
|
||||
portfolioFetchId,
|
||||
} from "../src/integrations/gitea/portfolio.js";
|
||||
import { registerPortfolioMcpTools } from "../src/mcp/portfolioTools.js";
|
||||
import { createProjectBrainMcpServer } from "../src/mcp/server.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("read-only Gitea portfolio MCP projection", () => {
|
||||
it("round-trips canonical repository/ref/path fetch identifiers", () => {
|
||||
const id = portfolioFetchId(
|
||||
{ owner: "Jens", repository: "Mediarr" },
|
||||
"codex/example-branch",
|
||||
"src/a file.ts",
|
||||
);
|
||||
expect(id).toBe(
|
||||
"gitea-repository://Jens/Mediarr/codex%2Fexample-branch/src%2Fa%20file.ts",
|
||||
);
|
||||
expect(parsePortfolioFetchId(id)).toEqual({
|
||||
owner: "Jens",
|
||||
repository: "Mediarr",
|
||||
ref: "codex/example-branch",
|
||||
path: "src/a file.ts",
|
||||
});
|
||||
expect(() => parsePortfolioFetchId("gitea://project/main/file.ts")).toThrow(
|
||||
"MCP_FETCH_ID_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises portfolio tools as distinct read-only capabilities", async () => {
|
||||
const server = createProjectBrainMcpServer(env, "portfolio-contract-test");
|
||||
registerPortfolioMcpTools(server, env, "portfolio-contract-test");
|
||||
const client = new Client({ name: "portfolio-contract-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const listed = await client.listTools();
|
||||
const tools = new Map(listed.tools.map((tool) => [tool.name, tool]));
|
||||
for (const name of [
|
||||
"list_portfolio_repositories",
|
||||
"search_portfolio_repositories",
|
||||
"fetch_portfolio_file",
|
||||
]) {
|
||||
const tool = tools.get(name);
|
||||
expect(tool, `${name} should be registered`).toBeDefined();
|
||||
expect(tool?.annotations).toMatchObject({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
});
|
||||
}
|
||||
expect(tools.get("list_portfolio_repositories")?.annotations?.openWorldHint).toBe(true);
|
||||
expect(tools.get("search_portfolio_repositories")?.annotations?.openWorldHint).toBe(false);
|
||||
expect(tools.get("fetch_portfolio_file")?.annotations?.openWorldHint).toBe(false);
|
||||
|
||||
// Existing delivery mutations remain separate, linked-project tools.
|
||||
expect(tools.has("write_feature_file")).toBe(true);
|
||||
expect(tools.has("search")).toBe(true);
|
||||
expect(tools.has("fetch")).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serializeAnalysisJob } from "../src/repositories/analysisJobRepository.js";
|
||||
import { buildProjectContextResume } from "../src/services/contextResolver.js";
|
||||
|
||||
describe("MCP efficiency hints", () => {
|
||||
it("finishes open PRs before starting newer delivery branches", () => {
|
||||
const resume = buildProjectContextResume(
|
||||
[{ id: "repo", owner: "Jens", repository: "app", accessMode: "read_write", defaultBranch: "main" }],
|
||||
[{ id: "new", title: "New plan", status: "approved", approvalState: "approved" }, { id: "existing", title: "Finish me", status: "pull_request_open", approvalState: "approved", runs: [{ pullRequestNumber: 7 }] }], [],
|
||||
);
|
||||
expect(resume.nextAction).toMatchObject({ type: "review-and-merge", deliveryId: "existing", pullRequestNumber: 7 });
|
||||
});
|
||||
it("returns one durable resume action for an active delivery", () => {
|
||||
const resume = buildProjectContextResume(
|
||||
[{ id: "repo-1", owner: "Jens", repository: "app", accessMode: "read_write", defaultBranch: "main" }],
|
||||
[{ id: "delivery-1", title: "Improve MCP", status: "executing", approvalState: "approved", runs: [{ branch: "chatgpt/mcp", headSha: "abc" }] }],
|
||||
[],
|
||||
"2026-08-27T00:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(resume).toMatchObject({
|
||||
repositoryCount: 1,
|
||||
activeDelivery: { id: "delivery-1", branch: "chatgpt/mcp" },
|
||||
nextAction: { type: "continue-implementation", deliveryId: "delivery-1" },
|
||||
});
|
||||
expect(resume.stateVersion).toMatch(/^[0-9a-f]{24}$/);
|
||||
});
|
||||
|
||||
it("tells clients when and whether to poll analysis state", () => {
|
||||
const running = serializeAnalysisJob({ id: "job-1", status: "running", progressCurrent: 1, progressTotal: 4, updatedAt: new Date("2026-08-27T00:00:00.000Z") });
|
||||
const succeeded = serializeAnalysisJob({ id: "job-1", status: "succeeded", progressCurrent: 4, progressTotal: 4, updatedAt: new Date("2026-08-27T00:01:00.000Z") });
|
||||
|
||||
expect(running).toMatchObject({ terminal: false, pollAfterMs: 4_000, nextAction: "wait-for-change" });
|
||||
expect(succeeded).toMatchObject({ terminal: true, pollAfterMs: 0, nextAction: "read-results" });
|
||||
expect(running.stateVersion).not.toBe(succeeded.stateVersion);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { augmentProjectBrainInitialize, handleProjectBrainSkillMethod } from "../src/mcp/skills.js";
|
||||
|
||||
type SkillList = { skills: Array<{ uri: string }> };
|
||||
type SkillResource = { contents: Array<{ text: string }> };
|
||||
|
||||
describe("ProjectBrain MCP skills", () => {
|
||||
it("publishes canonical public workflows for repository bootstrap and delivery", () => {
|
||||
const listed = handleProjectBrainSkillMethod("skills/list", {}) as SkillList;
|
||||
const uris = listed.skills.map((skill) => skill.uri);
|
||||
|
||||
expect(uris).toContain("skill://projectbrain/repository-bootstrap/SKILL.md");
|
||||
expect(uris).toContain("skill://projectbrain/feature-delivery/SKILL.md");
|
||||
|
||||
const bootstrap = handleProjectBrainSkillMethod("resources/read", { uri: "skill://projectbrain/repository-bootstrap/SKILL.md" }) as SkillResource;
|
||||
expect(bootstrap.contents[0]?.text).toContain("gitea.repository.create");
|
||||
expect(bootstrap.contents[0]?.text).toContain("projectbrain.portfolio.repository.reconcile");
|
||||
|
||||
const delivery = handleProjectBrainSkillMethod("resources/read", { uri: "skill://projectbrain/feature-delivery/SKILL.md" }) as SkillResource;
|
||||
expect(delivery.contents[0]?.text).toContain("projectbrain.delivery.changeset.apply");
|
||||
expect(delivery.contents[0]?.text).toContain("projectbrain.delivery.merge");
|
||||
expect(delivery.contents[0]?.text).toContain("gitea.workspace.run");
|
||||
expect(delivery.contents[0]?.text).not.toContain("apply_feature_changeset");
|
||||
expect(delivery.contents[0]?.text).not.toContain("merge_feature_delivery");
|
||||
});
|
||||
|
||||
it("publishes the Gitea-first Auto execution contract", () => {
|
||||
const operator = handleProjectBrainSkillMethod("resources/read", { uri: "skill://projectbrain/projectbrain-operator/SKILL.md" }) as SkillResource;
|
||||
const text = operator.contents[0]?.text ?? "";
|
||||
|
||||
expect(text).toContain("Gitea is the only primary repository execution surface");
|
||||
expect(text).toContain("projectbrain/auto/<normalized-work-id>");
|
||||
expect(text).toContain("atomic execution claim");
|
||||
expect(text).toContain("exact-head validation evidence");
|
||||
expect(text).toContain("missing or interrupted report_auto_work callback");
|
||||
expect(text).toContain("No OpenAI API key is required");
|
||||
});
|
||||
|
||||
it("advertises Gitea as the primary Auto execution surface during MCP initialization", () => {
|
||||
const initialized = augmentProjectBrainInitialize({ result: { capabilities: {} } }) as {
|
||||
result: { instructions: string };
|
||||
};
|
||||
|
||||
expect(initialized.result.instructions).toContain(
|
||||
"Gitea as its only primary repository execution surface",
|
||||
);
|
||||
expect(initialized.result.instructions).toContain("projectbrain/auto/<normalized-work-id>");
|
||||
expect(initialized.result.instructions).toContain("Branch existence is the atomic claim");
|
||||
expect(initialized.result.instructions).toContain(
|
||||
"report_auto_work are backward-compatible coordination/bookkeeping surfaces only",
|
||||
);
|
||||
expect(initialized.result.instructions).toContain(
|
||||
"Never write directly to the default branch",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GiteaClient } from "../src/integrations/gitea/client.js";
|
||||
import { assertSuccessfulActionRuns, exactHeadActionRuns } from "../src/integrations/gitea/policy.js";
|
||||
import type { GiteaActionRun } from "../src/integrations/gitea/types.js";
|
||||
|
||||
const branch = "codex/native-ci";
|
||||
const sha = "a".repeat(40);
|
||||
const context = "CI / build-test (pull_request)";
|
||||
const run = (id: number, overrides: Partial<GiteaActionRun> = {}): GiteaActionRun => ({
|
||||
id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml",
|
||||
event: "pull_request", head_sha: sha, head_branch: "", status: "completed",
|
||||
conclusion: "success", html_url: `https://gitea.example/runs/${id}`, ...overrides,
|
||||
});
|
||||
|
||||
describe("native PR validation discovery", () => {
|
||||
it("discovers an empty branch by exact SHA and checks available PR identity", () => {
|
||||
const matching = run(1, { pull_requests: [{ number: 30, head: { ref: branch, sha } }] });
|
||||
expect(exactHeadActionRuns([
|
||||
matching, run(2), run(3, { head_sha: "stale" }),
|
||||
run(4, { head_branch: "other" }),
|
||||
run(5, { pull_requests: [{ number: 31, head: { ref: "other", sha } }] }),
|
||||
run(6, { event: "workflow_dispatch" }),
|
||||
], branch, sha)).toEqual([matching, run(2)]);
|
||||
expect(assertSuccessfulActionRuns([matching], branch, sha)).toEqual([matching]);
|
||||
});
|
||||
|
||||
it.each(["pending", "queued", "waiting", "in_progress"])("does not authorize %s", (status) => {
|
||||
expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||
});
|
||||
|
||||
it.each(["cancelled", "canceled", "failure"])("does not authorize a %s job that never started", (conclusion) => {
|
||||
expect(() => assertSuccessfulActionRuns([run(1, { conclusion })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||
});
|
||||
|
||||
it("uses the newest attempt of each workflow and preserves all evidence", () => {
|
||||
const failure = run(2, { conclusion: "failure" });
|
||||
expect(() => assertSuccessfulActionRuns([failure, run(1, { status: "pending" })], branch, sha)).toThrow();
|
||||
const success = run(3);
|
||||
expect(assertSuccessfulActionRuns([failure, success], branch, sha)).toEqual([failure, success]);
|
||||
expect(() => assertSuccessfulActionRuns([success, run(4, { status: "queued" })], branch, sha)).toThrow();
|
||||
});
|
||||
|
||||
it("never lets dispatch success replace PR failure even for the same workflow path", () => {
|
||||
expect(() => assertSuccessfulActionRuns([
|
||||
run(1, { conclusion: "failure" }),
|
||||
run(2, { event: "workflow_dispatch", head_branch: branch }),
|
||||
], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||
});
|
||||
|
||||
it("queries by exact SHA without the upstream branch filter", async () => {
|
||||
const fetcher = vi.fn(async () => new Response(JSON.stringify({ total_count: 1, workflow_runs: [run(1)] })));
|
||||
await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch).listActionRuns("Jens", "Repo", { branch, headSha: sha });
|
||||
const url = new URL(String(fetcher.mock.calls[0]![0]));
|
||||
expect(url.searchParams.get("head_sha")).toBe(sha);
|
||||
expect(url.searchParams.has("branch")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[{ context, status: "failure" }, { context, status: "pending" }], "GITEA_REQUIRED_CHECKS_FAILED"],
|
||||
[[{ context: "CI / build-test (workflow_dispatch)", status: "success" }], "GITEA_REQUIRED_CHECKS_PENDING"],
|
||||
[[{ context: `${context} extra`, status: "success" }], "GITEA_REQUIRED_CHECKS_PENDING"],
|
||||
[[{ context, status: "pending" }], "GITEA_REQUIRED_CHECKS_PENDING"],
|
||||
] as const)("requires the exact latest protected status %#", async (statuses, error) => {
|
||||
const fetcher = vi.fn(async (url: URL | RequestInfo) => new Response(JSON.stringify(String(url).includes("branch_protections")
|
||||
? { enable_status_check: true, status_check_contexts: [context] } : { statuses })));
|
||||
const client = new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch);
|
||||
await expect(client.waitForRequiredPullRequestChecks("Jens", "Repo", "main", sha, { timeoutMs: 0 })).rejects.toMatchObject({ code: error });
|
||||
expect(String(fetcher.mock.calls[1]![0])).toContain(`/commits/${sha}/status`);
|
||||
});
|
||||
|
||||
it("accepts a newer native success after an old native failure", async () => {
|
||||
const fetcher = vi.fn(async (url: URL | RequestInfo) => new Response(JSON.stringify(String(url).includes("branch_protections")
|
||||
? { enable_status_check: true, status_check_contexts: [context] }
|
||||
: { statuses: [{ context, status: "success" }, { context, status: "failure" }] })));
|
||||
await expect(new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch)
|
||||
.waitForRequiredPullRequestChecks("Jens", "Repo", "main", sha, { timeoutMs: 0 })).resolves.toMatchObject({ state: "success" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandIntent } from "../src/services/commandIntent.js";
|
||||
import { detectStuckConditions, interpretConnectorHealth } from "../src/services/diagnostics.js";
|
||||
import { buildPulse } from "../src/services/pulse.js";
|
||||
import { deliveryValidationsAreGreen } from "../src/services/projectCompletion.js";
|
||||
import { buildParallelGroups, rankReadyWork, type WorkGraphNodeView } from "../src/services/workGraph.js";
|
||||
|
||||
const graphNode = (patch: Partial<WorkGraphNodeView> = {}): WorkGraphNodeView => ({
|
||||
id: "node-1", projectId: "project-1", stableKey: "node-1", source: "test", sourceId: "node-1", type: "feature", title: "Work", canonicalLifecycle: "ready", domainLifecycle: "pending", scope: "committed", priority: 50, risk: "normal", blockingStatus: "open", executionEligibility: "ready", attemptCount: 0, consecutiveFailureCount: 0, dependencies: [], dependents: [], completionEvidence: [], createdAt: "2026-09-12T10:00:00.000Z", updatedAt: "2026-09-12T10:00:00.000Z", ...patch,
|
||||
});
|
||||
|
||||
describe("operating-system user-facing services", () => {
|
||||
it("turns natural language into constrained policy instead of an execution bypass", () => {
|
||||
expect(parseCommandIntent("Continue this project but don't deploy.")).toMatchObject({
|
||||
objective: "continue",
|
||||
policy: { deploy: "blocked", automaticRecovery: true },
|
||||
allowedScope: ["required", "committed"],
|
||||
});
|
||||
expect(parseCommandIntent("Finish everything required for release.")).toMatchObject({ objective: "release", requestedCompletionState: "definition_of_done", policy: { deploy: "unchanged" } });
|
||||
});
|
||||
|
||||
it("distinguishes functional connector failure from process health", () => {
|
||||
expect(interpretConnectorHealth({ process: true, transport: true, authentication: true, api: true, functional_read: false })).toBe("degraded");
|
||||
expect(interpretConnectorHealth({ process: true, transport: true, api: false })).toBe("unavailable");
|
||||
expect(interpretConnectorHealth({ process: true, transport: true, authentication: true, authorization: true, api: true, functional_read: true, write_readiness: true })).toBe("healthy");
|
||||
});
|
||||
|
||||
it("detects reclaimable stale work and keeps the repair explicit", () => {
|
||||
const now = Date.parse("2026-09-12T12:00:00.000Z");
|
||||
expect(detectStuckConditions({
|
||||
now,
|
||||
running: [{ id: "lease-1", leaseExpiresAt: "2026-09-12T11:00:00.000Z" }],
|
||||
queued: [{ id: "queue-1", queuedAt: "2026-09-12T11:00:00.000Z", consumerPresent: false }],
|
||||
})).toMatchObject([
|
||||
{ kind: "expired_lease", repairable: true },
|
||||
{ kind: "queue_without_consumer", repairable: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("summarizes meaningful pulse events instead of returning a raw event dump", () => {
|
||||
const pulse = buildPulse([
|
||||
{ at: "2026-09-12T11:55:00.000Z", kind: "work.completed", title: "Delivery merged", summary: "The delivery was merged." },
|
||||
{ at: "2026-09-12T11:56:00.000Z", kind: "heartbeat", title: "heartbeat", summary: "technical noise", meaningful: false },
|
||||
{ at: "2026-09-12T11:57:00.000Z", kind: "work.blocked", title: "Validation failed", summary: "Retrying automatically." },
|
||||
], Date.parse("2026-09-12T12:00:00.000Z"));
|
||||
expect(pulse.counts).toMatchObject({ meaningful: 2, completed: 1, blockers: 1 });
|
||||
expect(pulse.nextLikelyAction).toContain("Retrying");
|
||||
});
|
||||
|
||||
it("treats a released delivery as a meaningful result while keeping noise out", () => {
|
||||
const pulse = buildPulse([
|
||||
{ at: "2026-09-12T11:55:00.000Z", kind: "delivery.released", title: "Release completed", summary: "The release is live." },
|
||||
{ at: "2026-09-12T11:56:00.000Z", kind: "work.claimed", title: "Claimed", summary: "technical lease", meaningful: false },
|
||||
], Date.parse("2026-09-12T12:00:00.000Z"));
|
||||
expect(pulse.counts).toMatchObject({ meaningful: 1, completed: 1, active: 0 });
|
||||
});
|
||||
|
||||
it("does not invent an operating action for unsupported language", () => {
|
||||
expect(parseCommandIntent("Tell me a joke")).toMatchObject({ objective: "unknown", confidence: 35 });
|
||||
});
|
||||
|
||||
it("keeps Future work out of ready scheduling and separates conflicting groups", () => {
|
||||
const ready = [
|
||||
graphNode({ id: "a", stableKey: "a", metadata: { touchedPaths: ["src/shared/a.ts"] } }),
|
||||
graphNode({ id: "b", stableKey: "b", metadata: { touchedPaths: ["src/shared/a.ts"] } }),
|
||||
graphNode({ id: "c", stableKey: "c", scope: "future" }),
|
||||
];
|
||||
expect(rankReadyWork(ready).map((item) => item.id)).toEqual(["a", "b"]);
|
||||
expect(buildParallelGroups(rankReadyWork(ready))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not mark a delivery complete before its declared validations have run", () => {
|
||||
expect(deliveryValidationsAreGreen(["pnpm test"], [])).toBe(false);
|
||||
expect(deliveryValidationsAreGreen(["pnpm test"], [{ status: "passed" }])).toBe(true);
|
||||
expect(deliveryValidationsAreGreen(["pnpm test"], [{ status: "failed" }])).toBe(false);
|
||||
expect(deliveryValidationsAreGreen([], [])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AutonomyProjectRuntime } from "../src/services/autonomyRuntime.js";
|
||||
import { buildAutoHumanAttentionPriority, PORTFOLIO_CATALOG_CACHE_MS, shouldRefreshPortfolioCatalog, summarizeDeliveryActivity, summarizeLiveExecution } from "../src/services/portfolioControlPlane.js";
|
||||
|
||||
const autoProject = (patch: Partial<AutonomyProjectRuntime> = {}): AutonomyProjectRuntime => ({
|
||||
projectId: "project-db-id",
|
||||
projectSlug: "demo",
|
||||
projectName: "Demo",
|
||||
enabled: true,
|
||||
status: "running",
|
||||
lifecycle: "ready",
|
||||
pendingWorkCount: 3,
|
||||
completedWorkCount: 1,
|
||||
maxWorkItemsPerRun: 5,
|
||||
maxRunMinutes: 30,
|
||||
stalled: false,
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe("portfolio delivery activity semantics", () => {
|
||||
it("counts stored delivery phases separately from executor liveness", () => {
|
||||
expect(summarizeDeliveryActivity({
|
||||
draft: 2,
|
||||
approved: 3,
|
||||
executing: 4,
|
||||
validating: 5,
|
||||
pull_request_open: 6,
|
||||
merged: 7,
|
||||
verified: 8,
|
||||
released: 9,
|
||||
})).toEqual({
|
||||
active: 9,
|
||||
queued: 5,
|
||||
waitingExternal: 6,
|
||||
mergedAwaitingVerification: 7,
|
||||
completed: 17,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing delivery buckets to zero", () => {
|
||||
expect(summarizeDeliveryActivity({})).toEqual({
|
||||
active: 0,
|
||||
queued: 0,
|
||||
waitingExternal: 0,
|
||||
mergedAwaitingVerification: 0,
|
||||
completed: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmed execution", () => {
|
||||
const now = Date.parse("2026-09-09T12:00:00Z");
|
||||
const working = autoProject({ activeWorkKind: "implement", activeRunId: "run-1", executorRunId: "run-1", executorStatus: "working", executorLastSeenAt: new Date(now - 1000).toISOString(), leaseExpiresAt: new Date(now + 60000).toISOString() });
|
||||
it("requires enabled state, valid lease and fresh working heartbeat together", () => {
|
||||
expect(summarizeLiveExecution([working], 120, now).verifiedActiveProjects).toBe(1);
|
||||
for (const patch of [{ enabled: false }, { executorStatus: "stale" as const }, { executorStatus: "idle" as const }, { activeWorkKind: undefined }, { leaseExpiresAt: new Date(now).toISOString() }, { executorLastSeenAt: new Date(now - 121000).toISOString() }, { executorLastSeenAt: "invalid" }, { executorLastSeenAt: new Date(now + 1000).toISOString() }]) {
|
||||
expect(summarizeLiveExecution([{ ...working, ...patch }], 120, now).verifiedActiveProjects).toBe(0);
|
||||
}
|
||||
});
|
||||
it("distinguishes paused automation from orphaned claims", () => {
|
||||
expect(summarizeLiveExecution([{ ...working, executorRunId: "different-run" }], 120, now).verifiedActiveProjects).toBe(0);
|
||||
expect(summarizeLiveExecution([{ ...working, activeRunId: undefined }], 120, now).verifiedActiveProjects).toBe(0);
|
||||
expect(summarizeLiveExecution([{ ...working, enabled: false }], 120, now)).toMatchObject({ verifiedActiveProjects: 0, enabledProjects: 0, unconfirmedLeaseProjects: 0 });
|
||||
expect(summarizeLiveExecution([{ ...working, executorStatus: "offline" }], 120, now).unconfirmedLeaseProjects).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portfolio Auto human attention", () => {
|
||||
it("surfaces true human Auto blockers as portfolio attention", () => {
|
||||
const priority = buildAutoHumanAttentionPriority([
|
||||
autoProject({ status: "blocked", blockerKind: "human", blockerReason: "Owner decision required" }),
|
||||
autoProject({ projectId: "retry-db", projectSlug: "retry", projectName: "Retry", status: "blocked", blockerKind: "retryable", blockerReason: "Runner timeout" }),
|
||||
]);
|
||||
|
||||
expect(priority).toMatchObject({
|
||||
id: "auto-human-blockers",
|
||||
projectId: "project-db-id",
|
||||
requiresHumanAttention: true,
|
||||
count: 1,
|
||||
route: "/projects/demo/attention",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not turn retryable or disabled Auto state into human attention", () => {
|
||||
expect(buildAutoHumanAttentionPriority([
|
||||
autoProject({ status: "blocked", blockerKind: "retryable" }),
|
||||
autoProject({ enabled: false, status: "blocked", blockerKind: "human" }),
|
||||
])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("portfolio catalog freshness", () => {
|
||||
it("serves a recent durable catalog before asking Gitea for another inventory", () => {
|
||||
const now = Date.parse("2026-09-13T12:00:00.000Z");
|
||||
expect(shouldRefreshPortfolioCatalog(new Date(now - PORTFOLIO_CATALOG_CACHE_MS), now)).toBe(false);
|
||||
expect(shouldRefreshPortfolioCatalog(new Date(now - PORTFOLIO_CATALOG_CACHE_MS - 1), now)).toBe(true);
|
||||
expect(shouldRefreshPortfolioCatalog(undefined, now)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import Fastify from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { registerProjectRoutes } from "../src/routes/projects.js";
|
||||
|
||||
describe("project create setup contract", () => {
|
||||
const apps: Array<ReturnType<typeof Fastify>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
it("returns the rich wizard setup only after the create request succeeds", async () => {
|
||||
const app = Fastify();
|
||||
apps.push(app);
|
||||
await registerProjectRoutes(app, { dataSource: "fixtures" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: {
|
||||
id: "durable-wizard",
|
||||
name: "Durable wizard",
|
||||
description: "A project whose complete wizard state survives the create boundary.",
|
||||
domain: "webapp",
|
||||
primaryGoal: "Persist the complete setup",
|
||||
targetAudience: "Project owners",
|
||||
dna: {
|
||||
vision: "Keep project setup durable",
|
||||
goals: ["Persist the complete setup", "Reload it safely"],
|
||||
nonGoals: ["Do not navigate before success"],
|
||||
targetAudience: "Project owners",
|
||||
principles: ["Review before truth"],
|
||||
successMetrics: [{ metric: "setup-roundtrip", target: "= true", operator: "=" }],
|
||||
maturityStage: "Discovery",
|
||||
revision: 1,
|
||||
},
|
||||
setupState: {
|
||||
id: "must-not-override",
|
||||
firstRun: {
|
||||
documentationDepth: "Full - exhaustive design system",
|
||||
repositoryProfile: { preferredStack: "React + Fastify", repository: "Acme/durable-wizard" },
|
||||
},
|
||||
constraints: [{ id: "constraint-1", description: "No false success" }],
|
||||
workspaceState: {
|
||||
projectId: "durable-wizard",
|
||||
primaryObjective: "Build the first durable slice",
|
||||
focusAreas: [],
|
||||
openTasksCount: 2,
|
||||
openBugCount: 0,
|
||||
activeSection: "workspace",
|
||||
updatedAt: "2026-08-30T00:00:00.000Z",
|
||||
notes: ["Created from the wizard"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.json().data).toMatchObject({
|
||||
id: "durable-wizard",
|
||||
name: "Durable wizard",
|
||||
dna: {
|
||||
goals: ["Persist the complete setup", "Reload it safely"],
|
||||
targetAudience: "Project owners",
|
||||
},
|
||||
constraints: [{ id: "constraint-1", description: "No false success" }],
|
||||
firstRun: {
|
||||
documentationDepth: "Full - exhaustive design system",
|
||||
repositoryProfile: { preferredStack: "React + Fastify", repository: "Acme/durable-wizard" },
|
||||
},
|
||||
workspaceState: {
|
||||
projectId: "durable-wizard",
|
||||
primaryObjective: "Build the first durable slice",
|
||||
openTasksCount: 2,
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeProjectSetupState, sanitizeProjectSetupState } from "../src/repositories/projectSetupState.js";
|
||||
|
||||
describe("project setup state persistence", () => {
|
||||
it("round-trips rich wizard fields while rejecting unsupported top-level state", () => {
|
||||
const setup = sanitizeProjectSetupState({
|
||||
id: "spoofed-project",
|
||||
name: "Spoofed name",
|
||||
firstRun: { documentationDepth: "Deep", repoFolders: ["docs/00-project"] },
|
||||
constraints: [{ id: "constraint-1", description: "Keep data durable" }],
|
||||
workspaceState: { projectId: "wizard-project", primaryObjective: "Ship the first slice" },
|
||||
repositoryIntakes: [{ id: "not-part-of-project-setup" }],
|
||||
});
|
||||
|
||||
expect(setup).toMatchObject({
|
||||
firstRun: { documentationDepth: "Deep", repoFolders: ["docs/00-project"] },
|
||||
constraints: [{ id: "constraint-1", description: "Keep data durable" }],
|
||||
workspaceState: { projectId: "wizard-project", primaryObjective: "Ship the first slice" },
|
||||
});
|
||||
expect(setup).not.toHaveProperty("id");
|
||||
expect(setup).not.toHaveProperty("name");
|
||||
expect(setup).not.toHaveProperty("repositoryIntakes");
|
||||
});
|
||||
|
||||
it("always lets canonical database fields win over setup state", () => {
|
||||
expect(mergeProjectSetupState(
|
||||
{ activeContextPackId: "starter-pack", workspaceState: { primaryObjective: "Wizard objective" }, name: "Spoofed" },
|
||||
{ id: "canonical", name: "Canonical" },
|
||||
)).toEqual({
|
||||
activeContextPackId: "starter-pack",
|
||||
workspaceState: { primaryObjective: "Wizard objective" },
|
||||
id: "canonical",
|
||||
name: "Canonical",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { projectHealth } from "../src/services/projectHealthSemantics.js";
|
||||
|
||||
const finding = (severity = "warning", status = "new") => ({ severity, status });
|
||||
const health = (repositoryFindings: ReturnType<typeof finding>[] = [], syncStatuses: string[] = ["ready"]) =>
|
||||
projectHealth({ repositoryFindings, giteaRepositories: syncStatuses.map((syncStatus) => ({ syncStatus })) });
|
||||
|
||||
describe("project health semantics", () => {
|
||||
it("does not label a returned warning such as branch sprawl as a critical risk", () => {
|
||||
expect(health([finding("warning", "regressed")])).toEqual({
|
||||
score: 80, healthStatus: "warn", activeFindingCount: 1, criticalCount: 0, warningCount: 1, regressedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["new", "triaged", "regressed", "issue_open"])("preserves critical failures in status %s", (status) => {
|
||||
expect(health([finding("critical", status)]).healthStatus).toBe("risk");
|
||||
});
|
||||
|
||||
it("preserves aggregate low-score risk without a critical finding", () => {
|
||||
expect(health(Array.from({ length: 11 }, () => finding()))).toMatchObject({ score: 45, healthStatus: "risk" });
|
||||
expect(health(Array.from({ length: 10 }, () => finding()))).toMatchObject({ score: 50, healthStatus: "warn" });
|
||||
});
|
||||
|
||||
it("retains recurrence evidence and its aggregate penalty", () => {
|
||||
expect(health(Array.from({ length: 3 }, () => finding("warning", "regressed")))).toMatchObject({
|
||||
score: 40, healthStatus: "risk", regressedCount: 3, warningCount: 3,
|
||||
});
|
||||
expect(health([finding("info", "regressed")])).toMatchObject({ score: 85, healthStatus: "warn", regressedCount: 1 });
|
||||
});
|
||||
|
||||
it("excludes resolved and suppressed findings without hiding active findings", () => {
|
||||
expect(health([finding("critical", "resolved"), finding("critical", "suppressed"), finding("info")])).toMatchObject({
|
||||
score: 100, healthStatus: "healthy", activeFindingCount: 1, criticalCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("retains repository availability penalties and bounded scores", () => {
|
||||
expect(health([], ["failed"])).toMatchObject({ score: 80, healthStatus: "warn" });
|
||||
expect(health(Array.from({ length: 10 }, () => finding("critical")), ["failed", "failed", "failed"])).toMatchObject({
|
||||
score: 0, healthStatus: "risk",
|
||||
});
|
||||
expect(health()).toMatchObject({ score: 100, healthStatus: "healthy" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reconcileTruthCandidates } from "../src/services/projectKnowledge.js";
|
||||
|
||||
describe("project knowledge truth reconciliation", () => {
|
||||
it("flags ambiguous disagreement as one actionable conflict", () => {
|
||||
const conflicts = reconcileTruthCandidates([
|
||||
{ key: "architecture", claim: "uses service a", source: "memory", authority: "memory", confidence: 90, entryId: "memory:1" },
|
||||
{ key: "architecture", claim: "uses service b", source: "documentation", authority: "roadmap", confidence: 80, entryId: "documentation:1" },
|
||||
]);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0]).toMatchObject({ status: "attention", entryIds: ["memory:1", "documentation:1"] });
|
||||
});
|
||||
|
||||
it("lets verified repository truth supersede stale descriptive memory", () => {
|
||||
const conflicts = reconcileTruthCandidates([
|
||||
{ key: "architecture", claim: "uses service a", source: "memory", authority: "memory", confidence: 95, entryId: "memory:1" },
|
||||
{ key: "architecture", claim: "uses service b", source: "repository", authority: "repository", confidence: 95, verifiedAt: "2026-09-12T10:00:00.000Z", entryId: "repo:1" },
|
||||
]);
|
||||
expect(conflicts[0]).toMatchObject({ status: "reconciled", winningEntryId: "repo:1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decideRecovery } from "../src/services/recoveryPolicy.js";
|
||||
import { normalizeAutonomyState, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js";
|
||||
|
||||
describe("bounded Auto Mode recovery policy", () => {
|
||||
it("walks through retry, cooldown and alternate recovery before escalation", () => {
|
||||
const sequence = [1, 2, 3, 4, 5, 6].map((recoveryAttempts) => decideRecovery({
|
||||
failureCount: recoveryAttempts,
|
||||
recoveryAttempts,
|
||||
recoveryStartedAt: "2026-09-12T10:00:00.000Z",
|
||||
maxRecoveryMinutes: 120,
|
||||
now: Date.parse("2026-09-12T10:10:00.000Z"),
|
||||
}).stage);
|
||||
expect(sequence).toEqual(["retry", "retry", "cooldown", "retry", "alternate_recovery", "escalated"]);
|
||||
});
|
||||
|
||||
it("stops on the wall-clock budget even if attempts are low", () => {
|
||||
expect(decideRecovery({
|
||||
failureCount: 1,
|
||||
recoveryAttempts: 1,
|
||||
recoveryStartedAt: "2026-09-12T08:00:00.000Z",
|
||||
maxRecoveryMinutes: 30,
|
||||
now: Date.parse("2026-09-12T08:31:00.000Z"),
|
||||
})).toMatchObject({ stage: "escalated", exhausted: true, reason: "recovery_wall_clock_limit" });
|
||||
});
|
||||
|
||||
it("escalates a persisted blocker when its cooldown wakes after the wall-clock budget", () => {
|
||||
const state = normalizeAutonomyState({
|
||||
enabled: true,
|
||||
maxRecoveryMinutes: 30,
|
||||
recoveryStartedAt: "2026-09-12T08:00:00.000Z",
|
||||
blocker: { at: "2026-09-12T08:00:00.000Z", phaseId: "phase-1", reason: "Runner unavailable", kind: "retryable", retryAt: "2026-09-12T08:31:00.000Z", recoveryStage: "retry", recoveryAttempts: 1, recoveryStartedAt: "2026-09-12T08:00:00.000Z" },
|
||||
roadmap: { id: "roadmap-1", title: "Release", status: "blocked", currentPhaseIndex: 0, importedAt: "2026-09-12T08:00:00.000Z", phases: [{ id: "phase-1", title: "Build", objective: "Build", status: "blocked", items: [] }] },
|
||||
});
|
||||
const result = retryAutonomyBlockerIfDue(state, Date.parse("2026-09-12T08:31:00.000Z"));
|
||||
expect(result.blocker).toMatchObject({ recoveryStage: "escalated" });
|
||||
expect(result.status).toBe("blocked");
|
||||
expect(result.roadmap?.status).toBe("blocked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { GiteaClient } from "../src/integrations/gitea/client.js";
|
||||
import { assertSuccessfulActionRuns } from "../src/integrations/gitea/policy.js";
|
||||
import type { GiteaActionRun, GiteaContent } from "../src/integrations/gitea/types.js";
|
||||
import { decodeFile, MAX_MCP_READ_BYTES } from "../src/mcp/resultHelpers.js";
|
||||
import { serializeAnalysisJob } from "../src/repositories/analysisJobRepository.js";
|
||||
|
||||
const actionRun = (input: {
|
||||
id: number;
|
||||
conclusion: string;
|
||||
workflow?: string;
|
||||
status?: string;
|
||||
headSha?: string;
|
||||
}): GiteaActionRun => ({
|
||||
id: input.id,
|
||||
run_number: input.id,
|
||||
display_title: input.workflow ?? "CI",
|
||||
path: `.gitea/workflows/${input.workflow ?? "ci.yml"}`,
|
||||
event: "push",
|
||||
head_sha: input.headSha ?? "current-sha",
|
||||
head_branch: "chatgpt/feature",
|
||||
status: input.status ?? "completed",
|
||||
conclusion: input.conclusion,
|
||||
html_url: `https://gitea.example.com/runs/${input.id}`,
|
||||
});
|
||||
|
||||
const productionEnv = {
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: "postgresql://projectbrain:strong-db-password@postgres:5432/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",
|
||||
} as const;
|
||||
|
||||
describe("review remediation regressions", () => {
|
||||
it("authorizes a successful rerun of the same workflow and revision without discarding evidence", () => {
|
||||
const runs = [
|
||||
actionRun({ id: 10, conclusion: "failure" }),
|
||||
actionRun({ id: 11, conclusion: "success" }),
|
||||
];
|
||||
|
||||
expect(assertSuccessfulActionRuns(runs, "chatgpt/feature", "current-sha")).toEqual(runs);
|
||||
});
|
||||
|
||||
it("still fails closed when the latest run of any workflow is not successful", () => {
|
||||
expect(() => assertSuccessfulActionRuns([
|
||||
actionRun({ id: 10, conclusion: "success", workflow: "quality.yml" }),
|
||||
actionRun({ id: 11, conclusion: "failure", workflow: "security.yml" }),
|
||||
], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||
|
||||
expect(() => assertSuccessfulActionRuns([
|
||||
actionRun({ id: 12, conclusion: "success" }),
|
||||
actionRun({ id: 13, conclusion: "", status: "in_progress" }),
|
||||
], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
|
||||
});
|
||||
|
||||
it("rejects shipped placeholder credentials in production", () => {
|
||||
expect(() => loadEnv({
|
||||
...productionEnv,
|
||||
PROJECTBRAIN_OWNER_PASSWORD: "replace-with-a-unique-password-of-at-least-12-characters",
|
||||
})).toThrow("PROJECTBRAIN_OWNER_PASSWORD must not use a placeholder");
|
||||
|
||||
expect(() => loadEnv({
|
||||
...productionEnv,
|
||||
PROJECTBRAIN_AUTH_SECRET: "replace-with-at-least-32-random-characters",
|
||||
})).toThrow("PROJECTBRAIN_AUTH_SECRET must not use a placeholder");
|
||||
|
||||
expect(() => loadEnv({
|
||||
...productionEnv,
|
||||
DATABASE_URL: "postgresql://projectbrain:projectbrain@postgres:5432/projectbrain",
|
||||
})).toThrow("DATABASE_URL database password must not use a placeholder");
|
||||
});
|
||||
|
||||
it("bounds complete MCP repository file reads", () => {
|
||||
const file = (size: number): GiteaContent => ({
|
||||
type: "file",
|
||||
name: "large.txt",
|
||||
path: "large.txt",
|
||||
sha: "abc",
|
||||
size,
|
||||
encoding: "base64",
|
||||
content: Buffer.from("safe content").toString("base64"),
|
||||
html_url: "https://gitea.example/large.txt",
|
||||
});
|
||||
|
||||
expect(decodeFile(file(MAX_MCP_READ_BYTES))).toBe("safe content");
|
||||
expect(() => decodeFile(file(MAX_MCP_READ_BYTES + 1))).toThrow("MCP_FILE_READ_TOO_LARGE");
|
||||
});
|
||||
|
||||
it("deletes the managed repository webhook through an exact bounded endpoint", async () => {
|
||||
const fetcher = vi.fn(async () => new Response(undefined, { status: 204 }));
|
||||
const client = new GiteaClient("https://gitea.example.com", "service-token", fetcher as typeof fetch);
|
||||
|
||||
await client.deleteRepositoryWebhook("Acme", "Repo", 42);
|
||||
|
||||
expect(String(fetcher.mock.calls[0][0])).toContain("/api/v1/repos/Acme/Repo/hooks/42");
|
||||
expect(fetcher.mock.calls[0][1]?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("treats partially successful analysis jobs as terminal but actionable", () => {
|
||||
const job = serializeAnalysisJob({
|
||||
id: "job-partial",
|
||||
kind: "portfolio_health",
|
||||
status: "partial",
|
||||
actor: "owner",
|
||||
progressCurrent: 7,
|
||||
progressTotal: 10,
|
||||
summaryText: "7 repositories geanalyseerd, 3 mislukt.",
|
||||
summaryJson: { repositoryCount: 10, succeeded: 7, failed: 3, findingCount: 2 },
|
||||
errorCode: "ANALYSIS_PARTIAL_FAILURE",
|
||||
cancelRequestedAt: null,
|
||||
queuedAt: new Date("2026-08-29T00:00:00Z"),
|
||||
startedAt: new Date("2026-08-29T00:00:01Z"),
|
||||
heartbeatAt: new Date("2026-08-29T00:01:00Z"),
|
||||
completedAt: new Date("2026-08-29T00:01:01Z"),
|
||||
expiresAt: new Date("2026-09-28T00:00:00Z"),
|
||||
createdAt: new Date("2026-08-29T00:00:00Z"),
|
||||
updatedAt: new Date("2026-08-29T00:01:01Z"),
|
||||
});
|
||||
|
||||
expect(job).toMatchObject({
|
||||
status: "partial",
|
||||
terminal: true,
|
||||
pollAfterMs: 0,
|
||||
nextAction: "read-results-and-inspect-failures",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import { GiteaClient } from "../src/integrations/gitea/client.js";
|
||||
import type { LinkedRepository } from "../src/integrations/gitea/service.js";
|
||||
import type { GiteaActionRun, GiteaPullRequest } from "../src/integrations/gitea/types.js";
|
||||
import { analyzeLinkedRepository, classifyActionFindings, loadActiveWorkflowEvents, parseWorkflowTriggerEvents } from "../src/services/repositoryAnalysis.js";
|
||||
import { projectAnalysisItemScope, resolveAnalysisTargets } from "../src/repositories/analysisJobRepository.js";
|
||||
|
||||
const envInput = {
|
||||
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_URL: "https://gitea.example.com",
|
||||
PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
const env = loadEnv(envInput);
|
||||
|
||||
const link: LinkedRepository = {
|
||||
id: "repo-1", projectId: "project-1", projectSlug: "project-one", projectName: "Project One",
|
||||
owner: "Acme", repository: "Repo", fullName: "Acme/Repo", defaultBranch: "main", accessMode: "read_write",
|
||||
role: "primary", isPrimary: true, syncStatus: "ready",
|
||||
};
|
||||
|
||||
const actionRun = (input: Partial<GiteaActionRun> & Pick<GiteaActionRun, "id" | "run_number">): GiteaActionRun => ({
|
||||
display_title: "CI",
|
||||
path: "ci.yml@refs/heads/main",
|
||||
event: "push",
|
||||
head_sha: `sha-${input.run_number}`,
|
||||
head_branch: "main",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
html_url: `https://gitea.example/actions/runs/${input.id}`,
|
||||
completed_at: `2026-08-28T00:${String(input.run_number).padStart(2, "0")}:00Z`,
|
||||
...input,
|
||||
});
|
||||
|
||||
const pullRequest = (number: number, branch: string): GiteaPullRequest => ({
|
||||
id: number,
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
state: "open",
|
||||
html_url: `https://gitea.example/pulls/${number}`,
|
||||
merged: false,
|
||||
created_at: "2026-08-28T00:00:00Z",
|
||||
updated_at: "2026-08-28T00:00:00Z",
|
||||
head: { ref: branch, sha: `head-${number}` },
|
||||
base: { ref: "main", sha: "main-sha" },
|
||||
});
|
||||
|
||||
describe("actionable Gitea Actions classification", () => {
|
||||
it.each([
|
||||
{ includeBranches: false, includePullRequests: true, includeActions: true, absent: ["REPOSITORY_EMPTY", "DEFAULT_BRANCH_UNPROTECTED", "BRANCH_SPRAWL"] },
|
||||
{ includeBranches: true, includePullRequests: false, includeActions: true, absent: ["OPEN_PR_BACKLOG", "TOPIC_BRANCH_ACTION_FAILED"] },
|
||||
{ includeBranches: true, includePullRequests: true, includeActions: false, absent: ["DEFAULT_BRANCH_ACTION_FAILED", "SCHEDULED_ACTION_FAILED", "TOPIC_BRANCH_ACTION_FAILED"] },
|
||||
{ includeBranches: true, includePullRequests: true, includeActions: true, absent: [] },
|
||||
])("records only actual rule coverage for partial analysis %j", async ({ absent, ...request }) => {
|
||||
const client = new GiteaClient("https://gitea.example", "fixture-token");
|
||||
vi.spyOn(client, "getRepository").mockResolvedValue({ default_branch: "main", archived: false, private: true, permissions: { push: true } } as never);
|
||||
const branches = vi.spyOn(client, "listBranches").mockResolvedValue([{ name: "main", protected: true }] as never);
|
||||
const prs = vi.spyOn(client, "listPullRequests").mockResolvedValue([]);
|
||||
const actions = vi.spyOn(client, "listActionRuns").mockResolvedValue({ workflow_runs: [actionRun({ id: 1, run_number: 1 })] } as never);
|
||||
vi.spyOn(client, "getContents").mockResolvedValue([]);
|
||||
const detail = await analyzeLinkedRepository(env, link, request, client);
|
||||
for (const code of absent) expect(detail.evaluatedRuleCodes).not.toContain(code);
|
||||
expect(branches).toHaveBeenCalledTimes(request.includeBranches ? 1 : 0);
|
||||
expect(prs).toHaveBeenCalledTimes(request.includePullRequests ? 1 : 0);
|
||||
expect(actions).toHaveBeenCalledTimes(request.includeActions ? 1 : 0);
|
||||
if (!absent.length) expect(detail.evaluatedRuleCodes).toEqual(expect.arrayContaining([
|
||||
"DEFAULT_BRANCH_UNPROTECTED", "OPEN_PR_BACKLOG", "DEFAULT_BRANCH_ACTION_FAILED", "TOPIC_BRANCH_ACTION_FAILED",
|
||||
]));
|
||||
});
|
||||
it("does not revive an older failure that finished after a newer successful run", () => {
|
||||
const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [
|
||||
actionRun({ id: 1, run_number: 1, conclusion: "failure", completed_at: "2026-09-09T12:00:00Z" }),
|
||||
actionRun({ id: 2, run_number: 2, conclusion: "success", completed_at: "2026-09-09T11:00:00Z" }),
|
||||
] });
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains a newer failure even if an older successful run finishes later", () => {
|
||||
const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [
|
||||
actionRun({ id: 1, run_number: 1, conclusion: "success", completed_at: "2026-09-09T12:00:00Z" }),
|
||||
actionRun({ id: 2, run_number: 2, conclusion: "failure", completed_at: "2026-09-09T11:00:00Z" }),
|
||||
] });
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", evidence: { runId: 2 } });
|
||||
});
|
||||
|
||||
it("does not hide a failing schedule behind a successful push in the same workflow", () => {
|
||||
const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [
|
||||
actionRun({ id: 1, run_number: 1, event: "schedule", conclusion: "failure" }),
|
||||
actionRun({ id: 2, run_number: 2, event: "push", conclusion: "success" }),
|
||||
] });
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({ code: "SCHEDULED_ACTION_FAILED", severity: "warning", evidence: { consecutiveFailures: 1 } });
|
||||
});
|
||||
|
||||
it("does not count push failures toward the scheduled failure threshold", () => {
|
||||
const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [
|
||||
actionRun({ id: 1, run_number: 1, conclusion: "failure" }),
|
||||
actionRun({ id: 2, run_number: 2, conclusion: "failure" }),
|
||||
actionRun({ id: 3, run_number: 3, event: "schedule", conclusion: "failure" }),
|
||||
] });
|
||||
expect(findings).toHaveLength(2);
|
||||
expect(findings.find((finding) => finding.code === "SCHEDULED_ACTION_FAILED")).toMatchObject({ severity: "warning", evidence: { consecutiveFailures: 1 } });
|
||||
expect(findings.find((finding) => finding.code === "DEFAULT_BRANCH_ACTION_FAILED")).toMatchObject({ severity: "critical", evidence: { consecutiveFailures: 2 } });
|
||||
});
|
||||
|
||||
it.each(["pull_request", "pull_request_target", "push"])("treats an explicit PR ref as PR evidence even with head_branch main (%s)", (event) => {
|
||||
const run = actionRun({ id: 2, run_number: 2, event, path: "ci.yml@refs/pull/7/merge", head_branch: "main", conclusion: "failure" });
|
||||
const findings = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [actionRun({ id: 1, run_number: 1 }), run], recentRuns: [run], openPullRequests: [pullRequest(7, "codex/work")] });
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({ code: "TOPIC_BRANCH_ACTION_FAILED", severity: "info", evidence: { branch: "codex/work" } });
|
||||
});
|
||||
|
||||
it("ignores in-flight agent branches while retaining a real default-branch failure", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [actionRun({ id: 1, run_number: 1, conclusion: "failure" })],
|
||||
recentRuns: [
|
||||
actionRun({ id: 2, run_number: 2, head_branch: "codex/work", path: "ci.yml@refs/heads/codex/work", conclusion: "failure" }),
|
||||
actionRun({ id: 1, run_number: 1, conclusion: "failure" }),
|
||||
],
|
||||
openPullRequests: [pullRequest(2, "codex/work")],
|
||||
});
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", severity: "critical", subject: "default:ci.yml" });
|
||||
});
|
||||
|
||||
it("escalates a scheduled workflow only after three consecutive failures", () => {
|
||||
const scheduled = (count: number) => classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: Array.from({ length: count }, (_, index) => actionRun({
|
||||
id: 10 - index,
|
||||
run_number: 10 - index,
|
||||
event: "schedule",
|
||||
path: "canary.yml@refs/heads/main",
|
||||
conclusion: "failure",
|
||||
})),
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
})[0];
|
||||
|
||||
expect(scheduled(2)).toMatchObject({ code: "SCHEDULED_ACTION_FAILED", severity: "warning", evidence: { consecutiveFailures: 2 } });
|
||||
expect(scheduled(3)).toMatchObject({ code: "SCHEDULED_ACTION_FAILED", severity: "critical", evidence: { consecutiveFailures: 3 } });
|
||||
});
|
||||
|
||||
it("keeps the previous completed failure while a newer run is still in progress", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [
|
||||
actionRun({ id: 3, run_number: 3, status: "in_progress", conclusion: undefined }),
|
||||
actionRun({ id: 2, run_number: 2, conclusion: "failure" }),
|
||||
],
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
});
|
||||
|
||||
expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", evidence: { runId: 2 } });
|
||||
});
|
||||
|
||||
it("does not treat cancelled or skipped conclusions as failures", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [actionRun({ id: 2, run_number: 2, conclusion: "cancelled" }), actionRun({ id: 1, run_number: 1, conclusion: "failure" })],
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("defensively rejects non-default runs even if Gitea ignores the branch query", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [
|
||||
actionRun({ id: 3, run_number: 3, head_branch: "codex/work", path: "ci.yml@refs/heads/codex/work", conclusion: "failure" }),
|
||||
actionRun({ id: 2, run_number: 2, head_branch: "main", conclusion: "success" }),
|
||||
],
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps workflows independent and includes actionable run evidence", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [
|
||||
actionRun({ id: 5, run_number: 5, path: "build.yml@refs/heads/main", conclusion: "success" }),
|
||||
actionRun({ id: 4, run_number: 4, path: "deploy.yml@refs/heads/main", conclusion: "failure", html_url: "https://gitea.example/actions/runs/4" }),
|
||||
],
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
});
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({
|
||||
code: "DEFAULT_BRANCH_ACTION_FAILED",
|
||||
subject: "default:deploy.yml",
|
||||
evidence: { workflow: "deploy.yml", branch: "main", runUrl: "https://gitea.example/actions/runs/4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores historical failures for workflow files removed from the default branch", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [
|
||||
actionRun({ id: 5, run_number: 5, path: "managed-validation.yml@refs/heads/main", conclusion: "success" }),
|
||||
actionRun({ id: 4, run_number: 4, path: "ci.yml@refs/heads/main", conclusion: "failure" }),
|
||||
],
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
activeWorkflows: ["managed-validation.yml"],
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains failures for workflow files that still exist on the default branch", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [actionRun({ id: 4, run_number: 4, path: "ci.yml@refs/heads/main", conclusion: "failure" })],
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
activeWorkflows: ["ci.yml"],
|
||||
});
|
||||
|
||||
expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml" });
|
||||
});
|
||||
|
||||
it("ignores a historical event removed from a workflow while retaining current events", () => {
|
||||
const historicalPush = actionRun({ id: 11, run_number: 11, path: "unraid-deploy.yml@refs/heads/main", event: "push", conclusion: "failure" });
|
||||
const manualFailure = actionRun({ id: 12, run_number: 12, path: "unraid-deploy.yml@refs/heads/main", event: "workflow_dispatch", conclusion: "failure" });
|
||||
const currentEvents = new Map([["unraid-deploy.yml", new Set(["workflow_dispatch"])]]);
|
||||
|
||||
const obsolete = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [historicalPush], recentRuns: [], openPullRequests: [], activeWorkflowEvents: currentEvents });
|
||||
expect(obsolete.some((finding) => finding.code === "DEFAULT_BRANCH_ACTION_FAILED")).toBe(false);
|
||||
|
||||
const current = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [manualFailure, historicalPush], recentRuns: [], openPullRequests: [], activeWorkflowEvents: currentEvents });
|
||||
expect(current[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:unraid-deploy.yml", evidence: { event: "workflow_dispatch", runId: 12 } });
|
||||
});
|
||||
|
||||
it("keeps historical failures actionable when current workflow events are unknown", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [actionRun({ id: 4, run_number: 4, path: "ci.yml@refs/heads/main", conclusion: "failure" })],
|
||||
recentRuns: [],
|
||||
openPullRequests: [],
|
||||
activeWorkflowEvents: new Map([["ci.yml", undefined]]),
|
||||
});
|
||||
expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml" });
|
||||
});
|
||||
|
||||
it("parses common top-level workflow trigger forms and rejects ambiguous syntax", () => {
|
||||
expect([...parseWorkflowTriggerEvents("on: push\njobs: {}") ?? []]).toEqual(["push"]);
|
||||
expect([...parseWorkflowTriggerEvents("on: [push, pull_request]\njobs: {}") ?? []]).toEqual(["push", "pull_request"]);
|
||||
expect([...parseWorkflowTriggerEvents("on:\n push:\n branches: [main]\n workflow_dispatch:\njobs: {}") ?? []]).toEqual(["push", "workflow_dispatch"]);
|
||||
expect(parseWorkflowTriggerEvents("on: { push: null }\njobs: {}"), "inline maps stay conservative").toBeUndefined();
|
||||
expect(parseWorkflowTriggerEvents("jobs:\n build:\n runs-on: ubuntu-latest"), "missing on stays conservative").toBeUndefined();
|
||||
});
|
||||
|
||||
it("loads current workflow events and falls back conservatively when one file cannot be read", async () => {
|
||||
const fetcher = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes("manual.yml")) return new Response(JSON.stringify({ type: "file", name: "manual.yml", path: ".gitea/workflows/manual.yml", sha: "manual", size: 20, encoding: "base64", content: Buffer.from("on:\n workflow_dispatch:\n").toString("base64") }), { status: 200 });
|
||||
return new Response(JSON.stringify({ message: "unavailable" }), { status: 503 });
|
||||
});
|
||||
const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch);
|
||||
const events = await loadActiveWorkflowEvents(client, "Acme", "Repo", "main", [
|
||||
{ type: "file", name: "manual.yml", path: ".gitea/workflows/manual.yml", sha: "manual", size: 20 },
|
||||
{ type: "file", name: "unknown.yml", path: ".gitea/workflows/unknown.yml", sha: "unknown", size: 20 },
|
||||
]);
|
||||
expect([...(events?.get("manual.yml") ?? [])]).toEqual(["workflow_dispatch"]);
|
||||
expect(events?.has("unknown.yml")).toBe(true);
|
||||
expect(events?.get("unknown.yml")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("downgrades an open pull-request failure to info and ignores closed pull requests", () => {
|
||||
const run = actionRun({
|
||||
id: 8,
|
||||
run_number: 8,
|
||||
event: "pull_request",
|
||||
head_branch: undefined,
|
||||
path: "release-gates.yml@refs/pull/7/head",
|
||||
conclusion: "failure",
|
||||
});
|
||||
const active = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [actionRun({ id: 1, run_number: 1 })], recentRuns: [run], openPullRequests: [pullRequest(7, "feature/release")] });
|
||||
const closed = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [actionRun({ id: 1, run_number: 1 })], recentRuns: [run], openPullRequests: [] });
|
||||
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0]).toMatchObject({ code: "TOPIC_BRANCH_ACTION_FAILED", severity: "info", evidence: { branch: "feature/release" } });
|
||||
expect(closed).toEqual([]);
|
||||
});
|
||||
|
||||
it("recognizes Gitea pull-request merge refs", () => {
|
||||
const findings = classifyActionFindings({
|
||||
defaultBranch: "main",
|
||||
defaultBranchRuns: [actionRun({ id: 1, run_number: 1 })],
|
||||
recentRuns: [actionRun({ id: 9, run_number: 9, head_branch: undefined, event: "pull_request", path: "ci.yml@refs/pull/9/merge", conclusion: "failure" })],
|
||||
openPullRequests: [pullRequest(9, "feature/merge-ref")],
|
||||
});
|
||||
|
||||
expect(findings[0]).toMatchObject({ code: "TOPIC_BRANCH_ACTION_FAILED", evidence: { branch: "feature/merge-ref" } });
|
||||
});
|
||||
|
||||
it("reports absent default-branch CI and an agent branch configured as default separately", () => {
|
||||
expect(classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [], recentRuns: [], openPullRequests: [] })[0]).toMatchObject({ code: "DEFAULT_BRANCH_CI_ABSENT", severity: "warning" });
|
||||
expect(classifyActionFindings({ defaultBranch: "codex/temporary", defaultBranchRuns: [], recentRuns: [], openPullRequests: [] })).toEqual([
|
||||
expect.objectContaining({ code: "DEFAULT_BRANCH_IS_AGENT_BRANCH", severity: "warning", subject: "codex/temporary" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reclassifies the frozen twelve-repository production sample without hiding failures", () => {
|
||||
const failed = (branch: string, event: string = "push", path = "ci.yml") => actionRun({ id: 10, run_number: 10, head_branch: branch, event, path: `${path}@refs/heads/${branch}`, conclusion: "failure" });
|
||||
const cases = [
|
||||
{ repository: "Gifted", defaultBranch: "main", defaults: [failed("main")], recent: [] },
|
||||
{ repository: "ITWorx-Pulse", defaultBranch: "master", defaults: [failed("master")], recent: [failed("codex/integration")] },
|
||||
{ repository: "Microsoft-Cloud-Operations-Platform", defaultBranch: "main", defaults: [failed("main", "push", "unraid-deploy.yml")], recent: [] },
|
||||
{ repository: "MobilityOps", defaultBranch: "master", defaults: [failed("master", "schedule", "live-canary.yml"), actionRun({ ...failed("master", "schedule", "live-canary.yml"), id: 9, run_number: 9 }), actionRun({ ...failed("master", "schedule", "live-canary.yml"), id: 8, run_number: 8 })], recent: [] },
|
||||
{ repository: "PentestForge", defaultBranch: "main", defaults: [], recent: [failed("chatgpt/m1-control-plane", "workflow_dispatch", "managed-validation.yml")] },
|
||||
{ repository: "Portfolio", defaultBranch: "main", defaults: [failed("main", "push", "deploy.yml")], recent: [] },
|
||||
{ repository: "RAGcore", defaultBranch: "main", defaults: [failed("main", "push", "unraid-deploy.yml")], recent: [] },
|
||||
{ repository: "chimera-retroarch", defaultBranch: "codex/ps5-loader-entry-diagnosis", defaults: [failed("codex/ps5-loader-entry-diagnosis", "schedule", "crowdin-daily.yml")], recent: [] },
|
||||
{ repository: "geointel", defaultBranch: "main", defaults: [], recent: [actionRun({ id: 7, run_number: 7, head_branch: undefined, event: "pull_request", path: "release-gates.yml@refs/pull/1/head", conclusion: "failure" })], pullRequests: [pullRequest(1, "feature/geointel")] },
|
||||
{ repository: "itworx-knowledge-hub", defaultBranch: "main", defaults: [failed("main")], recent: [] },
|
||||
{ repository: "opscenter", defaultBranch: "main", defaults: [actionRun({ id: 6, run_number: 6, head_branch: "main", conclusion: "success" })], recent: [failed("chatgpt/remediation")] },
|
||||
{ repository: "spoortrack", defaultBranch: "main", defaults: [], recent: [actionRun({ id: 5, run_number: 5, head_branch: undefined, event: "pull_request", path: "ci.yml@refs/pull/1/head", conclusion: "failure" })], pullRequests: [pullRequest(1, "feature/spoortrack")] },
|
||||
];
|
||||
const classified = cases.map((entry) => ({
|
||||
repository: entry.repository,
|
||||
findings: classifyActionFindings({ defaultBranch: entry.defaultBranch, defaultBranchRuns: entry.defaults, recentRuns: entry.recent, openPullRequests: entry.pullRequests ?? [] }),
|
||||
}));
|
||||
|
||||
expect(classified.filter((entry) => entry.findings.some((finding) => finding.severity === "critical")).map((entry) => entry.repository)).toEqual([
|
||||
"Gifted", "ITWorx-Pulse", "Microsoft-Cloud-Operations-Platform", "MobilityOps", "Portfolio", "RAGcore", "itworx-knowledge-hub",
|
||||
]);
|
||||
expect(classified.find((entry) => entry.repository === "opscenter")?.findings).toEqual([]);
|
||||
expect(classified.find((entry) => entry.repository === "PentestForge")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_CI_ABSENT"]);
|
||||
expect(classified.find((entry) => entry.repository === "chimera-retroarch")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_IS_AGENT_BRANCH"]);
|
||||
expect(classified.find((entry) => entry.repository === "geointel")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_CI_ABSENT", "TOPIC_BRANCH_ACTION_FAILED"]);
|
||||
expect(classified.find((entry) => entry.repository === "spoortrack")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_CI_ABSENT", "TOPIC_BRANCH_ACTION_FAILED"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded repository analysis", () => {
|
||||
it("matches project-scoped jobs by internal id or browser-facing slug", () => {
|
||||
expect(projectAnalysisItemScope("web-privacy-auditor")).toEqual({
|
||||
OR: [{ projectId: "web-privacy-auditor" }, { projectSlug: "web-privacy-auditor" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers unlinked current and future repositories for an unscoped portfolio job", () => {
|
||||
const repositories = [
|
||||
{ id: 1, name: "Repo", full_name: "Acme/Repo", private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/Acme/Repo", updated_at: "2026-08-26T00:00:00Z", permissions: { push: true }, owner: { login: "Acme" } },
|
||||
{ id: 2, name: "Future", full_name: "Acme/Future", private: true, archived: false, default_branch: "trunk", html_url: "https://gitea.example/Acme/Future", updated_at: "2026-08-26T00:00:00Z", permissions: { pull: true }, owner: { login: "Acme" } },
|
||||
];
|
||||
const targets = resolveAnalysisTargets([link], repositories, { includeBranches: true, includePullRequests: true, includeActions: true });
|
||||
|
||||
expect(targets.map((target) => target.fullName)).toEqual(["Acme/Future", "Acme/Repo"]);
|
||||
expect(targets.find((target) => target.fullName === "Acme/Repo")?.repositoryLinkId).toBe("repo-1");
|
||||
expect(targets.find((target) => target.fullName === "Acme/Future")).toMatchObject({ repositoryLinkId: undefined, projectSlug: "gitea-all", accessMode: "read_only" });
|
||||
});
|
||||
|
||||
it("keeps project-scoped jobs limited to explicit ProjectBrain links", () => {
|
||||
const targets = resolveAnalysisTargets([link], [], { projectIds: ["project-one"], includeBranches: true, includePullRequests: true, includeActions: true });
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].repositoryLinkId).toBe("repo-1");
|
||||
});
|
||||
|
||||
it("reduces several Gitea reads to a compact finding summary", async () => {
|
||||
const branches = Array.from({ length: 26 }, (_, index) => ({ name: index === 0 ? "main" : `branch-${index}`, commit: { id: `sha-${index}` }, protected: false }));
|
||||
const pullRequests = Array.from({ length: 11 }, (_, index) => ({ id: index, number: index, title: `PR ${index}`, state: "open", html_url: `https://gitea.example/pr/${index}`, merged: false, created_at: "2026-08-26T00:00:00Z", updated_at: "2026-08-26T00:00:00Z", head: { ref: `branch-${index}`, sha: `sha-${index}` }, base: { ref: "main", sha: "main-sha" } }));
|
||||
const fetcher = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/actions/runs")) return new Response(JSON.stringify({ total_count: 1, workflow_runs: [{ id: 1, run_number: 1, display_title: "CI", path: "ci.yml", event: "push", head_sha: "sha", head_branch: "main", status: "completed", conclusion: "failure", html_url: "https://gitea.example/run/1" }] }), { status: 200 });
|
||||
if (url.includes("/contents/.gitea/workflows/ci.yml")) return new Response(JSON.stringify({ type: "file", name: "ci.yml", path: ".gitea/workflows/ci.yml", sha: "workflow-file-sha", size: 20, encoding: "base64", content: Buffer.from("on:\n push:\n").toString("base64") }), { status: 200 });
|
||||
if (url.includes("/contents/.gitea/workflows")) return new Response(JSON.stringify([{ type: "file", name: "ci.yml", path: ".gitea/workflows/ci.yml", sha: "workflow-sha", size: 1 }]), { status: 200 });
|
||||
if (url.includes("/branches")) return new Response(JSON.stringify(branches), { status: 200 });
|
||||
if (url.includes("/pulls")) return new Response(JSON.stringify(pullRequests), { status: 200 });
|
||||
return new Response(JSON.stringify({ id: 1, name: "Repo", full_name: "Acme/Repo", private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/Acme/Repo", updated_at: "2026-08-26T00:00:00Z", permissions: { push: true }, owner: { login: "Acme" } }), { status: 200 });
|
||||
});
|
||||
const detail = await analyzeLinkedRepository(env, link, { includeBranches: true, includePullRequests: true, includeActions: true }, new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch));
|
||||
|
||||
expect(detail.branchSummary).toMatchObject({ total: 26, defaultProtected: false });
|
||||
expect(detail.pullRequestSummary).toEqual({ open: 11 });
|
||||
expect(detail.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["DEFAULT_BRANCH_UNPROTECTED", "BRANCH_SPRAWL", "OPEN_PR_BACKLOG", "DEFAULT_BRANCH_ACTION_FAILED"]));
|
||||
expect(detail.findings.find((finding) => finding.code === "DEFAULT_BRANCH_ACTION_FAILED")?.evidence).toMatchObject({ runUrl: "https://gitea.example/run/1", branch: "main" });
|
||||
expect(JSON.stringify(detail).length).toBeLessThan(8_000);
|
||||
expect(fetcher).toHaveBeenCalledTimes(7);
|
||||
expect(fetcher.mock.calls.map(([input]) => String(input))).toContainEqual(expect.stringContaining("branch=main"));
|
||||
});
|
||||
|
||||
it("treats Gitea's empty-repository pull-request 404 as an empty collection", async () => {
|
||||
const fetcher = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/branches")) return new Response("[]", { status: 200 });
|
||||
if (url.includes("/pulls")) return new Response(JSON.stringify({ message: "not found" }), { status: 404 });
|
||||
if (url.includes("/actions/runs")) return new Response(JSON.stringify({ total_count: 0, workflow_runs: [] }), { status: 200 });
|
||||
if (url.includes("/contents/.gitea/workflows")) return new Response(JSON.stringify({ message: "not found" }), { status: 404 });
|
||||
return new Response(JSON.stringify({ id: 1, name: "Repo", full_name: "Acme/Repo", private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/Acme/Repo", updated_at: "2026-08-26T00:00:00Z", permissions: { push: true }, owner: { login: "Acme" } }), { status: 200 });
|
||||
});
|
||||
|
||||
const detail = await analyzeLinkedRepository(env, link, { includeBranches: true, includePullRequests: true, includeActions: true }, new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch));
|
||||
|
||||
expect(detail.pullRequestSummary).toEqual({ open: 0 });
|
||||
expect(detail.findings).toEqual([{ severity: "info", code: "REPOSITORY_EMPTY", message: "Repository bevat nog geen commit of branch." }]);
|
||||
});
|
||||
|
||||
it("enforces safe worker concurrency and timeout bounds in configuration", () => {
|
||||
expect(() => loadEnv({ ...envInput, PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY: "3" })).toThrow();
|
||||
expect(() => loadEnv({ ...envInput, PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS: "10" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSafeGitProjectSourcePath } from "../src/services/gitSourceSync.js";
|
||||
import { chooseNextAutonomyWork } from "../src/services/autonomyScheduler.js";
|
||||
import {
|
||||
buildRepositoryAutonomyRoadmap,
|
||||
isRepositoryManagedRoadmap,
|
||||
mergeRepositoryAutonomyRoadmap,
|
||||
} from "../src/services/repositoryAutonomy.js";
|
||||
|
||||
const roadmapSource = {
|
||||
path: "ROADMAP.md",
|
||||
content: `# Phoenix roadmap
|
||||
|
||||
## M1 — Safe software foundation
|
||||
Build the strict software foundation while lab qualification can happen independently.
|
||||
|
||||
## M2 — K3s lab
|
||||
Provision only after the external lab gate is cleared.`,
|
||||
contentSha: "roadmap-sha",
|
||||
};
|
||||
|
||||
const backlogSource = (extraTasks: Array<Record<string, unknown>> = []) => ({
|
||||
path: "docs/implementation/backlog.json",
|
||||
contentSha: `backlog-sha-${extraTasks.length}`,
|
||||
content: JSON.stringify({
|
||||
schema_version: "1.0",
|
||||
project: "Phoenix",
|
||||
execution_enabled: false,
|
||||
tasks: [
|
||||
{
|
||||
id: "PHX-001",
|
||||
title: "Read-only lab qualification",
|
||||
milestone: "M1",
|
||||
depends_on: [],
|
||||
requirements: ["R02"],
|
||||
plan: "docs/implementation/01-foundation.md",
|
||||
execution: "qualification",
|
||||
status: "blocked_external",
|
||||
evidence: [],
|
||||
verified_commit: null,
|
||||
},
|
||||
{
|
||||
id: "PHX-002",
|
||||
title: "Strict contracts and development harness",
|
||||
milestone: "M1",
|
||||
depends_on: [],
|
||||
requirements: ["R03"],
|
||||
plan: "docs/implementation/01-foundation.md",
|
||||
execution: "code",
|
||||
status: "planned",
|
||||
evidence: [],
|
||||
verified_commit: null,
|
||||
},
|
||||
{
|
||||
id: "PHX-003",
|
||||
title: "API, database, durable jobs and outbox",
|
||||
milestone: "M1",
|
||||
depends_on: ["PHX-002"],
|
||||
requirements: ["R04"],
|
||||
plan: "docs/implementation/01-foundation.md",
|
||||
execution: "code",
|
||||
status: "planned",
|
||||
evidence: [],
|
||||
verified_commit: null,
|
||||
},
|
||||
{
|
||||
id: "PHX-006",
|
||||
title: "Safe K3s bootstrap",
|
||||
milestone: "M2",
|
||||
depends_on: ["PHX-001", "PHX-002"],
|
||||
requirements: ["R06"],
|
||||
plan: "docs/implementation/01-foundation.md",
|
||||
execution: "lab",
|
||||
status: "planned",
|
||||
evidence: [],
|
||||
verified_commit: null,
|
||||
},
|
||||
...extraTasks,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
describe("repository-native Auto Mode", () => {
|
||||
it("syncs planning truth without opening the source filter to arbitrary JSON", () => {
|
||||
expect(isSafeGitProjectSourcePath("ROADMAP.md")).toBe(true);
|
||||
expect(isSafeGitProjectSourcePath("docs/implementation/backlog.json")).toBe(true);
|
||||
expect(isSafeGitProjectSourcePath("docs/implementation/roadmap.json")).toBe(true);
|
||||
expect(isSafeGitProjectSourcePath("docs/implementation/random-data.json")).toBe(false);
|
||||
expect(isSafeGitProjectSourcePath("config/backlog.json")).toBe(false);
|
||||
expect(isSafeGitProjectSourcePath(".env")).toBe(false);
|
||||
});
|
||||
|
||||
it("turns a structured repository backlog into dependency-aware autonomous work", () => {
|
||||
const roadmap = buildRepositoryAutonomyRoadmap({
|
||||
roadmap: roadmapSource,
|
||||
backlog: backlogSource(),
|
||||
importedAt: "2026-09-21T12:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(roadmap).toBeDefined();
|
||||
expect(isRepositoryManagedRoadmap(roadmap)).toBe(true);
|
||||
expect(roadmap?.phases.map((phase) => phase.title)).toEqual([
|
||||
"M1 — Safe software foundation",
|
||||
"M2 — K3s lab",
|
||||
]);
|
||||
|
||||
const items = new Map(roadmap?.phases.flatMap((phase) => phase.items).map((item) => [item.id, item]));
|
||||
expect(items.get("PHX-001")).toMatchObject({ status: "pending", waitingExternal: true, source: "roadmap" });
|
||||
expect(items.get("PHX-002")).toMatchObject({ status: "pending", scope: "committed" });
|
||||
expect(items.get("PHX-002")).not.toHaveProperty("waitingExternal");
|
||||
expect(items.get("PHX-003")).toMatchObject({ dependsOn: ["PHX-002"] });
|
||||
expect(items.get("PHX-006")).toMatchObject({ waitingExternal: true, dependsOn: ["PHX-001", "PHX-002"] });
|
||||
expect(roadmap?.phases.every((phase) => phase.brainstormConverged)).toBe(true);
|
||||
|
||||
const next = chooseNextAutonomyWork(roadmap!.phases, roadmap!.currentPhaseIndex, "balanced");
|
||||
expect(next?.item.id).toBe("PHX-002");
|
||||
});
|
||||
|
||||
it("does not let a planning refresh newly complete an existing runtime item", () => {
|
||||
const current = buildRepositoryAutonomyRoadmap({
|
||||
roadmap: roadmapSource,
|
||||
backlog: backlogSource(),
|
||||
importedAt: "2026-09-21T12:00:00.000Z",
|
||||
})!;
|
||||
const currentItem = current.phases.flatMap((phase) => phase.items).find((item) => item.id === "PHX-002")!;
|
||||
currentItem.status = "pending";
|
||||
|
||||
const verifiedBacklog = JSON.parse(backlogSource().content) as { tasks: Array<Record<string, unknown>> };
|
||||
const task = verifiedBacklog.tasks.find((entry) => entry.id === "PHX-002")!;
|
||||
task.status = "verified";
|
||||
task.verified_commit = "deadbeef";
|
||||
|
||||
const incoming = buildRepositoryAutonomyRoadmap({
|
||||
roadmap: { ...roadmapSource, contentSha: "roadmap-sha-verified" },
|
||||
backlog: { ...backlogSource(), content: JSON.stringify(verifiedBacklog), contentSha: "backlog-sha-verified" },
|
||||
importedAt: "2026-09-21T13:00:00.000Z",
|
||||
})!;
|
||||
|
||||
const reconciled = mergeRepositoryAutonomyRoadmap(current, incoming);
|
||||
const item = reconciled.phases.flatMap((phase) => phase.items).find((entry) => entry.id === "PHX-002")!;
|
||||
expect(item.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("preserves verified runtime progress and explicit assignments when repository planning changes", () => {
|
||||
const current = buildRepositoryAutonomyRoadmap({
|
||||
roadmap: roadmapSource,
|
||||
backlog: backlogSource(),
|
||||
importedAt: "2026-09-21T12:00:00.000Z",
|
||||
})!;
|
||||
const phx002 = current.phases.flatMap((phase) => phase.items).find((item) => item.id === "PHX-002")!;
|
||||
phx002.status = "completed";
|
||||
phx002.attempts = 2;
|
||||
phx002.deliveryId = "delivery-phx-002";
|
||||
phx002.completionEvidence = ["merge:abc"];
|
||||
|
||||
current.phases[0].items.unshift({
|
||||
id: "assignment-1",
|
||||
title: "Explicit owner task",
|
||||
instruction: "Keep this assignment across repo refreshes.",
|
||||
source: "user_assignment",
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
});
|
||||
|
||||
const incoming = buildRepositoryAutonomyRoadmap({
|
||||
roadmap: { ...roadmapSource, contentSha: "roadmap-sha-2" },
|
||||
backlog: backlogSource([{
|
||||
id: "PHX-004",
|
||||
title: "Auth and target guard",
|
||||
milestone: "M1",
|
||||
depends_on: ["PHX-003"],
|
||||
requirements: ["R05"],
|
||||
plan: "docs/implementation/01-foundation.md",
|
||||
execution: "code",
|
||||
status: "planned",
|
||||
evidence: [],
|
||||
verified_commit: null,
|
||||
}]),
|
||||
importedAt: "2026-09-21T13:00:00.000Z",
|
||||
})!;
|
||||
|
||||
const reconciled = mergeRepositoryAutonomyRoadmap(current, incoming);
|
||||
const items = new Map(reconciled.phases.flatMap((phase) => phase.items).map((item) => [item.id, item]));
|
||||
|
||||
expect(items.get("PHX-002")).toMatchObject({
|
||||
status: "completed",
|
||||
attempts: 2,
|
||||
deliveryId: "delivery-phx-002",
|
||||
completionEvidence: expect.arrayContaining(["merge:abc"]),
|
||||
});
|
||||
expect(items.get("assignment-1")).toMatchObject({ source: "user_assignment", status: "pending" });
|
||||
expect(items.get("PHX-004")).toMatchObject({ dependsOn: ["PHX-003"], status: "pending" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertExplicitRepositoryTransfer,
|
||||
assertRepositoryTransferHasNoDeliveryHistory,
|
||||
GiteaRepositoryOwnershipConflictError,
|
||||
GiteaRepositoryTransferBlockedError,
|
||||
} from "../src/integrations/gitea/service.js";
|
||||
import { integrationError } from "../src/routes/giteaIntegration.js";
|
||||
|
||||
describe("Gitea repository ownership", () => {
|
||||
const existing = { projectId: "project-a", project: { name: "Project A" } };
|
||||
|
||||
it("blocks a silent move to another project with an HTTP 409 conflict", () => {
|
||||
let conflict: unknown;
|
||||
try {
|
||||
assertExplicitRepositoryTransfer(existing, "project-b", "Acme/platform", false);
|
||||
} catch (error) {
|
||||
conflict = error;
|
||||
}
|
||||
|
||||
expect(conflict).toBeInstanceOf(GiteaRepositoryOwnershipConflictError);
|
||||
expect(integrationError(conflict)).toMatchObject({
|
||||
status: 409,
|
||||
code: "GITEA_REPOSITORY_ALREADY_LINKED",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows same-project updates and an explicitly confirmed transfer", () => {
|
||||
expect(() => assertExplicitRepositoryTransfer(existing, "project-a", "Acme/platform", false)).not.toThrow();
|
||||
expect(() => assertExplicitRepositoryTransfer(existing, "project-b", "Acme/platform", true)).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks even an explicit transfer when delivery history would cross project boundaries", () => {
|
||||
let conflict: unknown;
|
||||
try {
|
||||
assertRepositoryTransferHasNoDeliveryHistory("Acme/platform", 2);
|
||||
} catch (error) {
|
||||
conflict = error;
|
||||
}
|
||||
|
||||
expect(conflict).toBeInstanceOf(GiteaRepositoryTransferBlockedError);
|
||||
expect(integrationError(conflict)).toMatchObject({
|
||||
status: 409,
|
||||
code: "GITEA_REPOSITORY_TRANSFER_BLOCKED",
|
||||
});
|
||||
expect(() => assertRepositoryTransferHasNoDeliveryHistory("Acme/platform", 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ReviewStatus } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reviewRequiresHumanAttention } from "../src/repositories/reviewAttentionPolicy.js";
|
||||
|
||||
const review = (overrides: Partial<{ status: ReviewStatus; source: string; sourceType: string; createdBy: string }> = {}) => ({
|
||||
status: ReviewStatus.pending,
|
||||
source: "projectbrain_runtime",
|
||||
sourceType: "documentation_update",
|
||||
createdBy: "api",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("review attention policy", () => {
|
||||
it("keeps genuine unresolved reviews visible", () => {
|
||||
expect(reviewRequiresHumanAttention(review())).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps seed and smoke evidence out of the owner's attention count", () => {
|
||||
expect(reviewRequiresHumanAttention(review({ createdBy: "seed" }))).toBe(false);
|
||||
expect(reviewRequiresHumanAttention(review({ source: "rc41-smoke-proof" }))).toBe(false);
|
||||
expect(reviewRequiresHumanAttention(review({ sourceType: "SMOKE_IMPORT" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an unresolved status", () => {
|
||||
expect(reviewRequiresHumanAttention(review({ status: ReviewStatus.accepted }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
|
||||
const ownerPassword = "correct-horse-battery-staple";
|
||||
const appEnv = () => loadEnv({
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PROJECTBRAIN_DATA_SOURCE: "fixture",
|
||||
PROJECTBRAIN_OWNER_USERNAME: "owner",
|
||||
PROJECTBRAIN_OWNER_PASSWORD: ownerPassword,
|
||||
PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters",
|
||||
PROJECTBRAIN_SESSION_TTL_HOURS: "1",
|
||||
PROJECTBRAIN_TRUST_PROXY: "false",
|
||||
});
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
describe("capture and review state machines", () => {
|
||||
it("classifies once, queues once, and keeps final review decisions immutable", async () => {
|
||||
const app = await buildApp({ env: appEnv() });
|
||||
apps.push(app);
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "owner", password: ownerPassword },
|
||||
});
|
||||
const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session");
|
||||
const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`;
|
||||
const csrfToken = login.json().data.csrfToken as string;
|
||||
let operation = 0;
|
||||
const mutate = (method: "POST" | "PATCH", url: string, payload: Record<string, unknown> = {}) =>
|
||||
app.inject({
|
||||
method,
|
||||
url,
|
||||
payload,
|
||||
headers: {
|
||||
cookie,
|
||||
"x-csrf-token": csrfToken,
|
||||
"idempotency-key": `review-state-machine-${++operation}`,
|
||||
},
|
||||
});
|
||||
|
||||
const capture = await mutate("POST", "/api/projects/widefrog/capture-items", {
|
||||
type: "note",
|
||||
content: "A decision that must pass through one governed review.",
|
||||
source: "state-machine-test",
|
||||
});
|
||||
expect(capture.statusCode).toBe(201);
|
||||
const captureId = capture.json().data.id as string;
|
||||
|
||||
const prematureQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`);
|
||||
expect(prematureQueue.statusCode).toBe(409);
|
||||
expect(prematureQueue.json().error.code).toBe("CAPTURE_NOT_CLASSIFIED");
|
||||
|
||||
expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${captureId}/classify`)).statusCode).toBe(200);
|
||||
const queued = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`);
|
||||
expect(queued.statusCode).toBe(201);
|
||||
const reviewId = queued.json().data.createdEntityId as string;
|
||||
|
||||
const duplicateQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`);
|
||||
expect(duplicateQueue.statusCode).toBe(409);
|
||||
expect(duplicateQueue.json().error.code).toBe("CAPTURE_ALREADY_IN_REVIEW");
|
||||
const reclassify = await mutate("PATCH", `/api/projects/widefrog/capture-items/${captureId}/classify`);
|
||||
expect(reclassify.statusCode).toBe(409);
|
||||
expect(reclassify.json().error.code).toBe("CAPTURE_ALREADY_IN_REVIEW");
|
||||
|
||||
const edited = await mutate("PATCH", `/api/projects/widefrog/review-queue/${reviewId}`, {
|
||||
description: "Edited review content that still needs a final decision.",
|
||||
});
|
||||
expect(edited.statusCode).toBe(200);
|
||||
expect(edited.json().data.description).toContain("Edited review content");
|
||||
|
||||
const invalidMerge = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/merge`, {
|
||||
targetEntityId: "does-not-exist",
|
||||
});
|
||||
expect(invalidMerge.statusCode).toBe(409);
|
||||
expect(invalidMerge.json().error.code).toBe("REVIEW_TARGET_NOT_FOUND");
|
||||
|
||||
const unsupportedConversion = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/convert`, {
|
||||
target: "decision",
|
||||
});
|
||||
expect(unsupportedConversion.statusCode).toBe(501);
|
||||
expect(unsupportedConversion.json().error.code).toBe("DATABASE_MODE_REQUIRED");
|
||||
|
||||
expect((await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`)).statusCode).toBe(200);
|
||||
const overwrite = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/reject`);
|
||||
expect(overwrite.statusCode).toBe(409);
|
||||
expect(overwrite.json().error.code).toBe("REVIEW_ALREADY_DECIDED");
|
||||
const editFinal = await mutate("PATCH", `/api/projects/widefrog/review-queue/${reviewId}`, { notes: "overwrite" });
|
||||
expect(editFinal.statusCode).toBe(409);
|
||||
expect(editFinal.json().error.code).toBe("REVIEW_ALREADY_DECIDED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import Fastify from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadEnv } from "../src/config/env.js";
|
||||
import type { AutonomyRuntimeSnapshot } from "../src/services/autonomyRuntime.js";
|
||||
import { registerSystemReadinessRoutes } from "../src/routes/systemReadiness.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ runtime: vi.fn(), query: vi.fn(), worker: vi.fn() }));
|
||||
vi.mock("../src/services/autonomyRuntime.js", () => ({ loadAutonomyRuntime: mocks.runtime }));
|
||||
vi.mock("../src/db/prisma.js", () => ({ prisma: {
|
||||
$queryRaw: mocks.query,
|
||||
project: { count: vi.fn(async () => 1) },
|
||||
blueprintGraph: { count: vi.fn(async () => 1) },
|
||||
portfolioRepository: { count: vi.fn(async () => 1) },
|
||||
giteaRepositoryLink: { count: vi.fn(async () => 1) },
|
||||
analysisWorkerHeartbeat: { findUnique: mocks.worker },
|
||||
analysisJob: { count: vi.fn(async () => 0) },
|
||||
} }));
|
||||
|
||||
const snapshot = (): AutonomyRuntimeSnapshot => ({
|
||||
executors: [], projects: [],
|
||||
counts: { connectedExecutors: 0, enabledProjects: 0, claimableProjects: 0,
|
||||
activeProjects: 0, pendingWorkItems: 0, blockedProjects: 0,
|
||||
waitingRoadmapProjects: 0, stalledProjects: 0 },
|
||||
});
|
||||
const apps: ReturnType<typeof Fastify>[] = [];
|
||||
const requestReadiness = async (runtime: AutonomyRuntimeSnapshot) => {
|
||||
mocks.runtime.mockResolvedValue(runtime);
|
||||
const app = Fastify();
|
||||
apps.push(app);
|
||||
await registerSystemReadinessRoutes(app, { env: loadEnv({
|
||||
NODE_ENV: "test", PROJECTBRAIN_DATA_SOURCE: "database",
|
||||
DATABASE_URL: "postgresql://fixture:fixture@localhost:5432/fixture",
|
||||
PROJECTBRAIN_OWNER_USERNAME: "fixture-owner",
|
||||
PROJECTBRAIN_OWNER_PASSWORD: "fixture-only-owner-password",
|
||||
PROJECTBRAIN_AUTH_SECRET: "fixture-only-auth-secret-at-least-32-characters",
|
||||
}) });
|
||||
const response = await app.inject({ method: "GET", url: "/api/system/readiness" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
return response.json().data;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.query.mockResolvedValue([{ count: 1n }]);
|
||||
mocks.worker.mockResolvedValue({ status: "ready", lastSeenAt: new Date() });
|
||||
});
|
||||
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
|
||||
|
||||
describe("autonomy readiness demand", () => {
|
||||
it.each([false, true])("does not require an executor when all Auto work is paused (historical heartbeat: %s)", async (historical) => {
|
||||
const runtime = snapshot();
|
||||
if (historical) runtime.executor = { status: "stale", instanceId: "old-web-task",
|
||||
startedAt: "2026-01-01T00:00:00.000Z", lastSeenAt: "2026-01-01T00:00:00.000Z" };
|
||||
const data = await requestReadiness(runtime);
|
||||
expect(data.status).toBe("ready");
|
||||
expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "skip" });
|
||||
expect(data.autonomyExecutor).toMatchObject({ status: "ready", connectedExecutors: 0 });
|
||||
expect(data.autonomyExecutor.lastSeenAt).toBe(runtime.executor?.lastSeenAt);
|
||||
});
|
||||
|
||||
it.each(["enabledProjects", "claimableProjects", "activeProjects", "pendingWorkItems"] as const)(
|
||||
"preserves warning without an executor when %s is positive", async (count) => {
|
||||
const runtime = snapshot();
|
||||
runtime.counts[count] = 1;
|
||||
const data = await requestReadiness(runtime);
|
||||
expect(data.status).toBe("degraded");
|
||||
expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "warn" });
|
||||
expect(data.autonomyExecutor.connectedExecutors).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps fresh connected executor and pending work ready", async () => {
|
||||
const runtime = snapshot();
|
||||
runtime.counts.connectedExecutors = runtime.counts.enabledProjects = runtime.counts.pendingWorkItems = 1;
|
||||
const data = await requestReadiness(runtime);
|
||||
expect(data.status).toBe("ready");
|
||||
expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "pass" });
|
||||
});
|
||||
|
||||
it("retains a stale heartbeat warning when enabled work still needs the executor", async () => {
|
||||
const runtime = snapshot();
|
||||
runtime.counts.enabledProjects = runtime.counts.pendingWorkItems = 1;
|
||||
runtime.executor = { status: "stale", instanceId: "old-web-task",
|
||||
startedAt: "2026-01-01T00:00:00.000Z", lastSeenAt: "2026-01-01T00:00:00.000Z" };
|
||||
const data = await requestReadiness(runtime);
|
||||
expect(data.status).toBe("degraded");
|
||||
expect(data.autonomyExecutor).toMatchObject({ status: "degraded", connectedExecutors: 0,
|
||||
lastSeenAt: runtime.executor.lastSeenAt });
|
||||
expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "warn" });
|
||||
});
|
||||
|
||||
it("does not suppress other readiness warnings while Auto is paused", async () => {
|
||||
mocks.worker.mockResolvedValue(null);
|
||||
expect((await requestReadiness(snapshot())).status).toBe("degraded");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user