test: exercise Subject permission management in Chromium
This commit is contained in:
@@ -11,3 +11,5 @@
|
|||||||
- 認証状態と成果物を分離し、scenario・引数・成果物に秘密の値を含めない。
|
- 認証状態と成果物を分離し、scenario・引数・成果物に秘密の値を含めない。
|
||||||
- PlaywrightとNixで固定するbrowserの対応を、toolchain変更として同時に検証する。
|
- PlaywrightとNixで固定するbrowserの対応を、toolchain変更として同時に検証する。
|
||||||
- 変更時は`deno.json`のcheck・testと必要なbrowser testを選ぶ。scenario変更では実際の画像と`review-context.json`を確認する。browser testの成功を目視の代わりにしない。
|
- 変更時は`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制御の証拠ではない。画像と診断の目視確認も別途必要である。
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||||
import { journalBrowserFixture } from "./journal_browser_fixture.ts";
|
import { journalBrowserFixture } from "./journal_browser_fixture.ts";
|
||||||
|
import {
|
||||||
|
createSubjectPermissionsFixture,
|
||||||
|
permissionFixtureSubjects,
|
||||||
|
} from "./subject_permissions_fixture.ts";
|
||||||
|
|
||||||
const port = Number(Deno.args[0]);
|
const port = Number(Deno.args[0]);
|
||||||
const buildRoot = Deno.args[1];
|
const buildRoot = Deno.args[1];
|
||||||
@@ -11,6 +15,15 @@ if (
|
|||||||
throw new Error("usage: server.ts <port> <build-root> [section-count: 3..100]");
|
throw new Error("usage: server.ts <port> <build-root> [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 workspaceId = "memory-review";
|
||||||
const representativeSubjectId =
|
const representativeSubjectId =
|
||||||
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review";
|
"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(ungeneratedSubjectId, "Ungenerated subject", "active", "fingerprint-0"),
|
||||||
subject(errorSubjectId, "Unavailable subject", "retired", "fingerprint-3"),
|
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");
|
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", "fingerprint-1");
|
||||||
|
|
||||||
function connectedWorker(displayName: string) {
|
function connectedWorker(displayName: string) {
|
||||||
@@ -132,7 +150,13 @@ function memoriesFor(subjectId: string) {
|
|||||||
if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return [];
|
if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return [];
|
||||||
if (subjectId === staleSubjectId) {
|
if (subjectId === staleSubjectId) {
|
||||||
return [
|
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) {
|
if (subjectId === failedSubjectId) {
|
||||||
@@ -227,7 +251,12 @@ function surfaceFor(subjectId: string) {
|
|||||||
function memoryDetail(memoryId: string, requestedChangeId: string | null) {
|
function memoryDetail(memoryId: string, requestedChangeId: string | null) {
|
||||||
const currentChangeId = memoryId === derivedMemoryId ? "change-parent" : "change-current";
|
const currentChangeId = memoryId === derivedMemoryId ? "change-parent" : "change-current";
|
||||||
const changeId = requestedChangeId ?? currentChangeId;
|
const changeId = requestedChangeId ?? currentChangeId;
|
||||||
const states: Record<string, string> = { "change-original": "retracted", "change-earlier": "resolved", "change-current": "active", "change-parent": "active" };
|
const states: Record<string, string> = {
|
||||||
|
"change-original": "retracted",
|
||||||
|
"change-earlier": "resolved",
|
||||||
|
"change-current": "active",
|
||||||
|
"change-parent": "active",
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
memory_id: memoryId,
|
memory_id: memoryId,
|
||||||
change_id: changeId,
|
change_id: changeId,
|
||||||
@@ -361,6 +390,10 @@ async function staticResponse(pathname: string): Promise<Response> {
|
|||||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname === "/health") return new Response("ok");
|
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") {
|
if (url.pathname === "/fixture/subject-create-requests") {
|
||||||
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
|
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_secrets: false,
|
||||||
manage_runtimes: false,
|
manage_runtimes: false,
|
||||||
delete_workspace: false,
|
delete_workspace: false,
|
||||||
|
delete_subjects: false,
|
||||||
},
|
},
|
||||||
extension_points: {
|
extension_points: {
|
||||||
store: "fixture",
|
store: "fixture",
|
||||||
@@ -409,7 +443,13 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||||
}
|
}
|
||||||
if (url.pathname === `/api/w/${workspaceId}/workers`) {
|
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`) {
|
if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) {
|
||||||
return json({
|
return json({
|
||||||
@@ -521,7 +561,15 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
const paginated = subjectId === representativeSubjectId;
|
const paginated = subjectId === representativeSubjectId;
|
||||||
const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor;
|
const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor;
|
||||||
const items = continued
|
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);
|
: memoriesFor(subjectId);
|
||||||
return json({
|
return json({
|
||||||
items,
|
items,
|
||||||
@@ -536,7 +584,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
}
|
}
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
const requested = url.searchParams.get("change_id");
|
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);
|
if (requested !== null && !knownChanges.includes(requested)) return json({}, 404);
|
||||||
return json(memoryDetail(memoryId, requested));
|
return json(memoryDetail(memoryId, requested));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string | boolean> & { kind: string };
|
||||||
|
};
|
||||||
|
export type FixturePolicy = {
|
||||||
|
change_id: string;
|
||||||
|
execution: FixturePermission[];
|
||||||
|
delegation: FixturePermission[];
|
||||||
|
};
|
||||||
|
type FixtureUpdate = Omit<FixturePolicy, "change_id"> & {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, FixturePolicy | null>();
|
||||||
|
const receipts = new Map<string, { input: string; permissions: FixturePolicy }>();
|
||||||
|
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<Response | null> => {
|
||||||
|
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: [] });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Page, BrowserDiagnostic[]>();
|
||||||
|
const documents = new WeakMap<Page, number>();
|
||||||
|
const errorCounts = new WeakMap<Page, number>();
|
||||||
|
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"));
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -167,16 +167,16 @@
|
|||||||
<style>
|
<style>
|
||||||
.permissions { max-width: 72rem; min-width: 0; }
|
.permissions { max-width: 72rem; min-width: 0; }
|
||||||
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem; }
|
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem; }
|
||||||
h2 { margin: 0; font-size: 1.2rem; } h3 { font-size: 1rem; margin: 0 0 .5rem; } h4 { margin: 1rem 0 .4rem; }
|
h2 { margin: 0; font-size: var(--font-size-body); } h3 { font-size: var(--font-size-body); margin: 0 0 .5rem; } h4 { margin: 1rem 0 .4rem; }
|
||||||
p { line-height: 1.55; color: var(--text-muted); } .state { font-size: .8rem; }
|
p { line-height: 1.55; color: var(--text-muted); } .state { font-size: var(--font-size-compact); }
|
||||||
.lanes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; margin: 1.5rem 0; }
|
.lanes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; margin: 1.5rem 0; }
|
||||||
.lane { min-width: 0; border-top: 1px solid var(--line); padding-top: 1rem; }
|
.lane { min-width: 0; border-top: 1px solid var(--line); padding-top: 1rem; }
|
||||||
ul { list-style: none; padding: 0; margin: .75rem 0; } li { display: flex; align-items: start; gap: .75rem; justify-content: space-between; padding: .75rem 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
ul { list-style: none; padding: 0; margin: .75rem 0; } li { display: flex; align-items: start; gap: .75rem; justify-content: space-between; padding: .75rem 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||||
li > div { min-width: 0; } .resource, .legacy { display: block; margin-top: .35rem; font-size: .85rem; } .legacy { color: var(--text-muted); }
|
li > div { min-width: 0; } li button { flex-shrink: 0; white-space: nowrap; } .resource, .legacy { display: block; margin-top: .35rem; font-size: var(--font-size-body); } .legacy { color: var(--text-muted); }
|
||||||
.permission-form, .diff { border-top: 1px solid var(--line); margin-top: 1.5rem; padding-top: 1rem; }
|
.permission-form, .diff { border-top: 1px solid var(--line); margin-top: 1.5rem; padding-top: 1rem; }
|
||||||
fieldset { border: 0; padding: 0; min-width: 0; display: grid; gap: .75rem; max-width: 38rem; }
|
fieldset { border: 0; padding: 0; min-width: 0; display: grid; gap: .75rem; max-width: 38rem; }
|
||||||
label { display: grid; gap: .35rem; font-size: .875rem; } input, select { width: 100%; min-width: 0; padding: .55rem; border: 1px solid var(--line); border-radius: 3px; background: var(--bg); color: var(--text); font: inherit; }
|
label { display: grid; gap: .35rem; font-size: var(--font-size-body); } input, select { width: 100%; min-width: 0; padding: .55rem; border: 1px solid var(--line); border-radius: 3px; background: var(--bg); color: var(--text); font: inherit; }
|
||||||
button { background: transparent; color: var(--text); border: 1px solid var(--line); padding: .45rem .7rem; border-radius: 3px; cursor: pointer; font: inherit; font-size: .85rem; } button:disabled { opacity: .5; cursor: default; } button.save { background: var(--text); color: var(--bg); margin-top: .75rem; }
|
button { background: transparent; color: var(--text); border: 1px solid var(--line); padding: .45rem .7rem; border-radius: 3px; cursor: pointer; font: inherit; font-size: var(--font-size-body); } button:disabled { opacity: .5; cursor: default; } button.save { background: var(--text); color: var(--bg); margin-top: .75rem; }
|
||||||
.check { display: flex; align-items: start; gap: .5rem; } .check input { width: auto; margin-top: .2rem; } .reload { margin: 1rem 0; }
|
.check { display: flex; align-items: start; gap: .5rem; } .check input { width: auto; margin-top: .2rem; } .reload { margin: 1rem 0; }
|
||||||
.attention { border-left: 3px solid var(--text-muted); padding: .7rem 1rem; overflow-wrap: anywhere; } .help { margin: 1.5rem 0; } summary { cursor: pointer; } code { overflow-wrap: anywhere; }
|
.attention { border-left: 3px solid var(--text-muted); padding: .7rem 1rem; overflow-wrap: anywhere; } .help { margin: 1.5rem 0; } summary { cursor: pointer; } code { overflow-wrap: anywhere; }
|
||||||
@media (max-width: 900px) { .lanes { grid-template-columns: 1fr; gap: 1rem; } }
|
@media (max-width: 900px) { .lanes { grid-template-columns: 1fr; gap: 1rem; } }
|
||||||
|
|||||||
+1
-1
@@ -13,4 +13,4 @@
|
|||||||
{#key JSON.stringify([data.workspaceId, data.subjectId])}<SubjectPermissions workspaceId={data.workspaceId} subjectId={data.subjectId} />{/key}
|
{#key JSON.stringify([data.workspaceId, data.subjectId])}<SubjectPermissions workspaceId={data.workspaceId} subjectId={data.subjectId} />{/key}
|
||||||
{:else}<p role="alert">Target unavailable. {data.subject.error} An old page cannot recreate a deleted Subject.</p>{/if}
|
{:else}<p role="alert">Target unavailable. {data.subject.error} An old page cannot recreate a deleted Subject.</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
<style>.permission-page { padding: 1.5rem; min-width: 0; } h1 { font-size: 1.5rem; margin: 1rem 0 1.5rem; overflow-wrap: anywhere; } @media (max-width: 600px) { .permission-page { padding: 1rem; } }</style>
|
<style>.permission-page { padding: 1.5rem; min-width: 0; } h1 { font-size: var(--font-size-title); margin: 1rem 0 1.5rem; overflow-wrap: anywhere; } @media (max-width: 600px) { .permission-page { padding: 1rem; } }</style>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function subject() {
|
|||||||
|
|
||||||
function subjectIndexData(items = [subject()]) {
|
function subjectIndexData(items = [subject()]) {
|
||||||
return {
|
return {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
cursor: null,
|
cursor: null,
|
||||||
subjects: result({
|
subjects: result({
|
||||||
@@ -57,6 +58,7 @@ function subjectIndexData(items = [subject()]) {
|
|||||||
test("renders the subject index as the Memory product entry", () => {
|
test("renders the subject index as the Memory product entry", () => {
|
||||||
render(SubjectIndexPage, {
|
render(SubjectIndexPage, {
|
||||||
data: {
|
data: {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
cursor: "subject-current",
|
cursor: "subject-current",
|
||||||
subjects: result({
|
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 () => {
|
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
|
||||||
render(SubjectPage, {
|
render(SubjectPage, {
|
||||||
data: {
|
data: {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
cursor: "memory-current",
|
cursor: "memory-current",
|
||||||
@@ -333,6 +336,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
|
|||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
render(SubjectPage, {
|
render(SubjectPage, {
|
||||||
data: {
|
data: {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
subject: result(subject()),
|
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") {
|
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
|
||||||
return {
|
return {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId,
|
workspaceId,
|
||||||
subjectId: record.id,
|
subjectId: record.id,
|
||||||
subject: result(record),
|
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.
|
// the same empty committed list as a new Subject; candidates are not a page input.
|
||||||
render(SubjectPage, {
|
render(SubjectPage, {
|
||||||
data: {
|
data: {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
cursor: null,
|
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 () => {
|
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||||
const base = {
|
const base = {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
subject: result(subject()),
|
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 () => {
|
test("renders committed Memory detail, provenance, derivation, and immutable changes", async () => {
|
||||||
render(MemoryDetailPage, {
|
render(MemoryDetailPage, {
|
||||||
data: {
|
data: {
|
||||||
|
workspace: { permissions: { delete_subjects: false } },
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
memoryId: "memory-1",
|
memoryId: "memory-1",
|
||||||
|
|||||||
Reference in New Issue
Block a user