feat: isolate asset release access classes (TASK-WP5-04)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, 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 { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
import { createAssetReleaseManifest } from "../../packages/asset-release-manifest/src/index.js";
|
||||
|
||||
const now = Date.parse("2026-08-03T10:00:00.000Z");
|
||||
const releaseVersion = "asset-20260803.1";
|
||||
const publicId = "7f0c9530-a7d9-4bf1-8c65-0e9298dd04ac";
|
||||
const previewId = "ab18fd72-60e1-44e3-a9a0-3dfccb12e17c";
|
||||
const ungrantedPreviewId = "d8fe890d-6df4-46a9-a578-96c1f8361ac0";
|
||||
const privateId = "e3792605-5252-4d3b-a101-827408ab3515";
|
||||
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const roots: string[] = [];
|
||||
const registrations: RegistrationService[] = [];
|
||||
|
||||
function addUser(registration: RegistrationService, role: "super_admin" | "user") {
|
||||
const userId = randomUUID();
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, ?, ?, 'active', ?, ?, ?)`).run(
|
||||
userId,
|
||||
`${role}-${userId}@example.invalid`,
|
||||
role,
|
||||
role === "user" ? 1 : 0,
|
||||
randomUUID(),
|
||||
now,
|
||||
);
|
||||
if (role === "user") {
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Asset User', '@asset_user')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
||||
} else {
|
||||
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
|
||||
}
|
||||
return { session: registration.issueAuthenticatedSession(userId, role === "user" ? "user" : "admin"), userId };
|
||||
}
|
||||
|
||||
function evidence(name: string, value: unknown) {
|
||||
const directory = process.env.DADA_EVIDENCE_DIR_WP5_RES;
|
||||
if (!directory) return;
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp5-04-api-"));
|
||||
roots.push(root);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
|
||||
});
|
||||
registrations.push(registration);
|
||||
const owner = addUser(registration, "user");
|
||||
const intruder = addUser(registration, "user");
|
||||
const admin = addUser(registration, "super_admin");
|
||||
const assetReleases = createAssetReleaseManifest({
|
||||
items: [
|
||||
{ access_class: "public_release_asset", cache_kind: "thumbnail", content: Buffer.from("public"), mime_type: "image/png", relative_path: "public/FLOWER001.png", resource_id: publicId, root_ref: "canonical-assets" },
|
||||
{ access_class: "internal_preview_asset", content: Buffer.from("preview"), mime_type: "image/webp", relative_path: "preview/FLOWER009.webp", resource_id: previewId, root_ref: "canonical-assets" },
|
||||
{ access_class: "internal_preview_asset", content: Buffer.from("ungranted-preview"), mime_type: "image/webp", relative_path: "preview/FLOWER010.webp", resource_id: ungrantedPreviewId, root_ref: "canonical-assets" },
|
||||
{ access_class: "private_user_asset", content: Buffer.from("private"), mime_type: "image/png", owner_id: owner.userId, relative_path: "private/generated.png", resource_id: privateId, root_ref: "managed-assets" },
|
||||
],
|
||||
release_version: releaseVersion,
|
||||
});
|
||||
let previewGranted = true;
|
||||
let previewChecks = 0;
|
||||
const appPromise = createApp({
|
||||
assetReleases,
|
||||
browserGate: false,
|
||||
networkBoundary: { allowTestPort: true },
|
||||
previewAssetAuthorizer: ({ resourceId, userId }) => {
|
||||
previewChecks += 1;
|
||||
return previewGranted && userId === owner.userId && resourceId === previewId;
|
||||
},
|
||||
privateAssetAdminAuthorizer: ({ adminUserId, ownerId }) => adminUserId === admin.userId && ownerId === owner.userId,
|
||||
registration,
|
||||
});
|
||||
return { admin, appPromise, intruder, owner, previewChecks: () => previewChecks, revokePreview: () => { previewGranted = false; } };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const registration of registrations.splice(0)) registration.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TDD-WP5-RES-001 three access classes", () => {
|
||||
it("separates route projections, per-request authorization, and cache headers", async () => {
|
||||
const test = harness();
|
||||
const app = await test.appPromise;
|
||||
const ownerCookie = `dada_session=${test.owner.session.sessionToken}`;
|
||||
const intruderCookie = `dada_session=${test.intruder.session.sessionToken}`;
|
||||
const adminCookie = `dada_admin_session=${test.admin.session.sessionToken}`;
|
||||
|
||||
const publicManifest = await app.inject({ headers, method: "GET", url: `/api/v1/assets/public/${releaseVersion}/manifest` });
|
||||
const publicAsset = await app.inject({ headers, method: "GET", url: `/api/v1/assets/public/${releaseVersion}/${publicId}` });
|
||||
const previewManifest = await app.inject({ headers: { ...headers, cookie: ownerCookie }, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/manifest` });
|
||||
const previewAsset = await app.inject({ headers: { ...headers, cookie: ownerCookie }, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/${previewId}` });
|
||||
const privateManifest = await app.inject({ headers: { ...headers, cookie: ownerCookie }, method: "GET", url: `/api/v1/private-assets/${releaseVersion}/manifest` });
|
||||
const privateAsset = await app.inject({ headers: { ...headers, cookie: ownerCookie }, method: "GET", url: `/api/v1/private-assets/${releaseVersion}/${privateId}` });
|
||||
const deniedPrivate = await app.inject({ headers: { ...headers, cookie: intruderCookie }, method: "GET", url: `/api/v1/private-assets/${releaseVersion}/${privateId}` });
|
||||
const controlledAdmin = await app.inject({ headers: { ...headers, cookie: adminCookie }, method: "GET", url: `/api/v1/private-assets/${releaseVersion}/${privateId}` });
|
||||
|
||||
expect(publicManifest.statusCode).toBe(200);
|
||||
expect(publicManifest.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
||||
expect(publicManifest.json().items).toEqual([expect.objectContaining({ access_class: "public_release_asset", resource_id: publicId })]);
|
||||
expect(JSON.stringify(publicManifest.json())).not.toMatch(/preview|private|relative_path|root_ref|[A-Z]:\\\\/i);
|
||||
expect(publicAsset.statusCode).toBe(200);
|
||||
expect(publicAsset.rawPayload).toEqual(Buffer.from("public"));
|
||||
expect(publicAsset.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
||||
|
||||
expect(previewManifest.statusCode).toBe(200);
|
||||
expect(previewManifest.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(previewManifest.json().items).toEqual([expect.objectContaining({ resource_id: previewId })]);
|
||||
expect(JSON.stringify(previewManifest.json())).not.toContain(ungrantedPreviewId);
|
||||
expect(previewAsset.statusCode).toBe(200);
|
||||
expect(previewAsset.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(privateManifest.json().items).toEqual([expect.objectContaining({ access_class: "private_user_asset", resource_id: privateId })]);
|
||||
expect(privateAsset.rawPayload).toEqual(Buffer.from("private"));
|
||||
expect(privateAsset.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(deniedPrivate.statusCode).toBe(404);
|
||||
expect(controlledAdmin.statusCode).toBe(200);
|
||||
|
||||
const publicGuess = await app.inject({ headers, method: "GET", url: `/api/v1/assets/public/${releaseVersion}/${privateId}` });
|
||||
expect(publicGuess.statusCode).toBe(404);
|
||||
test.revokePreview();
|
||||
const revoked = await app.inject({ headers: { ...headers, cookie: ownerCookie }, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/${previewId}` });
|
||||
expect(revoked.statusCode).toBe(404);
|
||||
expect(test.previewChecks()).toBe(4);
|
||||
|
||||
evidence("response.json", {
|
||||
controlled_admin_status: controlledAdmin.statusCode,
|
||||
private_intruder_status: deniedPrivate.statusCode,
|
||||
public_guess_status: publicGuess.statusCode,
|
||||
revoked_preview_status: revoked.statusCode,
|
||||
});
|
||||
evidence("headers.json", {
|
||||
private: privateAsset.headers["cache-control"],
|
||||
preview: previewAsset.headers["cache-control"],
|
||||
public: publicAsset.headers["cache-control"],
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
const releaseVersion = "asset-20260803.1";
|
||||
const publicId = "7f0c9530-a7d9-4bf1-8c65-0e9298dd04ac";
|
||||
const previewId = "ab18fd72-60e1-44e3-a9a0-3dfccb12e17c";
|
||||
const privateId = "e3792605-5252-4d3b-a101-827408ab3515";
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
const requestCounts = { preview: 0, private: 0, public: 0 };
|
||||
|
||||
function writeEvidence(directory: string | undefined, name: string, value: unknown) {
|
||||
if (!directory) return;
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({
|
||||
configFile: false,
|
||||
plugins: [{
|
||||
name: "wp5-04-three-resource-classes",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const routes = [
|
||||
{ access: "public", body: "public-content", id: publicId, prefix: "/api/v1/assets/public/" },
|
||||
{ access: "preview", body: "preview-content", id: previewId, prefix: "/api/v1/assets/preview/" },
|
||||
{ access: "private", body: "private-content", id: privateId, prefix: "/api/v1/private-assets/" },
|
||||
] as const;
|
||||
const route = routes.find((item) => request.url === `${item.prefix}${releaseVersion}/${item.id}`);
|
||||
if (!route) return next();
|
||||
requestCounts[route.access] += 1;
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Cache-Control", route.access === "public" ? "public, max-age=31536000, immutable" : "private, no-store");
|
||||
response.setHeader("Content-Type", "application/octet-stream");
|
||||
response.end(route.body);
|
||||
});
|
||||
},
|
||||
}],
|
||||
publicDir: resolve("apps/web/public"),
|
||||
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 () => vite.close());
|
||||
|
||||
test("TDD-WP5-04 enumerates no preview or private client state", async ({ context, page }) => {
|
||||
requestCounts.preview = 0;
|
||||
requestCounts.private = 0;
|
||||
requestCounts.public = 0;
|
||||
await page.goto(`${webUrl}/tests/e2e/fixtures/public-asset-cache.html`);
|
||||
await expect(page.locator("#status")).toHaveText("ready");
|
||||
await page.reload();
|
||||
await expect(page.locator("#status")).toHaveText("ready");
|
||||
|
||||
const online = await page.evaluate(async ({ privateId, previewId, publicId, releaseVersion }) => {
|
||||
const cache = window.dadaCacheProbe.cache;
|
||||
await cache.clear();
|
||||
const cached = await cache.cache({
|
||||
access_class: "public_release_asset",
|
||||
cache_kind: "thumbnail",
|
||||
release_version: releaseVersion,
|
||||
resource_id: publicId,
|
||||
});
|
||||
const rejected = await Promise.all([
|
||||
cache.cache({ access_class: "internal_preview_asset", cache_kind: "thumbnail", release_version: releaseVersion, resource_id: previewId }),
|
||||
cache.cache({ access_class: "private_user_asset", cache_kind: "thumbnail", release_version: releaseVersion, resource_id: privateId }),
|
||||
]);
|
||||
const preview = await fetch(`/api/v1/assets/preview/${releaseVersion}/${previewId}`);
|
||||
const privateAsset = await fetch(`/api/v1/private-assets/${releaseVersion}/${privateId}`);
|
||||
const inspection = await cache.inspect();
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
const databases = await indexedDB.databases();
|
||||
return {
|
||||
cached,
|
||||
inspection,
|
||||
private_bytes: (await privateAsset.arrayBuffer()).byteLength,
|
||||
private_cache_control: privateAsset.headers.get("cache-control"),
|
||||
preview_bytes: (await preview.arrayBuffer()).byteLength,
|
||||
preview_cache_control: preview.headers.get("cache-control"),
|
||||
rejected,
|
||||
service_workers: registrations.map((registration) => ({
|
||||
active: registration.active?.state,
|
||||
scope: registration.scope,
|
||||
script_url: registration.active?.scriptURL,
|
||||
})),
|
||||
indexed_db_names: databases.map((database) => database.name).filter(Boolean).sort(),
|
||||
local_storage_keys: Object.keys(localStorage),
|
||||
session_storage_keys: Object.keys(sessionStorage),
|
||||
};
|
||||
}, { privateId, previewId, publicId, releaseVersion });
|
||||
|
||||
await context.setOffline(true);
|
||||
const offline = await page.evaluate(async ({ privateId, previewId, publicId, releaseVersion }) => {
|
||||
const read = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return { body: await response.text(), status: response.status };
|
||||
} catch {
|
||||
return { body: null, status: "network_error" };
|
||||
}
|
||||
};
|
||||
return {
|
||||
preview: await read(`/api/v1/assets/preview/${releaseVersion}/${previewId}`),
|
||||
private: await read(`/api/v1/private-assets/${releaseVersion}/${privateId}`),
|
||||
public: await read(`/api/v1/assets/public/${releaseVersion}/${publicId}`),
|
||||
};
|
||||
}, { privateId, previewId, publicId, releaseVersion });
|
||||
await context.setOffline(false);
|
||||
|
||||
expect(online.cached.status).toBe("cached");
|
||||
expect(online.rejected).toEqual([
|
||||
{ status: "rejected_not_allowlisted" },
|
||||
{ status: "rejected_not_allowlisted" },
|
||||
]);
|
||||
expect(online.preview_cache_control).toBe("private, no-store");
|
||||
expect(online.private_cache_control).toBe("private, no-store");
|
||||
expect(online.inspection.cache_keys).toHaveLength(1);
|
||||
expect(online.inspection.cache_names).toEqual(["dada-public-assets-v1"]);
|
||||
expect(online.inspection.entries).toEqual([expect.objectContaining({ resource_id: publicId })]);
|
||||
expect(JSON.stringify(online.inspection)).not.toContain(previewId);
|
||||
expect(JSON.stringify(online.inspection)).not.toContain(privateId);
|
||||
expect(online.indexed_db_names).toEqual(["dada-public-asset-cache-v1"]);
|
||||
expect(online.local_storage_keys).toEqual([]);
|
||||
expect(online.session_storage_keys).toEqual([]);
|
||||
expect(online.service_workers).toHaveLength(1);
|
||||
expect(offline.public).toEqual({ body: "public-content", status: 200 });
|
||||
expect(offline.preview.status).toBe("network_error");
|
||||
expect(offline.private.status).toBe("network_error");
|
||||
expect(requestCounts).toEqual({ preview: 1, private: 1, public: 1 });
|
||||
|
||||
const cacheEnumeration = {
|
||||
business_database_calls: 0,
|
||||
offline,
|
||||
online,
|
||||
origin_request_counts: { ...requestCounts },
|
||||
};
|
||||
writeEvidence(process.env.DADA_EVIDENCE_DIR_WP5_CACHE, "cache-enumeration.json", cacheEnumeration);
|
||||
writeEvidence(process.env.DADA_EVIDENCE_DIR_WP5_CACHE, "service-worker.json", { registrations: online.service_workers });
|
||||
writeEvidence(process.env.DADA_EVIDENCE_DIR_WP5_RES, "cache-enumeration.json", cacheEnumeration);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAssetReleaseManifest } from "../../packages/asset-release-manifest/src/index.js";
|
||||
|
||||
const releaseVersion = "asset-20260803.1";
|
||||
const publicId = "7f0c9530-a7d9-4bf1-8c65-0e9298dd04ac";
|
||||
const previewId = "ab18fd72-60e1-44e3-a9a0-3dfccb12e17c";
|
||||
const privateId = "e3792605-5252-4d3b-a101-827408ab3515";
|
||||
|
||||
function fixture() {
|
||||
return createAssetReleaseManifest({
|
||||
items: [
|
||||
{
|
||||
access_class: "public_release_asset",
|
||||
cache_kind: "thumbnail",
|
||||
content: Buffer.from("public-content"),
|
||||
mime_type: "image/png",
|
||||
relative_path: "public/thumbnails/FLOWER001.png",
|
||||
resource_id: publicId,
|
||||
root_ref: "canonical-assets",
|
||||
},
|
||||
{
|
||||
access_class: "internal_preview_asset",
|
||||
content: Buffer.from("preview-content"),
|
||||
mime_type: "image/webp",
|
||||
relative_path: "preview/batch-7/FLOWER009.webp",
|
||||
resource_id: previewId,
|
||||
root_ref: "canonical-assets",
|
||||
},
|
||||
{
|
||||
access_class: "private_user_asset",
|
||||
content: Buffer.from("private-content"),
|
||||
mime_type: "image/png",
|
||||
owner_id: "owner-1",
|
||||
relative_path: "private/owner-1/generated.png",
|
||||
resource_id: privateId,
|
||||
root_ref: "managed-assets",
|
||||
},
|
||||
],
|
||||
release_version: releaseVersion,
|
||||
});
|
||||
}
|
||||
|
||||
describe("TASK-WP5-04 immutable asset release manifest", () => {
|
||||
it("projects only the selected access class and never exposes source paths", () => {
|
||||
const manifest = fixture();
|
||||
const projected = manifest.project("public_release_asset", releaseVersion);
|
||||
|
||||
expect(Object.isFrozen(projected)).toBe(true);
|
||||
expect(projected?.manifest_sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(projected?.items).toEqual([expect.objectContaining({
|
||||
access_class: "public_release_asset",
|
||||
resource_id: publicId,
|
||||
sha256: createHash("sha256").update("public-content").digest("hex"),
|
||||
url: `/api/v1/assets/public/${releaseVersion}/${publicId}`,
|
||||
})]);
|
||||
const serialized = JSON.stringify(projected);
|
||||
expect(serialized).not.toContain(previewId);
|
||||
expect(serialized).not.toContain(privateId);
|
||||
expect(serialized).not.toContain("relative_path");
|
||||
expect(serialized).not.toContain("root_ref");
|
||||
expect(serialized).not.toMatch(/[A-Z]:\\\\/i);
|
||||
});
|
||||
|
||||
it("keeps resource identifiers opaque, verifies file hashes, and rejects unsafe manifests", () => {
|
||||
const manifest = fixture();
|
||||
expect(manifest.read("public_release_asset", releaseVersion, publicId)?.bytes).toEqual(Buffer.from("public-content"));
|
||||
expect(manifest.read("public_release_asset", releaseVersion, previewId)).toBeUndefined();
|
||||
expect(manifest.project("private_user_asset", releaseVersion, { ownerId: "owner-1" })?.items)
|
||||
.toEqual([expect.objectContaining({ resource_id: privateId })]);
|
||||
expect(manifest.project("private_user_asset", releaseVersion, { ownerId: "owner-2" })?.items).toEqual([]);
|
||||
|
||||
expect(() => createAssetReleaseManifest({
|
||||
items: [{
|
||||
access_class: "public_release_asset",
|
||||
cache_kind: "thumbnail",
|
||||
content: Buffer.from("tampered"),
|
||||
mime_type: "image/png",
|
||||
relative_path: "public/tampered.png",
|
||||
resource_id: publicId,
|
||||
root_ref: "canonical-assets",
|
||||
sha256: "0".repeat(64),
|
||||
}],
|
||||
release_version: releaseVersion,
|
||||
})).toThrow(/sha-?256/i);
|
||||
expect(() => createAssetReleaseManifest({
|
||||
items: [{
|
||||
access_class: "public_release_asset",
|
||||
cache_kind: "thumbnail",
|
||||
content: Buffer.from("unsafe"),
|
||||
mime_type: "image/png",
|
||||
relative_path: "/outside/private.png",
|
||||
resource_id: publicId,
|
||||
root_ref: "canonical-assets",
|
||||
}],
|
||||
release_version: releaseVersion,
|
||||
})).toThrow(/relative path/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user