diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index ed34cb958..080e45b18 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -11,3 +11,5 @@ - 認証状態と成果物を分離し、scenario・引数・成果物に秘密の値を含めない。 - PlaywrightとNixで固定するbrowserの対応を、toolchain変更として同時に検証する。 - 変更時は`deno.json`のcheck・testと必要なbrowser testを選ぶ。scenario変更では実際の画像と`review-context.json`を確認する。browser testの成功を目視の代わりにしない。 + +Subject権限の操作確認には[`subject_permissions_review.ts`](browser-tests/subject_permissions_review.ts)、変更前の撮影には[`subject_permissions_before.ts`](browser-tests/subject_permissions_before.ts)を使う。実Webのビルドを合成HTTP fixtureで動かすため、採取結果は実Backendの認証・保存・認可・Worker制御の証拠ではない。画像と診断の目視確認も別途必要である。 diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 069b413e3..c27cc1d75 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -1,5 +1,9 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { journalBrowserFixture } from "./journal_browser_fixture.ts"; +import { + createSubjectPermissionsFixture, + permissionFixtureSubjects, +} from "./subject_permissions_fixture.ts"; const port = Number(Deno.args[0]); const buildRoot = Deno.args[1]; @@ -11,6 +15,15 @@ if ( throw new Error("usage: server.ts [section-count: 3..100]"); } +// Opt-in keeps the existing Memory scenarios and their pagination unchanged. +const permissionsMode = Deno.args[3] === "permissions"; +if (permissionsMode) { + // Permission evidence must serve a parent-supplied actual production build. + // Do not make /health succeed for a missing build or substitute fixture HTML. + await Deno.stat(join(buildRoot, "index.html")); + await Deno.stat(join(buildRoot, "_app/immutable")); +} +const permissionsFixture = createSubjectPermissionsFixture(); const workspaceId = "memory-review"; const representativeSubjectId = "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review"; @@ -81,6 +94,11 @@ const subjects = [ subject(ungeneratedSubjectId, "Ungenerated subject", "active", "fingerprint-0"), subject(errorSubjectId, "Unavailable subject", "retired", "fingerprint-3"), ]; +if (permissionsMode) { + subjects.push( + ...permissionFixtureSubjects.map(([id, role]) => subject(id, role, "active", "fingerprint-0")), + ); +} const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", "fingerprint-1"); function connectedWorker(displayName: string) { @@ -132,7 +150,13 @@ function memoriesFor(subjectId: string) { if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return []; if (subjectId === staleSubjectId) { return [ - memorySummary("stale-memory-1", "change-earlier", "lesson", "resolved", "A resolved stale-surface lesson"), + memorySummary( + "stale-memory-1", + "change-earlier", + "lesson", + "resolved", + "A resolved stale-surface lesson", + ), ]; } if (subjectId === failedSubjectId) { @@ -227,7 +251,12 @@ function surfaceFor(subjectId: string) { function memoryDetail(memoryId: string, requestedChangeId: string | null) { const currentChangeId = memoryId === derivedMemoryId ? "change-parent" : "change-current"; const changeId = requestedChangeId ?? currentChangeId; - const states: Record = { "change-original": "retracted", "change-earlier": "resolved", "change-current": "active", "change-parent": "active" }; + const states: Record = { + "change-original": "retracted", + "change-earlier": "resolved", + "change-current": "active", + "change-parent": "active", + }; return { memory_id: memoryId, change_id: changeId, @@ -361,6 +390,10 @@ async function staticResponse(pathname: string): Promise { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + if (permissionsMode) { + const response = await permissionsFixture(request); + if (response) return response; + } if (url.pathname === "/fixture/subject-create-requests") { return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests }); } @@ -393,6 +426,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { manage_secrets: false, manage_runtimes: false, delete_workspace: false, + delete_subjects: false, }, extension_points: { store: "fixture", @@ -409,7 +443,13 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } if (url.pathname === `/api/w/${workspaceId}/workers`) { - return json({ workspace_id: workspaceId, limit: 200, items: [], source: "fixture", diagnostics: [] }); + return json({ + workspace_id: workspaceId, + limit: 200, + items: [], + source: "fixture", + diagnostics: [], + }); } if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) { return json({ @@ -521,7 +561,15 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const paginated = subjectId === representativeSubjectId; const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor; const items = continued - ? [memorySummary("memory-page-two", "change-original", "lesson", "active", "Memory on the next page")] + ? [ + memorySummary( + "memory-page-two", + "change-original", + "lesson", + "active", + "Memory on the next page", + ), + ] : memoriesFor(subjectId); return json({ items, @@ -536,7 +584,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { } if (parts.length === 3) { const requested = url.searchParams.get("change_id"); - const knownChanges = memoryId === derivedMemoryId ? ["change-parent"] : ["change-original", "change-earlier", "change-current"]; + const knownChanges = memoryId === derivedMemoryId + ? ["change-parent"] + : ["change-original", "change-earlier", "change-current"]; if (requested !== null && !knownChanges.includes(requested)) return json({}, 404); return json(memoryDetail(memoryId, requested)); } diff --git a/tools/web-ux/browser-tests/subject_permissions_before.ts b/tools/web-ux/browser-tests/subject_permissions_before.ts new file mode 100644 index 000000000..5ba18304b --- /dev/null +++ b/tools/web-ux/browser-tests/subject_permissions_before.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env -S deno run -A +/** Capture the actual built Subject detail before UI changes; never builds Web. */ +import { dirname, fromFileUrl, join, resolve } from "@std/path"; +import { capture } from "../src/capture.ts"; +import type { Scenario } from "../src/types.ts"; +import { freePort } from "./memory_document_harness.ts"; + +const repositoryRoot = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); +const outputRoot = join(repositoryRoot, "target/web-ux/t-802"); +const buildRoot = resolve(Deno.args[0] ?? join(repositoryRoot, "web/workspace/build")); +const runPrefix = Deno.args[1] ?? `before-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`; +if (!/^[a-zA-Z0-9._-]+$/.test(runPrefix)) throw new Error("run id must be a safe filename"); +// Preflight is mandatory. A missing build must not be replaced by fixture HTML. +for (const filename of ["index.html", "_app/immutable"]) { + await Deno.stat(join(buildRoot, filename)).catch(() => { + throw new Error( + `Actual Web build missing: ${ + join(buildRoot, filename) + }. Parent must build/supply it; this harness does not build Web or bypass preflight.`, + ); + }); +} +await Deno.mkdir(outputRoot, { recursive: true }); +const index = await Deno.readFile(join(buildRoot, "index.html")); +const indexSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", index))) + .map((b) => b.toString(16).padStart(2, "0")).join(""); +await Deno.writeTextFile( + join(outputRoot, `${runPrefix}-build.json`), + JSON.stringify( + { + evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend", + buildRoot, + indexSha256, + capturedAt: new Date().toISOString(), + visualVerdict: "UNREVIEWED — parent must ViewImage before pass", + }, + null, + 2, + ), +); +const port = await freePort(); +for (const theme of ["light", "dark"] as const) { + const scenario: Scenario = { + schemaVersion: 1, + id: `subject-permissions-before-${theme}`, + title: "T-802 BEFORE — actual Subject detail / synthetic HTTP, NOT real Backend", + baseUrl: `http://127.0.0.1:${port}`, + colorScheme: theme, + reducedMotion: "reduce", + personas: [{ + id: "synthetic-owner", + label: "Synthetic HTTP owner, NOT real Backend authentication", + auth: { kind: "anonymous" }, + }], + viewports: [ + { label: "desktop", width: 1440, height: 900 }, + { label: "narrow", width: 768, height: 900 }, + { label: "mobile", width: 390, height: 844 }, + ], + routes: [{ + id: "subject-detail", + label: "Existing Subject detail before permission UI", + path: + "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review", + goal: + "Find Subject identity, current state and ordinary routes before permission management changes.", + dataState: + "Active Subject with current Worker and representative long identifiers. All HTTP is synthetic; no real Backend evidence.", + ready: { kind: "selector", selector: ".subject-overview" }, + capturePoints: [{ id: "initial", label: "BEFORE Subject detail" }], + }], + processes: [{ + id: "t-802-owned-synthetic-http", + command: Deno.execPath(), + cwd: join(repositoryRoot, "tools/web-ux"), + args: [ + "run", + "-A", + join(repositoryRoot, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"), + String(port), + buildRoot, + "3", + "permissions", + ], + readyUrl: `http://127.0.0.1:${port}/health`, + }], + }; + const scenarioPath = join(outputRoot, `${runPrefix}-${theme}-scenario.json`); + await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2)); + const bundle = await capture({ + scenarioPath, + outputDirectory: outputRoot, + runId: `${runPrefix}-${theme}`, + }); + console.log(JSON.stringify({ runId: bundle.runId, status: bundle.status })); + if (bundle.status !== "completed") { + throw new Error( + `BEFORE capture was not clean: ${bundle.runId}; inspect review-context.json. No visual pass is implied.`, + ); + } +} diff --git a/tools/web-ux/browser-tests/subject_permissions_fixture.ts b/tools/web-ux/browser-tests/subject_permissions_fixture.ts new file mode 100644 index 000000000..9efd6de6a --- /dev/null +++ b/tools/web-ux/browser-tests/subject_permissions_fixture.ts @@ -0,0 +1,187 @@ +/** + * Synthetic HTTP state for T-802 browser UX only. NOT the real Backend, + * authentication, authorization, operation catalog, or cancellation implementation. + * Keep real Backend integration evidence separate from these browser fixtures. + */ +export const permissionFixtureSubjects = [ + ["permissions-empty", "New Subject — permissions not configured"], + ["permissions-edit", "Release coordination"], + ["permissions-legacy", "Subject with preserved legacy permissions"], + ["permissions-reader", "Read-only Subject permissions"], + ["permissions-unavailable", "Unavailable Subject permissions"], +] as const; + +export type FixturePermission = { + operation: string; + resource: Record & { kind: string }; +}; +export type FixturePolicy = { + change_id: string; + execution: FixturePermission[]; + delegation: FixturePermission[]; +}; +type FixtureUpdate = Omit & { + operation_id: string; + expected_change_id: string | null; +}; + +const workspaceId = "memory-review"; +const resource = { kind: "ticket", workspace_id: workspaceId, ticket_id: "T-802" }; +export const fixtureLegacyPermission: FixturePermission = { + operation: "worker.notify", + resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" }, +}; +// Deliberately a small synthetic catalog, not a copy/second authority for production. +export const fixturePermissionOperations: { + operation: string; + label: string; + description: string; + resource_kinds: string[]; + availability: { kind: "available" } | { kind: "unavailable"; reason: string }; +}[] = [ + { + operation: "ticket.read", + label: "Read ticket", + description: "Read the selected ticket. Does not allow editing or closing it.", + resource_kinds: ["ticket"], + availability: { kind: "available" }, + }, + { + operation: "ticket.comment", + label: "Comment on ticket", + description: "Append a comment to the selected ticket; no completion or assignment authority.", + resource_kinds: ["ticket"], + availability: { kind: "available" }, + }, + { + operation: "worker.messaging", + label: "Message a Worker (T-803)", + description: + "Operation use is separate from Backend directional target control grants and execution conditions.", + resource_kinds: [], + availability: { + kind: "unavailable", + reason: "T-803 is not implemented in this synthetic browser fixture.", + }, + }, +]; + +function json(value: unknown, status = 200): Response { + return Response.json(value, { + status, + headers: { "x-yoi-evidence": "synthetic-http-not-real-backend" }, + }); +} +function error(message: string, status: number): Response { + return json({ error: "Synthetic fixture response", message }, status); +} +function isUpdate(value: unknown): value is FixtureUpdate { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.operation_id === "string" && v.operation_id.length > 0 && + (v.expected_change_id === null || typeof v.expected_change_id === "string") && + Array.isArray(v.execution) && Array.isArray(v.delegation) && + [...v.execution, ...v.delegation].every((p) => + p && typeof p.operation === "string" && p.resource && typeof p.resource.kind === "string" + ); +} + +export function createSubjectPermissionsFixture() { + const policies = new Map(); + const receipts = new Map(); + const requests: { subject_id: string; request: FixtureUpdate; outcome: string }[] = []; + let issuedChangeCount = 0; + for (const [id] of permissionFixtureSubjects) { + policies.set( + id, + id === "permissions-empty" ? null : { + change_id: `fixture-initial-${id}`, + execution: [{ operation: "ticket.read", resource: { ...resource } }], + delegation: id === "permissions-legacy" ? [structuredClone(fixtureLegacyPermission)] : [], + }, + ); + } + + return async (request: Request): Promise => { + const url = new URL(request.url); + if (url.pathname === "/api/auth/whoami") { + // Exercise the real Web's admission gate with a synthetic HTTP session projection. + // No cookies/tokens or real Backend session are created or bypassed. + return json({ + actor: { + user_id: "synthetic-owner", + account_id: "owner", + handle: "fixture-owner", + display_name: "Synthetic browser owner", + auth_method: "browser_session", + }, + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + authentication_reference: "synthetic-http-not-real-authentication", + }); + } + if (url.pathname === "/fixture/permissions/evidence") { + if (request.method !== "GET") return error("GET required", 405); + return json({ authority: "synthetic-http-not-real-backend", requests }); + } + const match = url.pathname.match( + /^\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/permissions$/, + ); + if (!match) return null; + const [, workspace, id] = match.map(decodeURIComponent); + if (workspace !== workspaceId || !policies.has(id)) return error("Subject unavailable", 404); + if (id === "permissions-unavailable") return error("Permission service unavailable", 503); + const current = policies.get(id)!; + if (request.method === "GET") { + return json({ + permissions: current, + can_manage: id !== "permissions-reader", + operations: fixturePermissionOperations, + }); + } + if (request.method !== "POST") return error("POST required", 405); + if (id === "permissions-reader") return error("Actor cannot manage permissions", 403); + const payload: unknown = await request.json().catch(() => null); + if (!isUpdate(payload)) return error("Invalid synthetic policy update", 400); + const input = JSON.stringify(payload); + const receiptKey = JSON.stringify([id, payload.operation_id]); + const previous = receipts.get(receiptKey); + if (previous) { + requests.push({ subject_id: id, request: payload, outcome: "replay" }); + if (previous.input !== input) { + return error("Operation identity reused with changed input", 409); + } + return json({ permissions: previous.permissions, interruptions: [] }); + } + if (payload.expected_change_id !== (current?.change_id ?? null)) { + requests.push({ subject_id: id, request: payload, outcome: "conflict" }); + return error("Policy changed in another browser; reload and review", 409); + } + // Reject only the combinations used by this fixture. This is NOT Backend validation evidence. + for (const p of [...payload.execution, ...payload.delegation]) { + const legacy = JSON.stringify(p) === JSON.stringify(fixtureLegacyPermission) && + id === "permissions-legacy"; + const supported = fixturePermissionOperations.some((o) => + o.availability.kind === "available" && o.operation === p.operation && + o.resource_kinds.includes(p.resource.kind) + ); + if (!legacy && !supported) { + return error("Unknown operation or invalid resource combination", 400); + } + if ( + p.resource.kind === "ticket" && + (p.resource.workspace_id !== workspaceId || !p.resource.ticket_id) + ) { + return error("Invalid ticket target", 400); + } + } + const saved: FixturePolicy = { + change_id: `fixture-change-${++issuedChangeCount}`, + execution: structuredClone(payload.execution), + delegation: structuredClone(payload.delegation), + }; + policies.set(id, saved); + receipts.set(receiptKey, { input, permissions: structuredClone(saved) }); + requests.push({ subject_id: id, request: payload, outcome: "saved" }); + return json({ permissions: saved, interruptions: [] }); + }; +} diff --git a/tools/web-ux/browser-tests/subject_permissions_review.ts b/tools/web-ux/browser-tests/subject_permissions_review.ts new file mode 100644 index 000000000..74d678869 --- /dev/null +++ b/tools/web-ux/browser-tests/subject_permissions_review.ts @@ -0,0 +1,477 @@ +#!/usr/bin/env -S deno run -A +/** Actual built Web + synthetic HTTP. No real Backend auth/Worker success claim. */ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join, resolve } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { sha256File } from "../src/artifacts.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts"; + +const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); +const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build")); +const runId = Deno.args[1] ?? `after-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`; +if (!/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("run id must be a safe filename"); +// Fail fast; the parent owns building Web. Never replace it with a synthetic shell. +for (const name of ["index.html", "_app/immutable"]) { + await Deno.stat(join(buildRoot, name)).catch(() => { + throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`); + }); +} +const output = join(root, "target/web-ux/t-802", runId); +await Deno.mkdir(dirname(output), { recursive: true }); +await Deno.mkdir(output); // A repeated run ID must not overwrite prior evidence. +const sourceCommit = await new Deno.Command("git", { + args: ["rev-parse", "HEAD"], + cwd: root, + stdout: "piped", +}).output(); +const sourceStatus = await new Deno.Command("git", { + args: ["status", "--porcelain"], + cwd: root, + stdout: "piped", +}).output(); +const manifest: { + evidence: string; + buildRoot: string; + buildIndexSha256: string; + visualVerdict: string; + status: string; + captures: unknown[]; + failures: string[]; + source: { commitSha: string | null; dirty: boolean | null }; + browser: { name: string; version: string } | null; + expectedFaults: string[]; +} = { + evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend", + buildRoot, + buildIndexSha256: await sha256File(join(buildRoot, "index.html")), + visualVerdict: "UNREVIEWED — parent must ViewImage and inspect diagnostics before pass", + status: "running", + captures: [], + failures: [], + source: { + commitSha: sourceCommit.success ? new TextDecoder().decode(sourceCommit.stdout).trim() : null, + dirty: sourceStatus.success + ? new TextDecoder().decode(sourceStatus.stdout).trim().length > 0 + : null, + }, + browser: null, + expectedFaults: [ + "One intentionally aborted POST response after synthetic persistence per width/theme", + "HTTP 409 for stale two-page policy save", + "HTTP 503 for permissions-unavailable GET", + ], +}; +const browser = await chromium.launch({ headless: true }); +manifest.browser = { name: "chromium", version: browser.version() }; +type BrowserDiagnostic = { + kind: string; + message: string; + url?: string; + method?: string; + resourceType?: string; + pageUrl?: string; +}; +const diagnostics = new WeakMap(); +const documents = new WeakMap(); +const errorCounts = new WeakMap(); +function observe(page: Page) { + const errors: BrowserDiagnostic[] = []; + diagnostics.set(page, errors); + const add = (e: typeof errors[number]) => { + errorCounts.set(page, (errorCounts.get(page) ?? 0) + 1); + if (errors.length < 100) errors.push(e); + }; + page.on("pageerror", (e) => add({ kind: "page", message: e.stack || e.message || String(e) })); + page.on("console", (e) => { + if (e.type() === "error" || e.type() === "warning") add({ kind: "console", message: e.text() }); + }); + page.on( + "requestfailed", + (r) => + add({ + kind: "request", + message: r.failure()?.errorText ?? "request failed", + url: r.url(), + method: r.method(), + resourceType: r.resourceType(), + pageUrl: page.url(), + }), + ); + page.on("response", (r) => { + if (r.request().isNavigationRequest()) documents.set(page, r.status()); + if (r.status() >= 400) { + add({ + kind: "http", + message: `HTTP ${r.status()}`, + url: r.url(), + method: r.request().method(), + resourceType: r.request().resourceType(), + pageUrl: page.url(), + }); + } + }); +} +async function capturePoint(page: Page, id: string, expectedErrors: string[] = []) { + // Show the decision being captured, without changing product CSS or layout. + if (id.includes("preview")) await page.locator(".diff").scrollIntoViewIfNeeded(); + else if (/saved|re-read|unknown|recovered|conflict/.test(id)) { + await page.locator(".permissions > header").scrollIntoViewIfNeeded(); + } + // Always retain the image first, even when an overflow assertion will fail. + const image = join(output, `${id}.png`); + const snapshot = join(output, `${id}.aria.txt`); + await page.screenshot({ path: image }); + await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot()); + const mobileRemoveButtons = page.viewportSize()?.width === 390 + ? await page.locator(".lane button").evaluateAll((buttons) => + buttons.map((button) => { + const style = button.ownerDocument.defaultView!.getComputedStyle(button); + const bounds = button.getBoundingClientRect(); + const lane = button.closest(".lane")!.getBoundingClientRect(); + const range = button.ownerDocument.createRange(); + range.selectNodeContents(button); + const textBounds = range.getBoundingClientRect(); + return { + label: button.getAttribute("aria-label"), + text: button.textContent?.trim(), + whiteSpace: style.whiteSpace, + flexShrink: style.flexShrink, + textLineBoxes: Array.from(range.getClientRects()).filter((rect) => rect.width > 0).length, + textFitsButton: textBounds.left >= + bounds.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) - 1 && + textBounds.right <= + bounds.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) + + 1 && + textBounds.top >= bounds.top && textBounds.bottom <= bounds.bottom, + buttonFitsLane: bounds.left >= lane.left - 1 && bounds.right <= lane.right + 1, + noInternalClipping: button.scrollWidth <= button.clientWidth && + button.scrollHeight <= button.clientHeight, + width: bounds.width, + textWidth: textBounds.width, + }; + }) + ) + : []; + manifest.captures.push({ + id, + route: new URL(page.url()).pathname, + viewport: page.viewportSize(), + theme: await page.evaluate(() => + (globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" + ), + documentStatus: documents.get(page) ?? null, + visibleAlerts: await page.getByRole("alert").allTextContents(), + permissionState: await page.locator("[data-permission-state]").getAttribute( + "data-permission-state", + ), + image, + imageSha256: await sha256File(image), + accessibilitySnapshot: snapshot, + expectedErrors, + // Per-page errors are cumulative so reload does not erase recovery evidence. + errors: [...diagnostics.get(page) ?? []], + errorSummary: { + observed: errorCounts.get(page) ?? 0, + retained: diagnostics.get(page)?.length ?? 0, + truncated: (errorCounts.get(page) ?? 0) > 100, + limit: 100, + }, + mobileRemoveButtons, + reviewed: false, + }); + await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2)); + for (const button of mobileRemoveButtons) { + assertEquals(button.text, "Remove", `${id}: expected Remove control`); + assertEquals(button.whiteSpace, "nowrap", `${id}: ${button.label} can wrap`); + assertEquals(button.flexShrink, "0", `${id}: ${button.label} can shrink`); + assertEquals(button.textLineBoxes, 1, `${id}: ${button.label} text breaks across lines`); + assert( + button.textFitsButton && button.buttonFitsLane && button.noInternalClipping, + `${id}: ${button.label} clips or exceeds its lane`, + ); + } + await assertNoPageWideOverflow(page); +} +async function open(page: Page, base: string, id: string, state = "ready") { + await page.goto(`${base}/w/memory-review/memory/${id}/permissions`); + await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor(); +} +async function add( + page: Page, + lane: "execution" | "delegation", + operation: string, + target: string, + resourceCaptureId?: string, +) { + await page.getByRole("combobox", { name: "Permission scope", exact: true }).selectOption(lane); + await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption(operation); + // Exercise the actual name/description/resource input UI, not internal JSON entry. + await page.getByText( + operation === "ticket.read" + ? "Read the selected ticket. Does not allow editing or closing it." + : "Append a comment to the selected ticket; no completion or assignment authority.", + { exact: true }, + ).waitFor(); + await page.getByLabel("Ticket identifier", { exact: true }).fill(target); + if (resourceCaptureId) { + await page.getByLabel("Ticket identifier", { exact: true }).focus(); + await page.keyboard.press("Tab"); + assertEquals( + await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).evaluate(( + element, + ) => element === element.ownerDocument.activeElement), + true, + ); + await capturePoint(page, resourceCaptureId); + } + await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).click(); + await page.locator("[data-permission-state=unsaved]").waitFor(); +} +async function save(page: Page, state = "saved") { + await page.getByLabel("I reviewed the operation, resource and delegation differences.").check(); + await page.getByRole("button", { name: "Save permissions", exact: true }).click(); + await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor(); +} +try { + for (const theme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const prefix = `${theme}-${width}`; + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + const owned = await startOwnedProcesses( + [{ + id: `t-802-${prefix}-synthetic-http`, + command: Deno.execPath(), + cwd: join(root, "tools/web-ux"), + args: [ + "run", + "-A", + join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"), + String(port), + buildRoot, + "3", + "permissions", + ], + readyUrl: `${base}/health`, + }], + join(output, "scenario.json"), + join(output, `logs-${prefix}`), + [], + ); + const context = await browser.newContext({ + viewport: { width, height: width === 390 ? 844 : 900 }, + colorScheme: theme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + observe(page); + // Enter through the ordinary Subject detail navigation. + await page.goto(`${base}/w/memory-review/memory/permissions-empty`); + await page.locator(".subject-overview").waitFor(); + await page.locator('main a[href$="/permissions"]').click(); + await page.locator("[data-permission-state=ready]").waitFor(); + await page.getByText("Not configured · no permissions", { exact: true }).waitFor(); + await capturePoint(page, `${prefix}-empty`); + await add( + page, + "execution", + "ticket.read", + "T-802", + `${prefix}-operation-description-resource-input`, + ); + await add(page, "delegation", "ticket.comment", "T-802"); + // Use named lane scopes so one grant cannot silently appear in both lanes. + assertEquals( + await page.locator('.lane[aria-label="Direct operations"]').getByText("Read ticket", { + exact: true, + }).count(), + 1, + ); + assertEquals( + await page.locator('.lane[aria-label="Direct operations"]').getByText( + "Comment on ticket", + { exact: true }, + ).count(), + 0, + ); + assertEquals( + await page.locator('.lane[aria-label="Background Worker delegation ceiling"]').getByText( + "Comment on ticket", + { exact: true }, + ).count(), + 1, + ); + await capturePoint(page, `${prefix}-preview-separate-lanes`); + await save(page); + await capturePoint(page, `${prefix}-saved`); + await page.reload(); + await page.locator("[data-permission-state=ready]").waitFor(); + await capturePoint(page, `${prefix}-re-read`); + + // Real browser transport loss AFTER the synthetic HTTP fixture accepts the POST. + await add(page, "execution", "ticket.comment", "T-803"); + let lost = false; + await page.route("**/subjektiv/subjects/permissions-empty/permissions", async (route) => { + if (route.request().method() === "POST" && !lost) { + const accepted = await route.fetch(); + assertEquals(accepted.status(), 200); + lost = true; + await route.abort("failed"); + } else await route.continue(); + }); + await save(page, "unknown"); + assert(lost); + // Fault injection is one-shot; restore ordinary browser networking before evidence capture. + await page.unroute("**/subjektiv/subjects/permissions-empty/permissions"); + await capturePoint(page, `${prefix}-unknown`, [ + "Intentional POST response loss after synthetic save", + ]); + await page.reload(); + await page.locator("[data-permission-state=unknown][aria-busy=false]").waitFor(); + await page.getByRole("button", { name: "Reconcile the same save", exact: true }).click(); + await page.locator("[data-permission-state=saved]").waitFor(); + const evidence = await (await fetch(`${base}/fixture/permissions/evidence`)).json(); + const saves = evidence.requests.filter((r: { subject_id: string }) => + r.subject_id === "permissions-empty" + ); + assertEquals(saves.length, 3); + assertEquals(saves[1].request, saves[2].request); + assertEquals(saves[2].outcome, "replay"); + await capturePoint(page, `${prefix}-recovered-same-operation`, [ + "Intentional earlier POST response loss", + ]); + + // Two browser pages observe the same synthetic change identity before either save. + const other = await context.newPage(); + observe(other); + await open(page, base, "permissions-edit"); + await open(other, base, "permissions-edit"); + await add(page, "delegation", "ticket.comment", "T-802"); + await other.getByRole("button", { + name: "Remove Read ticket from direct operations", + exact: true, + }).click(); + await save(page); + await save(other, "error"); + await other.getByRole("alert").filter({ hasText: "Conflict:" }).waitFor(); + await capturePoint(other, `${prefix}-two-page-conflict`, [ + "Intentional stale policy POST returns HTTP 409", + ]); + await other.close(); + await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption( + "worker.messaging", + ); + await page.getByText("Target unavailable:", { exact: false }).waitFor(); + assertEquals( + await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).count(), + 0, + ); + await capturePoint(page, `${prefix}-unavailable-operation`); + await open(page, base, "permissions-reader"); + assertEquals(await page.locator("form.permission-form").count(), 0); + assertEquals( + await page.getByRole("button", { name: "Save permissions", exact: true }).count(), + 0, + ); + await capturePoint(page, `${prefix}-read-only`); + await open(page, base, "permissions-unavailable", "error"); + await capturePoint(page, `${prefix}-service-unavailable`, [ + "Intentional permission GET returns HTTP 503", + ]); + await open(page, base, "permissions-legacy"); + await page.getByText( + "Preserved, not editable here. No conversion or expansion is implied.", + { exact: true }, + ).waitFor(); + await add(page, "execution", "ticket.comment", "T-802"); + await save(page); + const legacy = await (await fetch( + `${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`, + )).json(); + assertEquals(legacy.permissions.delegation, [{ + operation: "worker.notify", + resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" }, + }]); + await capturePoint(page, `${prefix}-legacy-preserved`, [ + "Intentional earlier service unavailable GET", + ]); + // Revoke a supported direct permission; this fixture proves UI intent, not real cancellation. + await page.getByRole("button", { + name: "Remove Read ticket from direct operations", + exact: true, + }).click(); + await save(page); + await capturePoint(page, `${prefix}-revoked-saved`); + const finalAudit = await (await fetch(`${base}/fixture/permissions/evidence`)).json(); + const finalLegacy = await (await fetch( + `${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`, + )).json(); + assertEquals(finalAudit.requests.length, 7); + assertEquals( + finalAudit.requests.filter((r: { outcome: string }) => r.outcome === "conflict").length, + 1, + ); + assertEquals(finalLegacy.permissions.execution, [{ + operation: "ticket.comment", + resource: { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" }, + }]); + assertEquals(finalLegacy.permissions.delegation, legacy.permissions.delegation); + await Deno.writeTextFile( + join(output, `${prefix}-synthetic-http-evidence.json`), + JSON.stringify( + { + authority: "synthetic-http-not-real-backend", + replayAssertions: { + originalAndRetryInputsEqual: true, + retryOutcome: saves[2].outcome, + }, + legacyDelegationPreserved: legacy.permissions.delegation, + requests: finalAudit.requests, + currentLegacyPolicyAfterShrink: finalLegacy, + }, + null, + 2, + ), + ); + } catch (e) { + manifest.failures.push( + `${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`, + ); + for (const [index, failedPage] of context.pages().entries()) { + const image = join(output, `${prefix}-failure-${index}.png`); + const snapshot = join(output, `${prefix}-failure-${index}.aria.txt`); + await failedPage.screenshot({ path: image, timeout: 5_000 }).catch(() => {}); + await Deno.writeTextFile(snapshot, await failedPage.locator("body").ariaSnapshot()); + manifest.captures.push({ + id: `${prefix}-failure-${index}`, + route: failedPage.url(), + documentStatus: documents.get(failedPage) ?? null, + image, + accessibilitySnapshot: snapshot, + errors: diagnostics.get(failedPage) ?? [], + reviewed: false, + }); + } + throw e; + } finally { + await context.close(); + await stopOwnedProcesses(owned); + } + } + } + assertEquals( + await sha256File(join(buildRoot, "index.html")), + manifest.buildIndexSha256, + "Web build changed during the browser matrix", + ); + manifest.status = "browser-assertions-passed-visual-unreviewed"; +} catch (e) { + manifest.status = "failed"; + throw e; +} finally { + await browser.close(); + await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2)); + console.log(join(output, "review-context.json")); +} diff --git a/tools/web-ux/scenarios/subject-permissions-dark.json b/tools/web-ux/scenarios/subject-permissions-dark.json new file mode 100644 index 000000000..df9570f29 --- /dev/null +++ b/tools/web-ux/scenarios/subject-permissions-dark.json @@ -0,0 +1,138 @@ +{ + "schemaVersion": 1, + "id": "subject-permissions-dark", + "title": "T-802 actual Web / synthetic HTTP NOT real Backend — dark", + "baseUrl": "http://127.0.0.1:15289", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection, NOT real Backend authentication or authorization", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "permissions-empty", + "label": "Unset permissions", + "path": "/w/memory-review/memory/permissions-empty/permissions", + "goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unset permissions" + } + ] + }, + { + "id": "permissions-edit", + "label": "Configured permissions", + "path": "/w/memory-review/memory/permissions-edit/permissions", + "goal": "Read exact operation and target before editing.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Configured permissions" + } + ] + }, + { + "id": "permissions-legacy", + "label": "Preserved unsupported permission", + "path": "/w/memory-review/memory/permissions-legacy/permissions", + "goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Preserved unsupported permission" + } + ] + }, + { + "id": "permissions-reader", + "label": "Read-only permissions", + "path": "/w/memory-review/memory/permissions-reader/permissions", + "goal": "Read policy without unauthorized editing controls.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only permissions" + } + ] + }, + { + "id": "permissions-unavailable", + "label": "Unavailable permission service", + "path": "/w/memory-review/memory/permissions-unavailable/permissions", + "goal": "Recover without success claims.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=error][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unavailable permission service" + } + ] + } + ], + "processes": [ + { + "id": "subject-permissions-synthetic-http", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "-A", + "browser-tests/memory_document_fixture_server.ts", + "15289", + "${WEB_UX_BUILD_ROOT}", + "3", + "permissions" + ], + "readyUrl": "http://127.0.0.1:15289/health" + } + ] +} diff --git a/tools/web-ux/scenarios/subject-permissions-light.json b/tools/web-ux/scenarios/subject-permissions-light.json new file mode 100644 index 000000000..56146c71a --- /dev/null +++ b/tools/web-ux/scenarios/subject-permissions-light.json @@ -0,0 +1,138 @@ +{ + "schemaVersion": 1, + "id": "subject-permissions-light", + "title": "T-802 actual Web / synthetic HTTP NOT real Backend — light", + "baseUrl": "http://127.0.0.1:15289", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection, NOT real Backend authentication or authorization", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "permissions-empty", + "label": "Unset permissions", + "path": "/w/memory-review/memory/permissions-empty/permissions", + "goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unset permissions" + } + ] + }, + { + "id": "permissions-edit", + "label": "Configured permissions", + "path": "/w/memory-review/memory/permissions-edit/permissions", + "goal": "Read exact operation and target before editing.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Configured permissions" + } + ] + }, + { + "id": "permissions-legacy", + "label": "Preserved unsupported permission", + "path": "/w/memory-review/memory/permissions-legacy/permissions", + "goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Preserved unsupported permission" + } + ] + }, + { + "id": "permissions-reader", + "label": "Read-only permissions", + "path": "/w/memory-review/memory/permissions-reader/permissions", + "goal": "Read policy without unauthorized editing controls.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=ready][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only permissions" + } + ] + }, + { + "id": "permissions-unavailable", + "label": "Unavailable permission service", + "path": "/w/memory-review/memory/permissions-unavailable/permissions", + "goal": "Recover without success claims.", + "dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.", + "ready": { + "kind": "selector", + "selector": "[data-permission-state=error][aria-busy=false]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unavailable permission service" + } + ] + } + ], + "processes": [ + { + "id": "subject-permissions-synthetic-http", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "-A", + "browser-tests/memory_document_fixture_server.ts", + "15289", + "${WEB_UX_BUILD_ROOT}", + "3", + "permissions" + ], + "readyUrl": "http://127.0.0.1:15289/health" + } + ] +} diff --git a/tools/web-ux/tests/subject_permissions_fixture_test.ts b/tools/web-ux/tests/subject_permissions_fixture_test.ts new file mode 100644 index 000000000..3f8ee851f --- /dev/null +++ b/tools/web-ux/tests/subject_permissions_fixture_test.ts @@ -0,0 +1,129 @@ +import { assert, assertEquals } from "@std/assert"; +import { + createSubjectPermissionsFixture, + fixtureLegacyPermission, + type FixturePolicy, +} from "../browser-tests/subject_permissions_fixture.ts"; + +const root = "http://127.0.0.1:12345/api/w/memory-review/subjektiv/subjects/"; +const target = { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" }; +const direct = { operation: "ticket.read", resource: target }; +const delegated = { operation: "ticket.comment", resource: target }; +function request(id: string, body?: unknown): Request { + return new Request( + `${root}${id}/permissions`, + body === undefined ? {} : { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); +} + +Deno.test("synthetic browser admission projection is explicitly labeled, with no real credentials", async () => { + const handle = createSubjectPermissionsFixture(); + const response = await handle(new Request("http://127.0.0.1:12345/api/auth/whoami")); + assert(response); + assertEquals(response.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend"); + assertEquals(response.headers.get("set-cookie"), null); + const view = await response.json(); + assertEquals(view.authentication_reference, "synthetic-http-not-real-authentication"); + assertEquals(view.session.kind, "authenticated"); + assertEquals(view.actor.auth_method, "browser_session"); +}); + +Deno.test("synthetic UX fixture: unset, separate lanes, exact replay and two-editor CAS", async () => { + const handle = createSubjectPermissionsFixture(); + const initial = await handle(request("permissions-empty")); + assert(initial); + assertEquals(initial.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend"); + assertEquals((await initial.json()).permissions, null); + const update = { + operation_id: "browser-save-1", + expected_change_id: null, + execution: [direct], + delegation: [delegated], + }; + const save = await handle(request("permissions-empty", update)); + assert(save); + assertEquals(save.status, 200); + const saved = await save.json(); + assertEquals(saved.permissions.execution, [direct]); + assertEquals(saved.permissions.delegation, [delegated]); + const replay = await handle(request("permissions-empty", update)); + assert(replay); + assertEquals(await replay.json(), saved); + const collision = await handle(request("permissions-empty", { ...update, execution: [] })); + assertEquals(collision?.status, 409); + const stale = await handle( + request("permissions-empty", { ...update, operation_id: "second-tab" }), + ); + assertEquals(stale?.status, 409); + const readback = await handle(request("permissions-empty")); + assertEquals((await readback!.json()).permissions, saved.permissions); +}); + +Deno.test("synthetic UX fixture: read-only, unavailable, wrong workspace and unknown targets", async () => { + const handle = createSubjectPermissionsFixture(); + const view = await handle(request("permissions-reader")); + assertEquals((await view!.json()).can_manage, false); + assertEquals((await handle(request("permissions-reader", {})))?.status, 403); + assertEquals((await handle(request("permissions-unavailable")))?.status, 503); + assertEquals((await handle(request("missing")))?.status, 404); + assertEquals( + (await handle( + new Request(`${root.replace("memory-review", "other")}permissions-edit/permissions`), + ))?.status, + 404, + ); + assertEquals(await handle(new Request("http://127.0.0.1:12345/not-owned")), null); +}); + +Deno.test("synthetic UX fixture preserves old Worker target without making T-803 usable", async () => { + const handle = createSubjectPermissionsFixture(); + const view = await handle(request("permissions-legacy")); + const initial = await view!.json(); + assertEquals(initial.permissions.delegation, [fixtureLegacyPermission]); + const workerOperation = initial.operations.find((o: { operation: string }) => + o.operation === "worker.messaging" + ); + assertEquals(workerOperation.availability.kind, "unavailable"); + assertEquals(workerOperation.resource_kinds, []); + assertEquals(Object.hasOwn(workerOperation, "available"), false); + assertEquals(Object.hasOwn(workerOperation, "unavailable_reason"), false); + assertEquals( + initial.operations.find((o: { operation: string }) => o.operation === "ticket.read") + .availability, + { kind: "available" }, + ); + const update = { + operation_id: "preserve-legacy", + expected_change_id: initial.permissions.change_id, + execution: [direct, delegated], + delegation: initial.permissions.delegation, + }; + const saved = await handle(request("permissions-legacy", update)); + assertEquals(saved?.status, 200); + const value = await saved!.json(); + assertEquals(value.permissions.delegation, [fixtureLegacyPermission]); + const policy = value.permissions as FixturePolicy; + for ( + const invalid of [ + { operation: "future.all", resource: target }, + { operation: "ticket.read", resource: { kind: "workspace", workspace_id: "memory-review" } }, + { + operation: "worker.messaging", + resource: { kind: "workspace", workspace_id: "memory-review" }, + }, + ] + ) { + const rejected = await handle(request("permissions-legacy", { + ...update, + operation_id: `reject-${invalid.operation}`, + expected_change_id: policy.change_id, + execution: [invalid], + })); + assertEquals(rejected?.status, 400); + } + assertEquals((await (await handle(request("permissions-legacy")))!.json()).permissions, policy); +}); diff --git a/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte b/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte index e566c97fd..a35e3ccae 100644 --- a/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte +++ b/web/workspace/src/lib/workspace/memory/SubjectPermissions.svelte @@ -167,16 +167,16 @@ + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index 6d71ec0f2..7e8ed9b45 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -44,6 +44,7 @@ function subject() { function subjectIndexData(items = [subject()]) { return { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", cursor: null, subjects: result({ @@ -57,6 +58,7 @@ function subjectIndexData(items = [subject()]) { test("renders the subject index as the Memory product entry", () => { render(SubjectIndexPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", cursor: "subject-current", subjects: result({ @@ -239,6 +241,7 @@ test("warns about an unknown create outcome without replaying the POST", async ( test("renders a ready resident surface and committed Memory lifecycle states", async () => { render(SubjectPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", cursor: "memory-current", @@ -333,6 +336,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save vi.stubGlobal("fetch", fetchMock); render(SubjectPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", subject: result(subject()), @@ -386,6 +390,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save function subjectPageData(record = subject(), workspaceId = "workspace-1") { return { + workspace: { permissions: { delete_subjects: false } }, workspaceId, subjectId: record.id, subject: result(record), @@ -663,6 +668,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid // the same empty committed list as a new Subject; candidates are not a page input. render(SubjectPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", cursor: null, @@ -746,6 +752,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)( test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { const base = { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", subject: result(subject()), @@ -831,6 +838,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s test("renders committed Memory detail, provenance, derivation, and immutable changes", async () => { render(MemoryDetailPage, { data: { + workspace: { permissions: { delete_subjects: false } }, workspaceId: "workspace-1", subjectId: "subject-1", memoryId: "memory-1",