feat: complete TASK-WP0-04 local data boundary
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DATA_TRANSFER_POLICY,
|
||||
defaultInstanceConfigPath,
|
||||
defaultLocalDataRoot,
|
||||
initializeLocalDataRoot,
|
||||
inspectInitializedLocalDataRoot,
|
||||
resolvePathWithinRoot,
|
||||
validateLocalDataRoot,
|
||||
validateReadOnlyAssetRoot,
|
||||
type LocalDataRootBoundaries,
|
||||
} from "../../apps/api/src/local-data-root.js";
|
||||
|
||||
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
||||
const Database = requireFromApi("better-sqlite3");
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function temporaryDirectory() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "dada-wp0-04-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const base = temporaryDirectory();
|
||||
const projectRoot = join(base, "project");
|
||||
const downloadsRoot = join(base, "Downloads");
|
||||
const assetRoot = join(base, "read-only-assets");
|
||||
for (const directory of [projectRoot, downloadsRoot, assetRoot]) mkdirSync(directory);
|
||||
const boundaries: LocalDataRootBoundaries = {
|
||||
downloadsRoot,
|
||||
programRoot: process.cwd(),
|
||||
projectRoots: [projectRoot],
|
||||
readOnlyAssetRoots: [assetRoot],
|
||||
repositoryRoot: process.cwd(),
|
||||
};
|
||||
return { assetRoot, base, boundaries, downloadsRoot, projectRoot };
|
||||
}
|
||||
|
||||
function snapshot(directory: string): string[] {
|
||||
if (!existsSync(directory)) return [];
|
||||
return readdirSync(directory, { recursive: true }).map(String).sort();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("TDD-WP0-DATA-001-root-validation", () => {
|
||||
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
||||
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
||||
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
||||
expect(defaultInstanceConfigPath({ LOCALAPPDATA: localAppData })).toBe(
|
||||
join(localAppData, "Dada", "P0A", "config", "instance.json"),
|
||||
);
|
||||
expect(() => defaultLocalDataRoot({})).toThrow("local_app_data_unavailable");
|
||||
});
|
||||
|
||||
it("rejects repository, project, Downloads, read-only asset, junction and escaped paths without writes", () => {
|
||||
const { assetRoot, base, boundaries, downloadsRoot, projectRoot } = fixture();
|
||||
const outside = join(base, "junction-target");
|
||||
const junction = join(base, "junction-root");
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, junction, "junction");
|
||||
expect(lstatSync(junction).isSymbolicLink()).toBe(true);
|
||||
|
||||
const cases = [
|
||||
{ path: join(process.cwd(), ".dada-local", "forbidden"), reason: "repository_root" },
|
||||
{ path: join(projectRoot, "data"), reason: "project_root" },
|
||||
{ path: join(downloadsRoot, "data"), reason: "downloads_root" },
|
||||
{ path: join(assetRoot, "data"), reason: "read_only_asset_root" },
|
||||
{ path: join(junction, "data"), reason: "symbolic_link" },
|
||||
] as const;
|
||||
const before = snapshot(base);
|
||||
for (const [index, candidate] of cases.entries()) {
|
||||
expect(validateLocalDataRoot(candidate.path, boundaries)).toEqual({
|
||||
ok: false,
|
||||
reason: candidate.reason,
|
||||
});
|
||||
expect(() => initializeLocalDataRoot({
|
||||
boundaries,
|
||||
configFile: join(base, `invalid-config-${index}`, "instance.json"),
|
||||
dataRoot: candidate.path,
|
||||
})).toThrow(candidate.reason);
|
||||
}
|
||||
expect(() => resolvePathWithinRoot(join(base, "safe"), "../escaped/file.png")).toThrow("path_escape");
|
||||
const after = snapshot(base);
|
||||
expect(after).toEqual(before);
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeFileSync(join(evidenceDirectory, "fs-before.json"), `${JSON.stringify({ entries: before }, null, 2)}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the fixed layout and SQLite only during explicit initialization", () => {
|
||||
const { base, boundaries } = fixture();
|
||||
const dataRoot = join(base, "compliant-data");
|
||||
const configFile = join(base, "config", "instance.json");
|
||||
const initialized = initializeLocalDataRoot({ boundaries, configFile, dataRoot });
|
||||
|
||||
expect(initialized.status).toBe("ready");
|
||||
for (const relativePath of [
|
||||
"db/dada.sqlite3",
|
||||
"content/references",
|
||||
"content/generated",
|
||||
"content/exports",
|
||||
"managed-assets",
|
||||
"derived-assets",
|
||||
"staging",
|
||||
"logs/api",
|
||||
"logs/worker",
|
||||
"logs/supervisor",
|
||||
]) {
|
||||
expect(existsSync(join(dataRoot, relativePath))).toBe(true);
|
||||
}
|
||||
|
||||
const database = new Database(join(dataRoot, "db", "dada.sqlite3"), { readonly: true });
|
||||
expect(database.prepare("select schema_version from instance_metadata").get()).toEqual({ schema_version: 1 });
|
||||
database.close();
|
||||
expect(JSON.parse(readFileSync(configFile, "utf8"))).toMatchObject({ initialized: true, schema_version: 1 });
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeFileSync(join(evidenceDirectory, "config-result.json"), `${JSON.stringify({
|
||||
database: "db/dada.sqlite3",
|
||||
fixed_layout: true,
|
||||
path_exposed: false,
|
||||
status: initialized.status,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports data_missing without recreating a deleted initialized root or database", () => {
|
||||
const { base, boundaries } = fixture();
|
||||
const dataRoot = join(base, "initialized-data");
|
||||
const configFile = join(base, "config", "instance.json");
|
||||
initializeLocalDataRoot({ boundaries, configFile, dataRoot });
|
||||
rmSync(join(dataRoot, "db", "dada.sqlite3"));
|
||||
|
||||
expect(inspectInitializedLocalDataRoot({ boundaries, configFile })).toMatchObject({
|
||||
database_exists: false,
|
||||
root_exists: true,
|
||||
status: "data_missing",
|
||||
});
|
||||
expect(existsSync(join(dataRoot, "db", "dada.sqlite3"))).toBe(false);
|
||||
|
||||
rmSync(dataRoot, { recursive: true });
|
||||
expect(inspectInitializedLocalDataRoot({ boundaries, configFile })).toMatchObject({
|
||||
database_exists: false,
|
||||
root_exists: false,
|
||||
status: "data_missing",
|
||||
});
|
||||
expect(existsSync(dataRoot)).toBe(false);
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeFileSync(join(evidenceDirectory, "fs-after.json"), `${JSON.stringify({
|
||||
database_recreated: false,
|
||||
root_recreated: false,
|
||||
status: "data_missing",
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates a read-only manifest by hash without copying or writing its source root", () => {
|
||||
const { assetRoot, base } = fixture();
|
||||
const manifest = join(assetRoot, "catalog.json");
|
||||
writeFileSync(manifest, "{\"assets\":[]}\n");
|
||||
const expectedSha256 = createHash("sha256").update(readFileSync(manifest)).digest("hex");
|
||||
const before = snapshot(assetRoot);
|
||||
|
||||
expect(validateReadOnlyAssetRoot({
|
||||
dataRoot: join(base, "data"),
|
||||
expectedSha256,
|
||||
manifestRelativePath: "catalog.json",
|
||||
root: assetRoot,
|
||||
rootRef: "fixture_assets",
|
||||
})).toMatchObject({ ok: true, root_ref: "fixture_assets" });
|
||||
expect(snapshot(assetRoot)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP0-DATA-002-no-backup-migration", () => {
|
||||
it("does not recover deleted data or treat retained downloads as migration input", () => {
|
||||
const { base, boundaries } = fixture();
|
||||
const dataRoot = join(base, "instance-a");
|
||||
const configFile = join(base, "config-a", "instance.json");
|
||||
initializeLocalDataRoot({ boundaries, configFile, dataRoot });
|
||||
const businessMarker = join(dataRoot, "content", "generated", "private-record.json");
|
||||
mkdirSync(dirname(businessMarker), { recursive: true });
|
||||
writeFileSync(businessMarker, "private business data");
|
||||
const retainedDownload = join(base, "user-downloads", "finished.png");
|
||||
mkdirSync(dirname(retainedDownload));
|
||||
writeFileSync(retainedDownload, "downloaded image");
|
||||
|
||||
rmSync(dataRoot, { recursive: true });
|
||||
expect(inspectInitializedLocalDataRoot({ boundaries, configFile }).status).toBe("data_missing");
|
||||
expect(existsSync(dataRoot)).toBe(false);
|
||||
expect(readFileSync(retainedDownload, "utf8")).toBe("downloaded image");
|
||||
|
||||
const replacementRoot = join(base, "instance-b");
|
||||
initializeLocalDataRoot({
|
||||
boundaries,
|
||||
configFile: join(base, "config-b", "instance.json"),
|
||||
dataRoot: replacementRoot,
|
||||
});
|
||||
expect(existsSync(join(replacementRoot, "content", "generated", "private-record.json"))).toBe(false);
|
||||
expect(DATA_TRANSFER_POLICY).toEqual({
|
||||
allowed_downloads: ["original_generation", "jpg", "png"],
|
||||
application_backup: false,
|
||||
business_import: false,
|
||||
editable_project_archive: false,
|
||||
p0b_migration: false,
|
||||
recovery: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user