feat: complete TASK-WP0-04 local data boundary

This commit is contained in:
suyx
2026-07-27 18:45:50 +08:00
parent 878788b1e2
commit b5a6c6629e
11 changed files with 1178 additions and 2 deletions
+133
View File
@@ -0,0 +1,133 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import {
createPublicAssetResolver,
validateReadOnlyAssetRoot,
} from "../../apps/api/src/local-data-root.js";
const temporaryDirectories: string[] = [];
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 resource boundary", () => {
it("serves a manifest resource by stable ID without exposing the absolute root or object key", async () => {
const base = mkdtempSync(join(tmpdir(), "dada-wp0-04-api-"));
temporaryDirectories.push(base);
const assetRoot = join(base, "read-only-assets");
const relativePath = "images/source.png";
const bytes = Buffer.from("synthetic png fixture");
const assetId = randomUUID();
mkdirSync(join(assetRoot, "images"), { recursive: true });
writeFileSync(join(assetRoot, relativePath), bytes);
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
writeFileSync(join(assetRoot, "catalog.json"), manifest);
const validatedRoot = validateReadOnlyAssetRoot({
dataRoot: join(base, "data"),
expectedSha256: createHash("sha256").update(manifest).digest("hex"),
manifestRelativePath: "catalog.json",
root: assetRoot,
rootRef: "fixture_assets",
});
if (!validatedRoot.ok) throw new Error(validatedRoot.reason);
const publicAssets = createPublicAssetResolver({
entries: [{
assetId,
mimeType: "image/png",
relativePath,
resourceVersion: "fixture-v1",
rootRef: "fixture_assets",
sha256: createHash("sha256").update(bytes).digest("hex"),
}],
roots: [validatedRoot],
});
const app = await createApp({ browserGate: false, publicAssets });
try {
const resourceUrl = `/api/v1/assets/public/fixture-v1/${assetId}`;
const response = await app.inject({
headers: { host: "127.0.0.1:43121" },
method: "GET",
url: resourceUrl,
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("image/png");
expect(response.rawPayload).toEqual(bytes);
const wrongVersion = await app.inject({
headers: { host: "127.0.0.1:43121" },
method: "GET",
url: `/api/v1/assets/public/other-version/${assetId}`,
});
expect(wrongVersion.statusCode).toBe(404);
const browserVisible = JSON.stringify({
headers: response.headers,
resource_id: assetId,
url: resourceUrl,
});
expect(browserVisible).not.toContain(assetRoot);
expect(browserVisible).not.toContain(relativePath);
expect(browserVisible).not.toMatch(/[A-Za-z]:\\/);
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DATA_ROOT;
if (evidenceDirectory) {
mkdirSync(evidenceDirectory, { recursive: true });
writeFileSync(join(evidenceDirectory, "response.json"), `${JSON.stringify({
content_type: response.headers["content-type"],
path_exposed: false,
resource_id: assetId,
status: "passed",
}, null, 2)}\n`);
}
} finally {
await app.close();
}
});
it("does not resolve unknown IDs or path-like route parameters", async () => {
const app = await createApp({ browserGate: false, publicAssets: createPublicAssetResolver({ entries: [], roots: [] }) });
try {
for (const target of [randomUUID(), "..%2F..%2Fsecret", "C:%5CUsers%5Csecret"]) {
const response = await app.inject({
headers: { host: "127.0.0.1:43121" },
method: "GET",
url: `/api/v1/assets/public/fixture-v1/${target}`,
});
expect(response.statusCode).toBe(404);
expect(response.body).not.toMatch(/[A-Za-z]:\\/);
}
} finally {
await app.close();
}
});
});
describe("TDD-WP0-DATA-002-no-backup-migration route inventory", () => {
it("contains no product backup, archive, business import, migration or recovery route", async () => {
const app = await createApp({ browserGate: false });
try {
const routes = app.printRoutes();
for (const forbidden of ["backup", "archive", "project-package", "business-import", "migration", "restore", "recovery"]) {
expect(routes.toLowerCase()).not.toContain(forbidden);
}
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NO_TRANSFER;
if (evidenceDirectory) {
mkdirSync(evidenceDirectory, { recursive: true });
writeFileSync(join(evidenceDirectory, "route-inventory.json"), `${JSON.stringify({
forbidden_routes: [],
status: "passed",
}, null, 2)}\n`);
}
} finally {
await app.close();
}
});
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dada 本机数据</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/tests/e2e/fixtures/local-data-boundary.tsx"></script>
</body>
</html>
@@ -0,0 +1,6 @@
import { mountLocalDataBoundary } from "../../../apps/web/src/local-data-boundary.js";
const root = document.querySelector("#root");
if (!root) throw new Error("Fixture root is missing.");
mountLocalDataBoundary(root);
+62
View File
@@ -0,0 +1,62 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
let vite: ViteDevServer;
let webUrl: string;
test.beforeAll(async () => {
vite = await createServer({
configFile: false,
root: process.cwd(),
server: { host: "127.0.0.1", port: 0 },
});
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
await vite.close();
});
test("TDD-WP0-DATA-002 keeps the fixed local-data warning visible without transfer entry points", async ({ page }) => {
await page.goto(`${webUrl}/tests/e2e/fixtures/local-data-boundary.html`);
const fixedCopy = "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。";
await expect(page.getByRole("heading", { name: "本机数据" })).toBeVisible();
await expect(page.getByText(fixedCopy, { exact: true })).toBeVisible();
await expect(page.getByText("当前 Windows 用户的 Dada 本机数据目录", { exact: true })).toBeVisible();
await expect(page.getByText(/不提供 Dada 应用层加密或云备份/)).toBeVisible();
await expect(page.getByText(/机器损坏、重装或删除本机数据目录后不可恢复/)).toBeVisible();
await expect(page.getByText(/主动下载需要保留的原始生成图或 JPG\/PNG 成品/)).toBeVisible();
await expect(page.getByRole("button")).toHaveCount(0);
await expect(page.getByRole("link")).toHaveCount(0);
const text = await page.locator("body").innerText();
expect(text).not.toMatch(/[A-Za-z]:\\/);
await page.reload();
await expect(page.getByText(fixedCopy, { exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NO_TRANSFER;
if (evidenceDirectory) {
mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true });
await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "fixed-copy.png") });
writeFileSync(resolve(evidenceDirectory, "package-scan.json"), `${JSON.stringify({
absolute_path_exposed: false,
automatic_backup_entry: false,
business_import_entry: false,
editable_project_archive_entry: false,
migration_entry: false,
recovery_entry: false,
status: "passed",
}, null, 2)}\n`);
}
await page.setViewportSize({ height: 844, width: 390 });
await expect(page.getByText(fixedCopy, { exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
@@ -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,
});
});
});