180 lines
7.7 KiB
TypeScript
180 lines
7.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { createApp } from "../../apps/api/src/app.js";
|
|
import { AssetPreviewGrantService } from "../../apps/api/src/preview-grants.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 start = Date.parse("2026-08-04T08:00:00.000Z");
|
|
const releaseVersion = "asset-20260804.1";
|
|
const previewResourceId = "8f9b5c62-7488-4c7a-9f0c-3b8f3fc34f92";
|
|
const roots: string[] = [];
|
|
const registrations: RegistrationService[] = [];
|
|
|
|
function harness() {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp5-07-"));
|
|
roots.push(root);
|
|
let now = start;
|
|
const registration = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0x71),
|
|
clock: () => now,
|
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
|
databasePath: join(root, "dada.sqlite3"),
|
|
invitePepper: Buffer.alloc(32, 0x72),
|
|
resend: new MockResendAdapter(),
|
|
sessionPepper: Buffer.alloc(32, 0x73),
|
|
});
|
|
registrations.push(registration);
|
|
const userId = randomUUID();
|
|
registration.database.prepare(`
|
|
INSERT INTO users (
|
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
|
registration_id, created_at
|
|
) VALUES (?, ?, 'user', 'active', 1, ?, ?)
|
|
`).run(userId, `${userId}@example.invalid`, randomUUID(), start);
|
|
registration.database.prepare(`
|
|
INSERT INTO user_profiles (user_id, creator_name, social_id)
|
|
VALUES (?, 'Preview User', '@preview_user')
|
|
`).run(userId);
|
|
registration.database.prepare(`
|
|
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
|
|
VALUES (?, 10, 0, ?)
|
|
`).run(userId, start);
|
|
const adminId = randomUUID();
|
|
registration.database.prepare(`
|
|
INSERT INTO users (
|
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
|
registration_id, created_at
|
|
) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
|
|
`).run(adminId, `${adminId}@example.invalid`, randomUUID(), start);
|
|
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
|
|
const assetReleases = createAssetReleaseManifest({
|
|
items: [{
|
|
access_class: "internal_preview_asset",
|
|
content: Buffer.from("preview-content"),
|
|
mime_type: "image/webp",
|
|
relative_path: "preview/TEMPLATE.webp",
|
|
resource_id: previewResourceId,
|
|
root_ref: "canonical-assets",
|
|
}],
|
|
release_version: releaseVersion,
|
|
});
|
|
const service = new AssetPreviewGrantService({ assetReleases, registration, clock: () => now });
|
|
return {
|
|
advance(milliseconds: number) { now += milliseconds; },
|
|
adminId,
|
|
registration,
|
|
service,
|
|
userId,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const registration of registrations.splice(0)) registration.close();
|
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
|
});
|
|
|
|
describe("TASK-WP5-07 internal preview grant lifecycle", () => {
|
|
it("keeps ordinary role, returns randomized manifest item IDs, and blocks revoked/expired grants", () => {
|
|
const test = harness();
|
|
const batch = test.service.createBatch({
|
|
name: "WP5 preview batch",
|
|
adminUserId: test.adminId,
|
|
});
|
|
test.service.addBatchItems({
|
|
adminUserId: test.adminId,
|
|
batchId: batch.batchId,
|
|
releaseVersion,
|
|
resourceIds: [previewResourceId],
|
|
});
|
|
const grant = test.service.grant({
|
|
adminUserId: test.adminId,
|
|
batchId: batch.batchId,
|
|
expiresAt: start + 60_000,
|
|
userId: test.userId,
|
|
});
|
|
|
|
const firstManifest = test.service.projectManifest({ releaseVersion, userId: test.userId });
|
|
expect(firstManifest?.items).toHaveLength(1);
|
|
expect(firstManifest?.items[0].resource_id).not.toBe(previewResourceId);
|
|
expect(firstManifest?.items[0].url).toContain(firstManifest?.items[0].resource_id ?? "");
|
|
expect(test.service.readManifestItem({
|
|
manifestItemId: firstManifest!.items[0].resource_id,
|
|
releaseVersion,
|
|
userId: test.userId,
|
|
})?.bytes).toEqual(Buffer.from("preview-content"));
|
|
expect(test.registration.database.prepare("SELECT role FROM users WHERE user_id = ?").get(test.userId)).toEqual({ role: "user" });
|
|
|
|
test.service.revoke({ adminUserId: test.adminId, grantId: grant.grantId });
|
|
expect(test.service.projectManifest({ releaseVersion, userId: test.userId })).toBeUndefined();
|
|
expect(test.service.readManifestItem({
|
|
manifestItemId: firstManifest!.items[0].resource_id,
|
|
releaseVersion,
|
|
userId: test.userId,
|
|
})).toBeUndefined();
|
|
|
|
const secondGrant = test.service.grant({
|
|
adminUserId: test.adminId,
|
|
batchId: batch.batchId,
|
|
expiresAt: start + 10_000,
|
|
userId: test.userId,
|
|
});
|
|
expect(secondGrant.status).toBe("active");
|
|
test.advance(10_001);
|
|
expect(test.service.projectManifest({ releaseVersion, userId: test.userId })).toBeUndefined();
|
|
expect(test.registration.database.prepare("SELECT status FROM asset_preview_grants WHERE grant_id = ?").get(secondGrant.grantId)).toEqual({ status: "expired" });
|
|
|
|
test.registration.database.prepare("UPDATE users SET status = 'active' WHERE user_id = ?").run(test.userId);
|
|
test.service.grant({
|
|
adminUserId: test.adminId,
|
|
batchId: batch.batchId,
|
|
expiresAt: start + 120_000,
|
|
userId: test.userId,
|
|
});
|
|
test.registration.changeUserStatus(test.userId, "suspended");
|
|
expect(test.service.projectManifest({ releaseVersion, userId: test.userId })).toBeUndefined();
|
|
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type LIKE 'preview_grant_%'").get()).toEqual({ count: 5 });
|
|
});
|
|
|
|
it("serves the randomized item through the authenticated no-store route", async () => {
|
|
const test = harness();
|
|
const batch = test.service.createBatch({ name: "WP5 route batch", adminUserId: test.adminId });
|
|
test.service.addBatchItems({
|
|
adminUserId: test.adminId,
|
|
batchId: batch.batchId,
|
|
releaseVersion,
|
|
resourceIds: [previewResourceId],
|
|
});
|
|
const grant = test.service.grant({ adminUserId: test.adminId, batchId: batch.batchId, expiresAt: start + 60_000, userId: test.userId });
|
|
const session = test.registration.issueAuthenticatedSession(test.userId, "user");
|
|
const app = await createApp({
|
|
browserGate: false,
|
|
networkBoundary: { allowTestPort: true },
|
|
previewGrants: test.service,
|
|
registration: test.registration,
|
|
});
|
|
try {
|
|
const headers = { cookie: `dada_session=${session.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
|
const manifest = await app.inject({ headers, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/manifest` });
|
|
expect(manifest.statusCode).toBe(200);
|
|
const itemId = manifest.json().items[0].resource_id;
|
|
expect(itemId).not.toBe(previewResourceId);
|
|
const asset = await app.inject({ headers, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/${itemId}` });
|
|
expect(asset.statusCode).toBe(200);
|
|
expect(asset.headers["cache-control"]).toBe("private, no-store");
|
|
expect(asset.rawPayload).toEqual(Buffer.from("preview-content"));
|
|
test.service.revoke({ adminUserId: test.adminId, grantId: grant.grantId });
|
|
const revoked = await app.inject({ headers, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/${itemId}` });
|
|
expect(revoked.statusCode).toBe(404);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
});
|