Files
ThothII/backend/test/workspaces-git-repository.test.ts
T

301 lines
12 KiB
TypeScript

import { execFile } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, test } from "vitest";
import { GitWorkspaceRepository, WorkspaceRepositoryLock } from "../src/workspaces/git-repository.js";
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
const validYaml = `workspace:
schema_version: 2
id: psd-clinical
name: Policlinico San Donato
language: it
dwh:
engine: postgres
database: postgres
schema: datawarehouse
supported_transports: [postgres_direct]
semantic_index:
vector_store:
engine: pgvector
database: postgres
schema: vectors
collection: clinical_documents
dimensions: 768
distance: cosine
supported_transports: [pgvector_direct]
embedding:
provider: ollama_compatible
model: nomic-embed-text-v2-moe
dimensions: 768
llm_policy:
allowed: [zai/glm-5.2]
`;
const runFile = promisify(execFile);
const temporaryRoots: string[] = [];
afterEach(() => {
temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
});
async function git(cwd: string, args: string[]): Promise<void> {
await runFile("git", args, { cwd });
}
async function temporaryRemote(): Promise<{ root: string; remote: string; source: string; initialCommit: string }> {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-"));
temporaryRoots.push(root);
const remote = join(root, "remote.git");
const source = join(root, "source");
await git(root, ["init", "--bare", "--initial-branch=main", remote]);
mkdirSync(source);
await git(source, ["init", "--initial-branch=main"]);
await git(source, ["config", "user.name", "Workspace Registry Test"]);
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
mkdirSync(join(source, "workspaces"));
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml);
writeFileSync(join(source, "workspaces", "research.yaml"), validYaml
.replace("id: psd-clinical", "id: research"));
mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true });
mkdirSync(join(source, "workspace-content", "other", "evidence"), { recursive: true });
mkdirSync(join(source, "workspace-content", "blob"), { recursive: true });
mkdirSync(join(source, "workspace-content", "link"), { recursive: true });
writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "guide v1\n");
writeFileSync(join(source, "workspace-content", "other", "evidence", "other.md"), "other\n");
writeFileSync(join(source, "workspace-content", "blob", "evidence"), "not a tree\n");
symlinkSync("../research/evidence", join(source, "workspace-content", "link", "evidence"));
symlinkSync("guide.md", join(source, "workspace-content", "research", "evidence", "nested-link"));
await git(source, ["add", "workspaces", "workspace-content"]);
await git(source, ["commit", "-m", "Initial workspace"]);
await git(source, ["remote", "add", "origin", remote]);
await git(source, ["push", "origin", "main"]);
const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: source });
return { root, remote, source, initialCommit: stdout.trim() };
}
function config(root: string, remoteUrl: string): WorkspaceRegistryConfig {
return {
root,
remoteUrl,
branch: "main",
gitAuthorName: "Workspace Registry Test",
gitAuthorEmail: "workspace-registry@example.invalid",
installationId: "test",
secretRoots: [],
maxImportBytes: 1024,
maxImportEntries: 1,
};
}
test("bootstraps a persistent checkout from a local bare repository", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
const status = await repository.bootstrap();
expect(status).toMatchObject({
branch: "main",
head: fixture.initialCommit,
ahead: 0,
behind: 0,
degraded: false,
});
});
test("accepts only a tree at the declared Evidence root for the requested revision", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
await repository.bootstrap();
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/research/evidence",
)).resolves.toBeUndefined();
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/missing/evidence",
)).rejects.toMatchObject({ code: "workspace_invalid" });
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/blob/evidence",
)).rejects.toMatchObject({ code: "workspace_invalid" });
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/link/evidence",
)).rejects.toMatchObject({ code: "workspace_invalid" });
});
test("redacts Git failures while checking an Evidence tree", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
await repository.bootstrap();
rmSync(repository.repoPath, { recursive: true, force: true });
const error = await repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/research/evidence",
).catch((failure: unknown) => failure);
expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" });
expect((error as Error).message).not.toContain(fixture.root);
});
test("classifies repository corruption as unavailable rather than invalid Evidence", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
await repository.bootstrap();
rmSync(join(repository.repoPath, ".git", "objects"), { recursive: true, force: true });
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/research/evidence",
)).rejects.toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" });
});
test("binds Evidence tree validation to old and new content-only commits", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
await repository.bootstrap();
writeFileSync(join(fixture.source, "workspace-content", "research", "evidence", "guide.md"), "guide v2\n");
await git(fixture.source, ["add", "workspace-content/research/evidence/guide.md"]);
await git(fixture.source, ["commit", "-m", "Update Evidence content"]);
await git(fixture.source, ["push", "origin", "main"]);
const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: fixture.source });
const newCommit = stdout.trim();
await repository.pull();
const oldTree = (await runFile("git", ["rev-parse", `${fixture.initialCommit}:workspace-content/research/evidence`], {
cwd: fixture.source,
})).stdout.trim();
const newTree = (await runFile("git", ["rev-parse", `${newCommit}:workspace-content/research/evidence`], {
cwd: fixture.source,
})).stdout.trim();
expect(newTree).not.toBe(oldTree);
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/research/evidence",
)).resolves.toBeUndefined();
await expect(repository.assertTreeAtRevision(
newCommit,
"workspace-content/research/evidence",
)).resolves.toBeUndefined();
});
test("defers nested Evidence symlink containment to P6", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
await repository.bootstrap();
// Task 2 validates only the declared root object. Recursive containment remains a P6 boundary.
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
"workspace-content/research/evidence",
)).resolves.toBeUndefined();
});
test("rejects malformed revisions and shell-like paths without executing them", async () => {
const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
await repository.bootstrap();
const marker = join(fixture.root, "shell-marker");
await expect(repository.assertTreeAtRevision(
"HEAD",
"workspace-content/research/evidence",
)).rejects.toMatchObject({ code: "workspace_invalid" });
await expect(repository.assertTreeAtRevision(
fixture.initialCommit,
`workspace-content/research/evidence;touch ${marker}`,
)).rejects.toMatchObject({ code: "workspace_invalid" });
expect(existsSync(marker)).toBe(false);
});
test("redacts failed Git checkout details behind a stable error code", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-missing-"));
temporaryRoots.push(root);
const remote = join(root, "missing.git");
const repository = new GitWorkspaceRepository(config(join(root, "registry"), remote));
const error = await repository.bootstrap().catch((error: unknown) => error);
expect(error).toMatchObject({
code: "git_unavailable",
message: "Workspace Git operation failed",
});
expect((error as Error).message).not.toContain(remote);
});
test("maps registry-layout failures to a stable redacted error", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-layout-"));
temporaryRoots.push(root);
const file = join(root, "not-a-directory");
writeFileSync(file, "occupied");
const repository = new GitWorkspaceRepository(config(file, join(root, "remote.git")));
const error = await repository.bootstrap().catch((error: unknown) => error);
expect(error).toMatchObject({
code: "git_unavailable",
message: "Workspace registry storage is unavailable",
});
expect((error as Error).message).not.toContain(file);
});
test("maps lock filesystem failures to a stable redacted error", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-lock-"));
temporaryRoots.push(root);
const file = join(root, "not-a-directory");
writeFileSync(file, "occupied");
const lock = new WorkspaceRepositoryLock(file);
const error = await lock.run(async () => undefined).catch((error: unknown) => error);
expect(error).toMatchObject({
code: "git_unavailable",
message: "Workspace registry lock is unavailable",
});
expect((error as Error).message).not.toContain(file);
});
test("releases its queue after a malformed lock failure so a later attempt can acquire", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-queue-"));
temporaryRoots.push(root);
const locks = join(root, "locks");
mkdirSync(join(locks, "repository.lock"), { recursive: true });
const lock = new WorkspaceRepositoryLock(locks);
await expect(lock.run(async () => "unreachable")).rejects.toMatchObject({ code: "git_unavailable" });
rmSync(join(locks, "repository.lock"), { recursive: true, force: true });
const result = await Promise.race([
lock.run(async () => "recovered"),
new Promise<string>((resolve) => setTimeout(() => resolve("timed out"), 2_000)),
]);
expect(result).toBe("recovered");
});
test("parallel contenders recover a stale lock file without overlapping critical sections", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-contenders-"));
temporaryRoots.push(root);
const locks = join(root, "locks");
mkdirSync(locks);
writeFileSync(join(locks, "repository.lock"), JSON.stringify({ pid: 999_999_999 }));
const first = new WorkspaceRepositoryLock(locks);
const second = new WorkspaceRepositoryLock(locks);
let active = 0;
let maximum = 0;
const critical = async () => {
active += 1;
maximum = Math.max(maximum, active);
await new Promise((resolve) => setTimeout(resolve, 25));
active -= 1;
};
const results = await Promise.allSettled([first.run(critical), second.run(critical)]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
expect(maximum).toBe(1);
});