348 lines
14 KiB
TypeScript
348 lines
14 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,
|
|
normalizeRepositoryIdentity,
|
|
} from "../src/workspaces/git-repository.js";
|
|
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
|
|
|
const validYaml = `workspace:
|
|
schema_version: 4
|
|
id: psd-clinical
|
|
name: Policlinico San Donato
|
|
language: it
|
|
dwh:
|
|
engine: postgres
|
|
database: postgres
|
|
schema: datawarehouse
|
|
supported_transports: [postgres_direct]
|
|
`;
|
|
|
|
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"]);
|
|
writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: psd-clinical, name: Policlinico San Donato}, {id: research, name: Policlinico San Donato}]\n");
|
|
mkdirSync(join(source, "psd-clinical"), { recursive: true });
|
|
mkdirSync(join(source, "research"), { recursive: true });
|
|
writeFileSync(join(source, "psd-clinical", "workspace.yaml"), validYaml);
|
|
writeFileSync(join(source, "research", "workspace.yaml"), validYaml
|
|
.replace("id: psd-clinical", "id: research"));
|
|
mkdirSync(join(source, "research", "evidence"), { recursive: true });
|
|
mkdirSync(join(source, "other", "evidence"), { recursive: true });
|
|
mkdirSync(join(source, "blob"), { recursive: true });
|
|
mkdirSync(join(source, "link"), { recursive: true });
|
|
writeFileSync(join(source, "research", "evidence", "guide.md"), "guide v1\n");
|
|
writeFileSync(join(source, "other", "evidence", "other.md"), "other\n");
|
|
writeFileSync(join(source, "blob", "evidence"), "not a tree\n");
|
|
symlinkSync("../research/evidence", join(source, "link", "evidence"));
|
|
symlinkSync("guide.md", join(source, "research", "evidence", "nested-link"));
|
|
await git(source, ["add", "-A"]);
|
|
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("does not expose repository mutation or publication operations", async () => {
|
|
const fixture = await temporaryRemote();
|
|
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
|
|
|
expect(repository).not.toHaveProperty("createRegistryFile");
|
|
expect(repository).not.toHaveProperty("writeRegistryFile");
|
|
expect(repository).not.toHaveProperty("removeRegistryFile");
|
|
expect(repository).not.toHaveProperty("commitAndPush");
|
|
});
|
|
|
|
test.each([
|
|
["https://github.com/aritmolab/workspaces.git", {
|
|
host: "github.com", repository: "aritmolab/workspaces", transport: "https",
|
|
}],
|
|
["ssh://git@gitlab.example.org/clinical/workspaces.git", {
|
|
host: "gitlab.example.org", repository: "clinical/workspaces", transport: "ssh",
|
|
}],
|
|
["git@gitea.example.org:clinical/workspaces.git", {
|
|
host: "gitea.example.org", repository: "clinical/workspaces", transport: "ssh",
|
|
}],
|
|
] as const)("normalizes the safe repository identity for %s", (remote, expected) => {
|
|
expect(normalizeRepositoryIdentity(remote)).toEqual(expected);
|
|
});
|
|
|
|
test.each([
|
|
"https://user:secret@github.com/aritmolab/workspaces.git",
|
|
"https://github.com/aritmolab/workspaces.git?token=secret",
|
|
"ssh://git:secret@gitlab.example.org/clinical/workspaces.git",
|
|
])("rejects a remote that could expose embedded credentials: %s", (remote) => {
|
|
expect(() => normalizeRepositoryIdentity(remote)).toThrow("Workspace Git remote is invalid");
|
|
});
|
|
|
|
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,
|
|
"research/evidence",
|
|
)).resolves.toBeUndefined();
|
|
await expect(repository.assertTreeAtRevision(
|
|
fixture.initialCommit,
|
|
"missing/evidence",
|
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
|
await expect(repository.assertTreeAtRevision(
|
|
fixture.initialCommit,
|
|
"blob/evidence",
|
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
|
await expect(repository.assertTreeAtRevision(
|
|
fixture.initialCommit,
|
|
"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,
|
|
"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,
|
|
"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, "research", "evidence", "guide.md"), "guide v2\n");
|
|
await git(fixture.source, ["add", "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}:research/evidence`], {
|
|
cwd: fixture.source,
|
|
})).stdout.trim();
|
|
const newTree = (await runFile("git", ["rev-parse", `${newCommit}:research/evidence`], {
|
|
cwd: fixture.source,
|
|
})).stdout.trim();
|
|
|
|
expect(newTree).not.toBe(oldTree);
|
|
await expect(repository.assertTreeAtRevision(
|
|
fixture.initialCommit,
|
|
"research/evidence",
|
|
)).resolves.toBeUndefined();
|
|
await expect(repository.assertTreeAtRevision(
|
|
newCommit,
|
|
"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,
|
|
"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",
|
|
"research/evidence",
|
|
)).rejects.toMatchObject({ code: "workspace_invalid" });
|
|
await expect(repository.assertTreeAtRevision(
|
|
fixture.initialCommit,
|
|
`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;
|
|
let firstEntered!: () => void;
|
|
const entered = new Promise<void>((resolve) => { firstEntered = resolve; });
|
|
let releaseFirst!: () => void;
|
|
const held = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
|
const firstRun = first.run(async () => {
|
|
active += 1;
|
|
maximum = Math.max(maximum, active);
|
|
firstEntered();
|
|
await held;
|
|
active -= 1;
|
|
});
|
|
await entered;
|
|
const secondRun = second.run(async () => {
|
|
active += 1;
|
|
maximum = Math.max(maximum, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
active -= 1;
|
|
});
|
|
|
|
let timeout!: ReturnType<typeof setTimeout>;
|
|
const contender = await Promise.race([
|
|
secondRun.then(
|
|
() => ({ status: "fulfilled" as const }),
|
|
() => ({ status: "rejected" as const }),
|
|
),
|
|
new Promise<{ status: "timed-out" }>((resolve) => {
|
|
timeout = setTimeout(() => resolve({ status: "timed-out" }), 2_000);
|
|
}),
|
|
]);
|
|
clearTimeout(timeout);
|
|
releaseFirst();
|
|
await firstRun;
|
|
await secondRun.catch(() => undefined);
|
|
|
|
expect(contender.status).toBe("rejected");
|
|
expect(maximum).toBe(1);
|
|
});
|