Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55646ba1b4 | ||
|
|
8abf1397a6 | ||
|
|
e0e101ef28 | ||
|
|
33f87f8db3 | ||
|
|
5e2d4e7aaf |
@@ -9,7 +9,6 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dada/asset-release-manifest": "workspace:*",
|
|
||||||
"@dada/shared-contracts": "workspace:*",
|
"@dada/shared-contracts": "workspace:*",
|
||||||
"@fastify/multipart": "10.1.0",
|
"@fastify/multipart": "10.1.0",
|
||||||
"@fastify/swagger": "9.8.1",
|
"@fastify/swagger": "9.8.1",
|
||||||
|
|||||||
+1
-133
@@ -134,7 +134,6 @@ import {
|
|||||||
type RegistrationCompleteRequest,
|
type RegistrationCompleteRequest,
|
||||||
type RegistrationSendRequest,
|
type RegistrationSendRequest,
|
||||||
} from "@dada/shared-contracts";
|
} from "@dada/shared-contracts";
|
||||||
import type { AssetReleaseReader } from "@dada/asset-release-manifest";
|
|
||||||
import swagger from "@fastify/swagger";
|
import swagger from "@fastify/swagger";
|
||||||
import multipart from "@fastify/multipart";
|
import multipart from "@fastify/multipart";
|
||||||
import Fastify, { type FastifyReply } from "fastify";
|
import Fastify, { type FastifyReply } from "fastify";
|
||||||
@@ -193,7 +192,6 @@ const defaultBootstrap: BootstrapResponse = {
|
|||||||
|
|
||||||
export interface CreateAppOptions {
|
export interface CreateAppOptions {
|
||||||
amap?: AmapAdapter;
|
amap?: AmapAdapter;
|
||||||
assetReleases?: AssetReleaseReader;
|
|
||||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||||
browserGate?: boolean;
|
browserGate?: boolean;
|
||||||
browserSupportRelease?: BrowserSupportRelease;
|
browserSupportRelease?: BrowserSupportRelease;
|
||||||
@@ -207,17 +205,6 @@ export interface CreateAppOptions {
|
|||||||
publicAssets?: PublicAssetResolver;
|
publicAssets?: PublicAssetResolver;
|
||||||
recentAssets?: RecentAssetService;
|
recentAssets?: RecentAssetService;
|
||||||
projects?: ProjectService;
|
projects?: ProjectService;
|
||||||
previewAssetAuthorizer?: (input: {
|
|
||||||
releaseVersion: string;
|
|
||||||
resourceId: string;
|
|
||||||
userId: string;
|
|
||||||
}) => boolean | Promise<boolean>;
|
|
||||||
privateAssetAdminAuthorizer?: (input: {
|
|
||||||
adminUserId: string;
|
|
||||||
ownerId: string;
|
|
||||||
releaseVersion: string;
|
|
||||||
resourceId: string;
|
|
||||||
}) => boolean | Promise<boolean>;
|
|
||||||
registration?: RegistrationService;
|
registration?: RegistrationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,27 +816,13 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get(
|
|
||||||
"/api/v1/assets/public/:resourceVersion/manifest",
|
|
||||||
{ schema: { hide: true } },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { resourceVersion } = request.params as { resourceVersion: string };
|
|
||||||
const manifest = options.assetReleases?.project("public_release_asset", resourceVersion);
|
|
||||||
if (!manifest) return reply.code(404).send();
|
|
||||||
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
||||||
reply.header("ETag", `"sha256-${manifest.manifest_sha256}"`);
|
|
||||||
return manifest;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/assets/public/:resourceVersion/:assetId",
|
"/api/v1/assets/public/:resourceVersion/:assetId",
|
||||||
{ schema: { hide: true } },
|
{ schema: { hide: true } },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { assetId, resourceVersion } = request.params as { assetId?: string; resourceVersion?: string };
|
const { assetId, resourceVersion } = request.params as { assetId?: string; resourceVersion?: string };
|
||||||
const resource = assetId && resourceVersion
|
const resource = assetId && resourceVersion
|
||||||
? options.assetReleases?.read("public_release_asset", resourceVersion, assetId)
|
? options.publicAssets?.read(resourceVersion, assetId)
|
||||||
?? options.publicAssets?.read(resourceVersion, assetId)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!resource) return reply.code(404).send();
|
if (!resource) return reply.code(404).send();
|
||||||
reply.type(resource.mimeType);
|
reply.type(resource.mimeType);
|
||||||
@@ -860,111 +833,6 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get(
|
|
||||||
"/api/v1/assets/preview/:resourceVersion/manifest",
|
|
||||||
{ schema: { hide: true } },
|
|
||||||
async (request, reply) => {
|
|
||||||
if (!options.registration) {
|
|
||||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
|
||||||
}
|
|
||||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
|
||||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
|
||||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
||||||
const { resourceVersion } = request.params as { resourceVersion: string };
|
|
||||||
const available = options.assetReleases?.project("internal_preview_asset", resourceVersion);
|
|
||||||
if (!available || !options.previewAssetAuthorizer) return reply.code(404).send();
|
|
||||||
const authorizedIds: string[] = [];
|
|
||||||
for (const item of available.items) {
|
|
||||||
if (await options.previewAssetAuthorizer({
|
|
||||||
releaseVersion: resourceVersion,
|
|
||||||
resourceId: item.resource_id,
|
|
||||||
userId: session.userId,
|
|
||||||
})) authorizedIds.push(item.resource_id);
|
|
||||||
}
|
|
||||||
if (authorizedIds.length === 0) return reply.code(404).send();
|
|
||||||
const manifest = options.assetReleases?.project("internal_preview_asset", resourceVersion, { resourceIds: authorizedIds });
|
|
||||||
if (!manifest) return reply.code(404).send();
|
|
||||||
reply.header("Cache-Control", "private, no-store");
|
|
||||||
reply.header("Vary", "Cookie");
|
|
||||||
return manifest;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get(
|
|
||||||
"/api/v1/assets/preview/:resourceVersion/:assetId",
|
|
||||||
{ schema: { hide: true } },
|
|
||||||
async (request, reply) => {
|
|
||||||
if (!options.registration) {
|
|
||||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
|
||||||
}
|
|
||||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
|
||||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
|
||||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
||||||
const { assetId, resourceVersion } = request.params as { assetId: string; resourceVersion: string };
|
|
||||||
const authorized = await options.previewAssetAuthorizer?.({ resourceId: assetId, releaseVersion: resourceVersion, userId: session.userId });
|
|
||||||
const resource = authorized ? options.assetReleases?.read("internal_preview_asset", resourceVersion, assetId) : undefined;
|
|
||||||
if (!resource) return reply.code(404).send();
|
|
||||||
reply.type(resource.mimeType);
|
|
||||||
reply.header("Cache-Control", "private, no-store");
|
|
||||||
reply.header("Content-Disposition", "inline");
|
|
||||||
reply.header("Vary", "Cookie");
|
|
||||||
return resource.bytes;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get(
|
|
||||||
"/api/v1/private-assets/:resourceVersion/manifest",
|
|
||||||
{ schema: { hide: true } },
|
|
||||||
async (request, reply) => {
|
|
||||||
if (!options.registration) {
|
|
||||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
|
||||||
}
|
|
||||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
|
||||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
|
||||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
||||||
const { resourceVersion } = request.params as { resourceVersion: string };
|
|
||||||
const manifest = options.assetReleases?.project("private_user_asset", resourceVersion, { ownerId: session.userId });
|
|
||||||
if (!manifest) return reply.code(404).send();
|
|
||||||
reply.header("Cache-Control", "private, no-store");
|
|
||||||
reply.header("Vary", "Cookie");
|
|
||||||
return manifest;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get(
|
|
||||||
"/api/v1/private-assets/:resourceVersion/:assetId",
|
|
||||||
{ schema: { hide: true } },
|
|
||||||
async (request, reply) => {
|
|
||||||
if (!options.registration) {
|
|
||||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
|
||||||
}
|
|
||||||
const userToken = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
|
||||||
const userSession = userToken ? options.registration.readUserSession(userToken) : undefined;
|
|
||||||
const adminToken = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
|
||||||
const adminSession = adminToken ? options.registration.readAdminSession(adminToken) : undefined;
|
|
||||||
if (!userSession && !adminSession) {
|
|
||||||
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
||||||
}
|
|
||||||
const { assetId, resourceVersion } = request.params as { assetId: string; resourceVersion: string };
|
|
||||||
const resource = options.assetReleases?.read("private_user_asset", resourceVersion, assetId);
|
|
||||||
if (!resource?.ownerId) return reply.code(404).send();
|
|
||||||
const controlledAdmin = adminSession
|
|
||||||
? await options.privateAssetAdminAuthorizer?.({
|
|
||||||
adminUserId: adminSession.user_id,
|
|
||||||
ownerId: resource.ownerId,
|
|
||||||
releaseVersion: resource.releaseVersion,
|
|
||||||
resourceId: resource.resourceId,
|
|
||||||
})
|
|
||||||
: false;
|
|
||||||
if (userSession?.userId !== resource.ownerId && !controlledAdmin) return reply.code(404).send();
|
|
||||||
reply.type(resource.mimeType);
|
|
||||||
reply.header("Cache-Control", "private, no-store");
|
|
||||||
reply.header("Content-Disposition", "inline");
|
|
||||||
reply.header("Vary", "Cookie");
|
|
||||||
return resource.bytes;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/assets/recent",
|
"/api/v1/assets/recent",
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts --config playwright.config.ts",
|
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts --config playwright.config.ts",
|
||||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
@@ -93,8 +93,8 @@
|
|||||||
"preview:wp5-02": "node scripts/run-wp5-02-manual-preview.mjs",
|
"preview:wp5-02": "node scripts/run-wp5-02-manual-preview.mjs",
|
||||||
"test:wp5-03": "node scripts/run-wp5-03-validation.mjs",
|
"test:wp5-03": "node scripts/run-wp5-03-validation.mjs",
|
||||||
"test:wp5-03:red": "node scripts/run-wp5-03-validation.mjs --phase red",
|
"test:wp5-03:red": "node scripts/run-wp5-03-validation.mjs --phase red",
|
||||||
"test:wp5-04": "node scripts/run-wp5-04-validation.mjs",
|
"test:wp7-05": "node scripts/run-wp7-05-validation.mjs",
|
||||||
"test:wp5-04:red": "node scripts/run-wp5-04-validation.mjs --phase red"
|
"test:wp7-05:unit": "node --test tests/package/wp7-05-ui-gate.test.mjs tests/package/wp7-05-coverage.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@dada/asset-release-manifest",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -p tsconfig.json",
|
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "24.13.3",
|
|
||||||
"typescript": "7.0.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { posix, win32 } from "node:path";
|
|
||||||
|
|
||||||
export const ASSET_ACCESS_CLASSES = [
|
|
||||||
"public_release_asset",
|
|
||||||
"internal_preview_asset",
|
|
||||||
"private_user_asset",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type AssetAccessClass = typeof ASSET_ACCESS_CLASSES[number];
|
|
||||||
export type PublicAssetCacheKind = "font" | "template_conversion" | "thumbnail";
|
|
||||||
|
|
||||||
export interface AssetReleaseItemInput {
|
|
||||||
access_class: AssetAccessClass;
|
|
||||||
cache_kind?: PublicAssetCacheKind;
|
|
||||||
content: Uint8Array;
|
|
||||||
mime_type: string;
|
|
||||||
owner_id?: string;
|
|
||||||
relative_path: string;
|
|
||||||
resource_id: string;
|
|
||||||
root_ref: string;
|
|
||||||
sha256?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssetReleaseManifestInput {
|
|
||||||
items: readonly AssetReleaseItemInput[];
|
|
||||||
release_version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssetReleaseManifestItem {
|
|
||||||
access_class: AssetAccessClass;
|
|
||||||
byte_size: number;
|
|
||||||
cache_kind?: PublicAssetCacheKind;
|
|
||||||
mime_type: string;
|
|
||||||
release_version: string;
|
|
||||||
resource_id: string;
|
|
||||||
sha256: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssetReleaseManifestProjection {
|
|
||||||
items: readonly AssetReleaseManifestItem[];
|
|
||||||
manifest_sha256: string;
|
|
||||||
release_version: string;
|
|
||||||
schema_version: "AssetReleaseManifest/v1";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssetReleasePayload {
|
|
||||||
accessClass: AssetAccessClass;
|
|
||||||
bytes: Buffer;
|
|
||||||
mimeType: string;
|
|
||||||
ownerId?: string;
|
|
||||||
releaseVersion: string;
|
|
||||||
resourceId: string;
|
|
||||||
sha256: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssetReleaseReader {
|
|
||||||
project(
|
|
||||||
accessClass: AssetAccessClass,
|
|
||||||
releaseVersion: string,
|
|
||||||
options?: { ownerId?: string; resourceIds?: readonly string[] },
|
|
||||||
): AssetReleaseManifestProjection | undefined;
|
|
||||||
read(accessClass: AssetAccessClass, releaseVersion: string, resourceId: string): AssetReleasePayload | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoredItem {
|
|
||||||
accessClass: AssetAccessClass;
|
|
||||||
bytes: Buffer;
|
|
||||||
cacheKind?: PublicAssetCacheKind;
|
|
||||||
mimeType: string;
|
|
||||||
ownerId?: string;
|
|
||||||
projection: AssetReleaseManifestItem;
|
|
||||||
relativePath: string;
|
|
||||||
rootRef: string;
|
|
||||||
sha256: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseVersionPattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
|
||||||
const resourceIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
const sha256Pattern = /^[0-9a-f]{64}$/;
|
|
||||||
const rootRefPattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
|
||||||
|
|
||||||
function assetUrl(accessClass: AssetAccessClass, releaseVersion: string, resourceId: string) {
|
|
||||||
if (accessClass === "public_release_asset") return `/api/v1/assets/public/${releaseVersion}/${resourceId}`;
|
|
||||||
if (accessClass === "internal_preview_asset") return `/api/v1/assets/preview/${releaseVersion}/${resourceId}`;
|
|
||||||
return `/api/v1/private-assets/${releaseVersion}/${resourceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSafeRelativePath(value: string) {
|
|
||||||
if (!value || value.includes("\\") || posix.isAbsolute(value) || win32.isAbsolute(value)) return false;
|
|
||||||
const segments = value.split("/");
|
|
||||||
return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sha256(value: string | Uint8Array) {
|
|
||||||
return createHash("sha256").update(value).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function immutableProjection(input: Omit<AssetReleaseManifestProjection, "manifest_sha256">): AssetReleaseManifestProjection {
|
|
||||||
const items = input.items.map((item) => Object.freeze({ ...item }));
|
|
||||||
const manifestBody = JSON.stringify({ ...input, items });
|
|
||||||
return Object.freeze({ ...input, items: Object.freeze(items), manifest_sha256: sha256(manifestBody) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateItem(item: AssetReleaseItemInput, releaseVersion: string, seenIds: Set<string>): StoredItem {
|
|
||||||
if (!ASSET_ACCESS_CLASSES.includes(item.access_class)) throw new Error("asset access class is unsupported");
|
|
||||||
if (!resourceIdPattern.test(item.resource_id) || seenIds.has(item.resource_id)) throw new Error("resource_id must be a unique opaque UUID");
|
|
||||||
seenIds.add(item.resource_id);
|
|
||||||
if (!rootRefPattern.test(item.root_ref)) throw new Error("root_ref is invalid");
|
|
||||||
if (!isSafeRelativePath(item.relative_path)) throw new Error("relative path must stay within its declared root");
|
|
||||||
if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(item.mime_type)) throw new Error("mime_type is invalid");
|
|
||||||
if (item.access_class === "public_release_asset" && !item.cache_kind) throw new Error("public release asset requires an allowlisted cache kind");
|
|
||||||
if (item.access_class !== "public_release_asset" && item.cache_kind) throw new Error("non-public assets cannot declare a public cache kind");
|
|
||||||
if (item.access_class === "private_user_asset" && !item.owner_id) throw new Error("private user asset requires owner_id");
|
|
||||||
if (item.access_class !== "private_user_asset" && item.owner_id) throw new Error("only private user assets can declare owner_id");
|
|
||||||
|
|
||||||
const bytes = Buffer.from(item.content);
|
|
||||||
const digest = sha256(bytes);
|
|
||||||
if (item.sha256 !== undefined && (!sha256Pattern.test(item.sha256) || item.sha256 !== digest)) {
|
|
||||||
throw new Error("file SHA-256 does not match content");
|
|
||||||
}
|
|
||||||
const projection: AssetReleaseManifestItem = {
|
|
||||||
access_class: item.access_class,
|
|
||||||
byte_size: bytes.byteLength,
|
|
||||||
...(item.cache_kind ? { cache_kind: item.cache_kind } : {}),
|
|
||||||
mime_type: item.mime_type,
|
|
||||||
release_version: releaseVersion,
|
|
||||||
resource_id: item.resource_id,
|
|
||||||
sha256: digest,
|
|
||||||
url: assetUrl(item.access_class, releaseVersion, item.resource_id),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
accessClass: item.access_class,
|
|
||||||
bytes,
|
|
||||||
...(item.cache_kind ? { cacheKind: item.cache_kind } : {}),
|
|
||||||
mimeType: item.mime_type,
|
|
||||||
...(item.owner_id ? { ownerId: item.owner_id } : {}),
|
|
||||||
projection: Object.freeze(projection),
|
|
||||||
relativePath: item.relative_path,
|
|
||||||
rootRef: item.root_ref,
|
|
||||||
sha256: digest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAssetReleaseManifest(input: AssetReleaseManifestInput): AssetReleaseReader {
|
|
||||||
if (!releaseVersionPattern.test(input.release_version)) throw new Error("release_version is invalid");
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
const items = input.items
|
|
||||||
.map((item) => validateItem(item, input.release_version, seenIds))
|
|
||||||
.sort((left, right) => left.projection.resource_id.localeCompare(right.projection.resource_id));
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
project(accessClass: AssetAccessClass, releaseVersion: string, options: { ownerId?: string; resourceIds?: readonly string[] } = {}) {
|
|
||||||
if (releaseVersion !== input.release_version) return undefined;
|
|
||||||
const selected = items.filter((item) => item.accessClass === accessClass
|
|
||||||
&& (accessClass !== "private_user_asset" || Boolean(options.ownerId) && item.ownerId === options.ownerId)
|
|
||||||
&& (!options.resourceIds || options.resourceIds.includes(item.projection.resource_id)));
|
|
||||||
return immutableProjection({
|
|
||||||
items: selected.map((item) => item.projection),
|
|
||||||
release_version: input.release_version,
|
|
||||||
schema_version: "AssetReleaseManifest/v1",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
read(accessClass: AssetAccessClass, releaseVersion: string, resourceId: string) {
|
|
||||||
if (releaseVersion !== input.release_version) return undefined;
|
|
||||||
const item = items.find((candidate) => candidate.accessClass === accessClass && candidate.projection.resource_id === resourceId);
|
|
||||||
if (!item) return undefined;
|
|
||||||
return {
|
|
||||||
accessClass: item.accessClass,
|
|
||||||
bytes: Buffer.from(item.bytes),
|
|
||||||
mimeType: item.mimeType,
|
|
||||||
...(item.ownerId ? { ownerId: item.ownerId } : {}),
|
|
||||||
releaseVersion,
|
|
||||||
resourceId,
|
|
||||||
sha256: item.sha256,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"lib": ["ES2024"],
|
|
||||||
"types": ["node"],
|
|
||||||
"declaration": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
Generated
-12
@@ -38,9 +38,6 @@ importers:
|
|||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dada/asset-release-manifest':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../packages/asset-release-manifest
|
|
||||||
'@dada/shared-contracts':
|
'@dada/shared-contracts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared-contracts
|
version: link:../../packages/shared-contracts
|
||||||
@@ -154,15 +151,6 @@ importers:
|
|||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
|
|
||||||
packages/asset-release-manifest:
|
|
||||||
devDependencies:
|
|
||||||
'@types/node':
|
|
||||||
specifier: 24.13.3
|
|
||||||
version: 24.13.3
|
|
||||||
typescript:
|
|
||||||
specifier: 7.0.2
|
|
||||||
version: 7.0.2
|
|
||||||
|
|
||||||
packages/asset-renderer:
|
packages/asset-renderer:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dada/template-registry':
|
'@dada/template-registry':
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const frozenPackages = {
|
|||||||
},
|
},
|
||||||
"apps/api/package.json": {
|
"apps/api/package.json": {
|
||||||
dependencies: {
|
dependencies: {
|
||||||
"@dada/asset-release-manifest": "workspace:*",
|
|
||||||
"@fastify/multipart": "10.1.0",
|
"@fastify/multipart": "10.1.0",
|
||||||
"@fastify/swagger": "9.8.1",
|
"@fastify/swagger": "9.8.1",
|
||||||
"@sinclair/typebox": "0.34.52",
|
"@sinclair/typebox": "0.34.52",
|
||||||
@@ -47,12 +46,6 @@ export const frozenPackages = {
|
|||||||
typescript: "7.0.2",
|
typescript: "7.0.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/asset-release-manifest/package.json": {
|
|
||||||
devDependencies: {
|
|
||||||
"@types/node": "24.13.3",
|
|
||||||
typescript: "7.0.2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/shared-contracts/package.json": {
|
"packages/shared-contracts/package.json": {
|
||||||
dependencies: {
|
dependencies: {
|
||||||
"@sinclair/typebox": "0.34.52",
|
"@sinclair/typebox": "0.34.52",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ function runPnpm(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildApiContracts() {
|
export function buildApiContracts() {
|
||||||
runPnpm(["--filter", "@dada/api...", "build"]);
|
runPnpm(["--filter", "@dada/shared-contracts", "build"]);
|
||||||
|
runPnpm(["--filter", "@dada/api", "build"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOpenApiDocument() {
|
export async function createOpenApiDocument() {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { REQUIRED_COVERAGE_UNITS } from './wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|[\\/]{2}|\\\\)/;
|
||||||
|
|
||||||
|
function assertSafeRelative(value, field) {
|
||||||
|
if (typeof value !== 'string' || !value || ABSOLUTE_PATH.test(value) || path.isAbsolute(value)) {
|
||||||
|
throw new Error(`WP7_05_UNSAFE_${field}`);
|
||||||
|
}
|
||||||
|
const normalized = value.replaceAll('\\', '/');
|
||||||
|
if (normalized.split('/').includes('..')) throw new Error(`WP7_05_UNSAFE_${field}`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCoverageEvidence({ runId, candidateSha256, coverageUnits, viewports }) {
|
||||||
|
if (!runId || !/^[A-Za-z0-9._-]+$/.test(runId)) throw new Error('WP7_05_INVALID_RUN_ID');
|
||||||
|
if (!/^[A-Fa-f0-9]{64}$/.test(candidateSha256 ?? '')) throw new Error('WP7_05_INVALID_CANDIDATE_HASH');
|
||||||
|
if (!Array.isArray(coverageUnits)) throw new Error('WP7_05_COVERAGE_UNITS_REQUIRED');
|
||||||
|
|
||||||
|
const byPage = new Map();
|
||||||
|
for (const unit of coverageUnits) {
|
||||||
|
if (!REQUIRED_COVERAGE_UNITS.includes(unit.page_id)) throw new Error('WP7_05_UNKNOWN_PAGE');
|
||||||
|
if (byPage.has(unit.page_id)) throw new Error('WP7_05_DUPLICATE_PAGE');
|
||||||
|
if (!Array.isArray(unit.states) || unit.states.length === 0) throw new Error('WP7_05_STATES_REQUIRED');
|
||||||
|
const states = unit.states.map((state) => ({
|
||||||
|
state: assertSafeRelative(state.state, 'STATE'),
|
||||||
|
screenshot_100pct: assertSafeRelative(state.screenshot_100pct, 'SCREENSHOT'),
|
||||||
|
screenshot_200pct: assertSafeRelative(state.screenshot_200pct, 'SCREENSHOT'),
|
||||||
|
trace: assertSafeRelative(state.trace, 'TRACE'),
|
||||||
|
}));
|
||||||
|
byPage.set(unit.page_id, { page_id: unit.page_id, states });
|
||||||
|
}
|
||||||
|
const missing = REQUIRED_COVERAGE_UNITS.filter((page) => !byPage.has(page));
|
||||||
|
if (missing.length) throw new Error(`WP7_05_MISSING_PAGES:${missing.join(',')}`);
|
||||||
|
if (!Array.isArray(viewports) || viewports.length !== 2) throw new Error('WP7_05_VIEWPORTS_REQUIRED');
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: '1.0',
|
||||||
|
task: 'TASK-WP7-05',
|
||||||
|
run_id: runId,
|
||||||
|
candidate_sha256: candidateSha256.toUpperCase(),
|
||||||
|
viewports,
|
||||||
|
coverage_units: REQUIRED_COVERAGE_UNITS.map((page) => byPage.get(page)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
export const REQUIRED_COVERAGE_UNITS = Object.freeze([
|
||||||
|
'support-gate', 'user-auth', 'workspace', 'current-task', 'projects',
|
||||||
|
'project-detail', 'editor', 'export', 'credits', 'settings',
|
||||||
|
'preview-user-variant', 'admin-auth', 'admin-overview', 'admin-users',
|
||||||
|
'admin-invites', 'admin-models', 'admin-assets', 'admin-preview',
|
||||||
|
'admin-generations', 'admin-services-storage', 'admin-audit', 'system-ui',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REQUIRED_VIEWPORTS = Object.freeze([
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 100 },
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 200 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function blocked(code, details = {}) {
|
||||||
|
return { status: 'externally_blocked', code, ...details };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCandidateRecord(path) {
|
||||||
|
if (!path || !fs.existsSync(path)) return blocked('candidate_record_missing');
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||||
|
if (!Array.isArray(record.browsers) || record.browsers.length !== 2) {
|
||||||
|
return blocked('candidate_browser_record_incomplete');
|
||||||
|
}
|
||||||
|
const brands = new Set(record.browsers.map((browser) => browser.brand));
|
||||||
|
if (brands.size !== 2 || !brands.has('Google Chrome') || !brands.has('Microsoft Edge')) {
|
||||||
|
return blocked('candidate_browser_pair_invalid');
|
||||||
|
}
|
||||||
|
if (record.windows?.build == null || !record.candidate_package?.sha256 || !record.candidate_package?.fixed_port) {
|
||||||
|
return blocked('candidate_identity_incomplete');
|
||||||
|
}
|
||||||
|
if (record.browsers.some((browser) => !browser.full_version || !browser.major)) {
|
||||||
|
return blocked('candidate_full_version_missing');
|
||||||
|
}
|
||||||
|
return { status: 'ready', record };
|
||||||
|
} catch {
|
||||||
|
return blocked('candidate_record_invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCoverageEvidence(evidence) {
|
||||||
|
if (!evidence || !Array.isArray(evidence.coverage_units)) {
|
||||||
|
return blocked('coverage_evidence_missing');
|
||||||
|
}
|
||||||
|
const actual = new Set(evidence.coverage_units.map((unit) => unit.page_id));
|
||||||
|
const missing = REQUIRED_COVERAGE_UNITS.filter((unit) => !actual.has(unit));
|
||||||
|
if (missing.length) return blocked('coverage_units_incomplete', { missing });
|
||||||
|
const missingStates = evidence.coverage_units
|
||||||
|
.filter((unit) => REQUIRED_COVERAGE_UNITS.includes(unit.page_id))
|
||||||
|
.filter((unit) => !Array.isArray(unit.states) || unit.states.length === 0)
|
||||||
|
.map((unit) => unit.page_id);
|
||||||
|
if (missingStates.length) return blocked('coverage_states_incomplete', { missingStates });
|
||||||
|
const viewportKeys = new Set((evidence.viewports ?? []).map((viewport) => JSON.stringify(viewport)));
|
||||||
|
const missingViewports = REQUIRED_VIEWPORTS.filter((viewport) => !viewportKeys.has(JSON.stringify(viewport)));
|
||||||
|
if (missingViewports.length) return blocked('candidate_viewports_incomplete', { missingViewports });
|
||||||
|
return { status: 'ready' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runWp705Gate({ candidatePath, evidence, dependencies = {} }) {
|
||||||
|
const candidate = loadCandidateRecord(candidatePath);
|
||||||
|
if (candidate.status !== 'ready') return candidate;
|
||||||
|
const coverage = validateCoverageEvidence(evidence);
|
||||||
|
if (coverage.status !== 'ready') return coverage;
|
||||||
|
const externalBlockers = Object.entries(dependencies)
|
||||||
|
.filter(([, status]) => status === 'externally_blocked')
|
||||||
|
.map(([task]) => task);
|
||||||
|
if (externalBlockers.length) return blocked('upstream_external_blocked', { externalBlockers });
|
||||||
|
return { status: 'ready_for_execution' };
|
||||||
|
}
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const phaseIndex = process.argv.indexOf("--phase");
|
|
||||||
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
|
||||||
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
|
||||||
const runId = process.env.DADA_TDD_RUN_ID ?? `wp5-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
|
||||||
const runDirectory = resolve("artifacts", "tdd", runId);
|
|
||||||
const casesDirectory = resolve(runDirectory, "cases");
|
|
||||||
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
|
||||||
|
|
||||||
const cases = [
|
|
||||||
{
|
|
||||||
acceptance_criteria: ["AC-42", "AC-48"],
|
|
||||||
evidence: ["response.json", "headers.json", "cache-enumeration.json", "trace.zip"],
|
|
||||||
id: "TDD-WP5-RES-001-three-access-classes",
|
|
||||||
red_reason: "公开 manifest 暴露 internal/private、路径可推导或缓存策略混用",
|
|
||||||
requirements: ["PRIV-02", "PRIV-05"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
acceptance_criteria: ["AC-46", "AC-48"],
|
|
||||||
evidence: ["cache-enumeration.json", "service-worker.json", "trace.zip"],
|
|
||||||
id: "TDD-WP5-CACHE-001-no-private-client-state",
|
|
||||||
red_reason: "SW 拦截 internal/private 或 IndexedDB 保存私有字段",
|
|
||||||
requirements: ["NFR-07", "PRIV-02"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
|
|
||||||
|
|
||||||
const resourceDirectory = resolve(casesDirectory, cases[0].id);
|
|
||||||
const cacheDirectory = resolve(casesDirectory, cases[1].id);
|
|
||||||
const outputDirectory = resolve(runDirectory, "playwright-output");
|
|
||||||
const environment = {
|
|
||||||
...process.env,
|
|
||||||
DADA_EVIDENCE_DIR_WP5_CACHE: cacheDirectory,
|
|
||||||
DADA_EVIDENCE_DIR_WP5_RES: resourceDirectory,
|
|
||||||
DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory,
|
|
||||||
};
|
|
||||||
const commands = phase === "red"
|
|
||||||
? [["red-focused", "pnpm --filter @dada/shared-contracts build && pnpm exec vitest run tests/unit/wp5-04-asset-release-manifest.test.ts tests/api/wp5-04-asset-access.test.ts"]]
|
|
||||||
: [
|
|
||||||
["build-manifest", "pnpm --filter @dada/asset-release-manifest build"],
|
|
||||||
["unit", "pnpm test:unit"],
|
|
||||||
["api", "pnpm test:api"],
|
|
||||||
["e2e", "pnpm test:e2e"],
|
|
||||||
["security", "pnpm test:security"],
|
|
||||||
["package", "pnpm test:package"],
|
|
||||||
["tdd-trace", "pnpm validate:tdd-trace"],
|
|
||||||
];
|
|
||||||
const commandResults = [];
|
|
||||||
for (const [name, command] of commands) {
|
|
||||||
const started_at = new Date().toISOString();
|
|
||||||
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
|
|
||||||
encoding: "utf8", env: environment, maxBuffer: 40 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
if (result.stdout) process.stdout.write(result.stdout);
|
|
||||||
if (result.stderr) process.stderr.write(result.stderr);
|
|
||||||
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
|
||||||
if (phase === "green" && (result.status ?? 1) !== 0) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFiles(directory, name) {
|
|
||||||
const matches = [];
|
|
||||||
if (!existsSync(directory)) return matches;
|
|
||||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
||||||
const path = resolve(directory, entry.name);
|
|
||||||
if (entry.isDirectory()) matches.push(...findFiles(path, name));
|
|
||||||
else if (entry.name === name) matches.push(path);
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase === "green") {
|
|
||||||
const trace = findFiles(outputDirectory, "trace.zip").find((path) => path.toLowerCase().includes("wp5-04"));
|
|
||||||
if (trace) {
|
|
||||||
copyFileSync(trace, resolve(resourceDirectory, "trace.zip"));
|
|
||||||
copyFileSync(trace, resolve(cacheDirectory, "trace.zip"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const redConfirmed = phase === "red" && commandResults.length === 1 && commandResults[0].exit_code !== 0;
|
|
||||||
if (phase === "red") {
|
|
||||||
for (const item of cases) {
|
|
||||||
writeFileSync(resolve(casesDirectory, item.id, "red-observation.json"), `${JSON.stringify({
|
|
||||||
expected_failure: item.red_reason,
|
|
||||||
observed_command: commandResults[0].command,
|
|
||||||
observed_exit_code: commandResults[0].exit_code,
|
|
||||||
status: redConfirmed ? "red_confirmed" : "failed",
|
|
||||||
}, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandState = phase === "red" ? redConfirmed : commandResults.length === commands.length && commandResults.every((result) => result.exit_code === 0);
|
|
||||||
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
|
|
||||||
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
|
||||||
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
|
||||||
const summaries = [];
|
|
||||||
for (const item of cases) {
|
|
||||||
const directory = resolve(casesDirectory, item.id);
|
|
||||||
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
|
|
||||||
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
|
|
||||||
const status = commandState && missingEvidence.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed";
|
|
||||||
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
|
||||||
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
|
|
||||||
acceptance_criteria: item.acceptance_criteria,
|
|
||||||
automation: ["automated"],
|
|
||||||
commit,
|
|
||||||
evidence_refs: evidenceRefs,
|
|
||||||
layer: ["UNIT", "API", "E2E", "PKG-SEC"],
|
|
||||||
manifest,
|
|
||||||
missing_evidence: missingEvidence,
|
|
||||||
phase,
|
|
||||||
red_reason: item.red_reason,
|
|
||||||
requirements: item.requirements,
|
|
||||||
run_id: runId,
|
|
||||||
status,
|
|
||||||
task_id: "TASK-WP5-04",
|
|
||||||
test_id: item.id,
|
|
||||||
work_package: "WP-5",
|
|
||||||
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
|
||||||
}, null, 2)}\n`);
|
|
||||||
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
|
|
||||||
}
|
|
||||||
const status = summaries.every((item) => item.status === (phase === "red" ? "red_confirmed" : "passed"))
|
|
||||||
? phase === "red" ? "red_confirmed" : "passed"
|
|
||||||
: "failed";
|
|
||||||
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
|
||||||
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
|
|
||||||
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
|
|
||||||
if (status === "failed") process.exit(1);
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { runWp705Gate } from './lib/wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
const result = runWp705Gate({
|
||||||
|
candidatePath: process.env.WP7_01_CANDIDATE_RECORD,
|
||||||
|
evidence: null,
|
||||||
|
dependencies: {
|
||||||
|
'TASK-WP7-03': 'externally_blocked',
|
||||||
|
'TASK-WP7-04': 'externally_blocked',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ task: 'TASK-WP7-05', ...result }));
|
||||||
|
process.exitCode = result.status === 'ready_for_execution' ? 0 : 3;
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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,41 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildCoverageEvidence } from '../../scripts/lib/wp7-05-coverage.mjs';
|
||||||
|
import { REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 100 },
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function completeUnits() {
|
||||||
|
return REQUIRED_COVERAGE_UNITS.map((page_id) => ({
|
||||||
|
page_id,
|
||||||
|
states: [{
|
||||||
|
state: 'normal',
|
||||||
|
screenshot_100pct: `ui/${page_id}/normal/100pct.png`,
|
||||||
|
screenshot_200pct: `ui/${page_id}/normal/200pct.png`,
|
||||||
|
trace: `ui/${page_id}/normal/trace.zip`,
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('builds ordered, sanitized evidence for all 22 coverage units', () => {
|
||||||
|
const evidence = buildCoverageEvidence({
|
||||||
|
runId: 'wp7-05-red-001',
|
||||||
|
candidateSha256: 'a'.repeat(64),
|
||||||
|
coverageUnits: completeUnits(),
|
||||||
|
viewports,
|
||||||
|
});
|
||||||
|
assert.equal(evidence.coverage_units.length, 22);
|
||||||
|
assert.equal(evidence.coverage_units[0].page_id, 'support-gate');
|
||||||
|
assert.equal(evidence.candidate_sha256, 'A'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects absolute evidence paths and incomplete page state', () => {
|
||||||
|
const units = completeUnits();
|
||||||
|
units[0].states[0].trace = 'C:\\secret\\trace.zip';
|
||||||
|
assert.throws(() => buildCoverageEvidence({
|
||||||
|
runId: 'wp7-05-red-001', candidateSha256: 'a'.repeat(64), coverageUnits: units, viewports,
|
||||||
|
}), /WP7_05_UNSAFE_TRACE/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { loadCandidateRecord, validateCoverageEvidence, runWp705Gate, REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
test('WP7-05 accepts the WP7-01 candidate record schema', () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wp7-05-'));
|
||||||
|
const file = path.join(directory, 'release-candidate.json');
|
||||||
|
fs.writeFileSync(file, JSON.stringify({
|
||||||
|
browsers: [
|
||||||
|
{ brand: 'Google Chrome', full_version: '150.0.0', major: 150 },
|
||||||
|
{ brand: 'Microsoft Edge', full_version: '151.0.0', major: 151 },
|
||||||
|
],
|
||||||
|
windows: { build: '26200.8875' },
|
||||||
|
candidate_package: { fixed_port: 43121, sha256: 'A'.repeat(64) },
|
||||||
|
}));
|
||||||
|
assert.equal(loadCandidateRecord(file).status, 'ready');
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WP7-05 requires all 22 coverage units and both exact candidate viewports', () => {
|
||||||
|
const result = validateCoverageEvidence({ coverage_units: [], viewports: [] });
|
||||||
|
assert.equal(result.status, 'externally_blocked');
|
||||||
|
assert.equal(result.code, 'coverage_units_incomplete');
|
||||||
|
assert.equal(result.missing.length, REQUIRED_COVERAGE_UNITS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WP7-05 requires state evidence for every listed coverage unit', () => {
|
||||||
|
const result = validateCoverageEvidence({
|
||||||
|
coverage_units: REQUIRED_COVERAGE_UNITS.map((page_id) => ({ page_id, states: [] })),
|
||||||
|
viewports: [],
|
||||||
|
});
|
||||||
|
assert.equal(result.code, 'coverage_states_incomplete');
|
||||||
|
assert.equal(result.missingStates.length, REQUIRED_COVERAGE_UNITS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WP7-05 preserves upstream external blockers instead of declaring Green', () => {
|
||||||
|
const result = runWp705Gate({
|
||||||
|
candidatePath: 'missing-release-candidate.json',
|
||||||
|
evidence: null,
|
||||||
|
dependencies: { 'TASK-WP7-03': 'externally_blocked' },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'externally_blocked');
|
||||||
|
assert.equal(result.code, 'candidate_record_missing');
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@ describe("TASK-WP0-01 minimum toolchain", () => {
|
|||||||
expect(probe.fabricVersion).toBe("7.4.0");
|
expect(probe.fabricVersion).toBe("7.4.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads and closes Fastify with the frozen Swagger plugin", async () => {
|
it("loads and closes Fastify with the frozen Swagger plugin", { timeout: 15_000 }, async () => {
|
||||||
const app = await createApp();
|
const app = await createApp();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
expect(app.hasPlugin("@fastify/swagger")).toBe(true);
|
expect(app.hasPlugin("@fastify/swagger")).toBe(true);
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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