136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
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 {
|
|
await app.ready();
|
|
const routes = Object.keys(app.swagger().paths ?? {}).map((route) => route.toLowerCase());
|
|
for (const forbidden of ["backup", "archive", "project-package", "business-import", "migration", "recovery"]) {
|
|
expect(routes.some((route) => route.includes(forbidden))).toBe(false);
|
|
}
|
|
expect(routes.filter((route) => route.includes("restore"))).toEqual(["/api/v1/projects/{projectid}/restore"]);
|
|
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();
|
|
}
|
|
});
|
|
});
|