rea/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts

265 lines
9.1 KiB
TypeScript

import {
lstat,
open,
readFile,
rename,
symlink,
unlink,
utimes,
writeFile,
} from "node:fs/promises";
import { DatabaseSync } from "node:sqlite";
import { expect, it, onTestFinished, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import type { StableArtifactFileSystem } from "../../../src/artifacts/readStableArtifact.js";
import { captureSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseSnapshot.js";
import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js";
import { sqliteDatabaseFailure } from "../../../src/sqlite/SqliteDatabaseFailures.js";
import {
createSqliteDatabaseFixture,
SQLITE_NATIVE_LIMITS_AVAILABLE,
} from "../../fixtures/sqlite/database.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../../support/workspace/workspaceFixture.js";
const fixture = async (wal = false) => {
const workspace = await createTestWorkspace("rea-sqlite-snapshot-race-");
const database = createSqliteDatabaseFixture(workspace.root, wal);
if (!wal) database.close();
const resourceScope = new ArtifactResourceScope();
onTestFinished(async () => {
await resourceScope.close();
if (wal) database.close();
await removeTestWorkspace(workspace.root);
});
const root = await workspace.mkdir("private");
const capture = (fileSystem?: StableArtifactFileSystem) =>
captureSqliteDatabaseSnapshot(
database.path,
root,
resourceScope,
undefined,
fileSystem,
);
return { workspace, database, root, capture };
};
it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE).each(["PERSIST", "TRUNCATE"])(
"inspects a committed %s journal without modifying the source file set",
async (mode) => {
const { database, capture } = await fixture();
const writer = new DatabaseSync(database.path);
try {
writer.exec(
`PRAGMA journal_mode=${mode}; INSERT INTO records(id, text_value) VALUES (4, 'committed');`,
);
} finally {
writer.close();
}
const journalPath = `${database.path}-journal`;
const before = await readFile(journalPath);
const beforeStat = await lstat(journalPath, { bigint: true });
const snapshot = await capture();
const result = inspectSqliteDatabaseSnapshot(snapshot.snapshotPath, {
path: database.path,
table: "records",
});
expect(result.rows).toMatchObject({ returned_rows: 4, truncated: false });
expect(await readFile(journalPath)).toEqual(before);
const afterStat = await lstat(journalPath, { bigint: true });
expect([
afterStat.ino,
afterStat.size,
afterStat.mtimeNs,
afterStat.ctimeNs,
]).toEqual([
beforeStat.ino,
beforeStat.size,
beforeStat.mtimeNs,
beforeStat.ctimeNs,
]);
},
);
it.runIf(process.platform !== "win32").each([
["database", "remove"],
["WAL", "remove"],
["database", "replace with symlink"],
["WAL", "replace with symlink"],
])(
"reports an observed %s change during acquisition as changed evidence: %s",
async (member, change) => {
const { database, workspace, capture } = await fixture(true);
const replacement = await workspace.write("replacement", "");
const selectedPath =
member === "database" ? database.path : database.walPath;
let observations = 0;
const fileSystem: StableArtifactFileSystem = {
lstat: async (path) => {
if (path === selectedPath && ++observations === 2) {
await unlink(path);
if (change === "replace with symlink")
await symlink(replacement, path);
}
return lstat(path, { bigint: true });
},
open,
};
try {
await capture(fileSystem);
throw new Error("A disappearing source must be rejected");
} catch (cause) {
expect(
sqliteDatabaseFailure(cause, database.path, "artifact-read"),
).toMatchObject({ _tag: "AnalysisArtifactChangedError" });
expect(cause).toMatchObject({
message: expect.stringContaining(selectedPath),
});
}
},
);
it("refuses an active journal even before SQLite writes its magic header", async () => {
const { database, capture } = await fixture();
const writer = new DatabaseSync(database.path);
try {
writer.exec(
"PRAGMA journal_mode=PERSIST; BEGIN IMMEDIATE; INSERT INTO records(id, text_value) VALUES (4, 'uncommitted');",
);
const journalPath = `${database.path}-journal`;
const journal = await readFile(journalPath);
expect(journal.subarray(0, 8).every((byte) => byte === 0)).toBe(true);
expect(journal.subarray(8, 28).some((byte) => byte !== 0)).toBe(true);
const original = await readFile(database.path);
await expect(capture()).rejects.toMatchObject({
reason: "format",
message: expect.stringContaining("recovery"),
});
expect(await readFile(database.path)).toEqual(original);
expect(await readFile(journalPath)).toEqual(journal);
} finally {
writer.exec("ROLLBACK");
writer.close();
}
});
it("refuses an incomplete zeroed journal header", async () => {
const { database, capture } = await fixture();
await writeFile(`${database.path}-journal`, Buffer.alloc(27));
await expect(capture()).rejects.toMatchObject({ reason: "format" });
});
it.runIf(process.platform !== "win32")(
"refuses a journal symlink even when its target is empty",
async () => {
const { database, workspace, capture } = await fixture();
const empty = await workspace.write("empty-journal", "");
await symlink(empty, `${database.path}-journal`);
await expect(capture()).rejects.toMatchObject({ reason: "path" });
},
);
it.each(["activate", "replace", "remove"])(
"rejects a cold journal changed during database acquisition: %s",
async (change) => {
const { database, capture } = await fixture();
const journalPath = `${database.path}-journal`;
await writeFile(journalPath, Buffer.alloc(28));
const fileSystem: StableArtifactFileSystem = {
lstat: (path) => lstat(path, { bigint: true }),
open: async (path, flags) => {
if (path === database.path) {
if (change === "activate")
await writeFile(journalPath, Buffer.alloc(28, 1));
else if (change === "remove") await unlink(journalPath);
else {
await rename(journalPath, `${journalPath}.previous`);
await writeFile(journalPath, Buffer.alloc(28));
}
}
return open(path, flags);
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
});
},
);
it("refuses an activated journal even when filesystem metadata cannot distinguish the write", async () => {
const { database, capture } = await fixture();
const journalPath = `${database.path}-journal`;
await writeFile(journalPath, Buffer.alloc(28));
const observed = await lstat(journalPath, { bigint: true });
const fileSystem: StableArtifactFileSystem = {
lstat: (path) =>
path === journalPath
? Promise.resolve(observed)
: lstat(path, { bigint: true }),
open: async (path, flags) => {
if (path === database.path)
await writeFile(journalPath, Buffer.alloc(28, 1));
const handle = await open(path, flags);
if (path === journalPath)
vi.spyOn(handle, "stat").mockResolvedValue(observed);
return handle;
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
});
});
it("rejects a database changed after its copy but before WAL acquisition", async () => {
const { database, root, capture } = await fixture(true);
const initial = await readFile(database.path);
const initialStat = await lstat(database.path);
let changed = false;
const fileSystem: StableArtifactFileSystem = {
lstat: (path) => lstat(path, { bigint: true }),
open: async (path, flags) => {
if (path === database.walPath) {
const bytes = Buffer.from(initial);
const offset = bytes.length - 1;
bytes[offset] = (bytes[offset] ?? 0) ^ 1;
await writeFile(database.path, bytes);
await utimes(database.path, initialStat.atime, initialStat.mtime);
changed = true;
}
return open(path, flags);
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
});
expect(changed).toBe(true);
expect(await readFile(`${root}/database.snapshot`)).toEqual(initial);
});
it.each(["-wal", "-journal"] as const)(
"rejects a previously absent %s created during database acquisition",
async (suffix) => {
const { database, capture } = await fixture();
let created = false;
const fileSystem: StableArtifactFileSystem = {
lstat: (path) => lstat(path, { bigint: true }),
open: async (path, flags) => {
if (path === database.path) {
await writeFile(
`${database.path}${suffix}`,
"newly selected sidecar",
);
created = true;
}
return open(path, flags);
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
});
expect(created).toBe(true);
},
);