feat: replace Subject retirement with durable explicit deletion
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// 本番Web buildと合成HTTP境界を接続する。実認証・削除の永続性はBackend suiteの責務。
|
||||
import { join, resolve } from "jsr:@std/path@1.1.4";
|
||||
const [portText, build, mode] = Deno.args;
|
||||
const port = Number(portText);
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const upstreamPort = port + 1;
|
||||
const upstream = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"-A",
|
||||
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
|
||||
String(upstreamPort),
|
||||
resolve(build),
|
||||
"3",
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "inherit",
|
||||
}).spawn();
|
||||
const stop = () => {
|
||||
try {
|
||||
upstream.kill();
|
||||
} catch { /* already stopped */ }
|
||||
};
|
||||
Deno.addSignalListener("SIGTERM", () => {
|
||||
stop();
|
||||
Deno.exit();
|
||||
});
|
||||
Deno.addSignalListener("SIGINT", () => {
|
||||
stop();
|
||||
Deno.exit();
|
||||
});
|
||||
const attempts = new Map<string, string>();
|
||||
const hidden = new Set<string>();
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "owner",
|
||||
handle: "owner",
|
||||
display_name: "Memory owner",
|
||||
};
|
||||
const apiRoot = "/api/w/memory-review";
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") {
|
||||
try {
|
||||
return await fetch(`http://127.0.0.1:${upstreamPort}/health`);
|
||||
} catch {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: "subject-delete-fixture",
|
||||
actor: { ...user, auth_method: "browser_session" },
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
}
|
||||
const sessionId =
|
||||
request.headers.get("cookie")?.match(/delete-review-session=([^;]+)/)
|
||||
?.[1] ?? crypto.randomUUID();
|
||||
const reader =
|
||||
request.headers.get("cookie")?.includes("subject-delete-persona=reader") ??
|
||||
false;
|
||||
const match = url.pathname.match(/\/subjektiv\/subjects\/([^/]+)(?:\/|$)/);
|
||||
const id = match ? decodeURIComponent(match[1]) : null;
|
||||
const key = JSON.stringify([sessionId, id]);
|
||||
if (request.method === "DELETE" && id) {
|
||||
if (reader) {
|
||||
return Response.json({ error: "forbidden", message: "Owner required" }, {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const body = await request.json();
|
||||
const prior = attempts.get(key);
|
||||
if (prior && prior !== body.operation_id) {
|
||||
return Response.json(
|
||||
{ error: "conflict", message: "Changed operation" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
attempts.set(key, body.operation_id);
|
||||
hidden.add(key);
|
||||
if (!prior && id === "unknown-subject") {
|
||||
return Response.json({
|
||||
error: "internal",
|
||||
message: "Response lost after fence",
|
||||
}, { status: 500 });
|
||||
}
|
||||
return Response.json({
|
||||
subject_id: id,
|
||||
operation_id: body.operation_id,
|
||||
completed: !!prior,
|
||||
});
|
||||
}
|
||||
if (id && request.method === "GET" && hidden.has(key)) {
|
||||
return Response.json(
|
||||
{ error: "not_found", message: "Subject unavailable" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const mapped = id &&
|
||||
["pending-subject", "unknown-subject", "legacy-subject"].includes(id);
|
||||
const target = new URL(request.url);
|
||||
target.port = String(upstreamPort);
|
||||
if (mapped) {
|
||||
target.pathname = target.pathname.replace(
|
||||
encodeURIComponent(id!),
|
||||
"empty-subject",
|
||||
);
|
||||
}
|
||||
let response = await fetch(new Request(target, request));
|
||||
if (url.pathname === `${apiRoot}/workspace` || mapped) {
|
||||
const data = await response.json();
|
||||
if (url.pathname === `${apiRoot}/workspace` && mode !== "before") {
|
||||
data.permissions.delete_subjects = !reader;
|
||||
}
|
||||
if (mapped) {
|
||||
if ("id" in data) {
|
||||
data.id = id;
|
||||
data.role = `Deletion review · ${id}`;
|
||||
if (id === "legacy-subject") data.state = "retired";
|
||||
}
|
||||
if ("subject_id" in data) data.subject_id = id;
|
||||
}
|
||||
response = Response.json(data, { status: response.status });
|
||||
}
|
||||
response = new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
const persona = url.searchParams.get("persona");
|
||||
if (persona) {
|
||||
response.headers.set(
|
||||
"set-cookie",
|
||||
`subject-delete-persona=${persona}; Path=/; HttpOnly; SameSite=Lax`,
|
||||
);
|
||||
}
|
||||
response.headers.append(
|
||||
"set-cookie",
|
||||
`delete-review-session=${sessionId}; Path=/; HttpOnly; SameSite=Lax`,
|
||||
);
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { join, resolve } from "@std/path";
|
||||
import {
|
||||
assertNoPageWideOverflow,
|
||||
freePort,
|
||||
waitForServer,
|
||||
} from "../../../../tools/web-ux/browser-tests/memory_document_harness.ts";
|
||||
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const artifact = join(root, "target/web-ux/t786-browser");
|
||||
async function confirm(page: Page) {
|
||||
await page.locator(".subject-delete-open").click();
|
||||
const button = page.getByRole("button", {
|
||||
name: "Permanently delete Subject",
|
||||
exact: true,
|
||||
});
|
||||
assert(await button.isDisabled());
|
||||
await page.getByRole("checkbox").check();
|
||||
await button.click();
|
||||
}
|
||||
Deno.test({
|
||||
name:
|
||||
"Subject deletion shell: owner confirmation, pending/reload/retry, unknown/rejected and route fencing",
|
||||
sanitizeOps: false,
|
||||
sanitizeResources: false,
|
||||
fn: async () => {
|
||||
const build = await new Deno.Command(Deno.execPath(), {
|
||||
args: ["task", "build"],
|
||||
cwd: join(root, "web/workspace"),
|
||||
stdout: "null",
|
||||
stderr: "piped",
|
||||
}).output();
|
||||
assert(build.success, new TextDecoder().decode(build.stderr));
|
||||
const port = await freePort();
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"-A",
|
||||
join(import.meta.dirname!, "fixture-server.ts"),
|
||||
String(port),
|
||||
join(root, "web/workspace/build"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "inherit",
|
||||
}).spawn();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
await Deno.mkdir(artifact, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
await waitForServer(`${origin}/health`);
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme: theme,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
const requests: unknown[] = [];
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(
|
||||
"**/subjektiv/subjects/pending-subject",
|
||||
async (route) => {
|
||||
if (route.request().method() !== "DELETE") {
|
||||
return route.continue();
|
||||
}
|
||||
requests.push(route.request().postDataJSON());
|
||||
if (requests.length === 1) await held;
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
await page.goto(`${origin}/w/memory-review/memory/pending-subject`);
|
||||
await page.locator(".subject-delete-open").waitFor();
|
||||
await page.screenshot({
|
||||
path: join(artifact, `${theme}-${width}-initial.png`),
|
||||
});
|
||||
await page.locator(".subject-delete-open").click();
|
||||
assert(
|
||||
await page.getByText("This is irreversible.", { exact: true })
|
||||
.isVisible(),
|
||||
);
|
||||
await assertNoPageWideOverflow(page);
|
||||
await page.screenshot({
|
||||
path: join(artifact, `${theme}-${width}-confirmation.png`),
|
||||
});
|
||||
await page.getByRole("button", { name: "Cancel", exact: true })
|
||||
.click();
|
||||
assertEquals(requests.length, 0);
|
||||
await confirm(page);
|
||||
await page.locator('[data-delete-state="running"]').waitFor();
|
||||
assertEquals(await page.locator(".subject-overview").count(), 0);
|
||||
await page.screenshot({
|
||||
path: join(artifact, `${theme}-${width}-running.png`),
|
||||
});
|
||||
release();
|
||||
await page.locator('[data-delete-state="pending"]').waitFor();
|
||||
assertEquals(requests.length, 1);
|
||||
const saved = await page.evaluate(() =>
|
||||
Object.keys(sessionStorage).filter((key) =>
|
||||
key.startsWith("yoi.subject-delete:")
|
||||
).map((key) => JSON.parse(sessionStorage.getItem(key)!))
|
||||
);
|
||||
assertEquals(saved, [{
|
||||
subjectId: "pending-subject",
|
||||
operationId: (requests[0] as { operation_id: string }).operation_id,
|
||||
}]);
|
||||
await page.screenshot({
|
||||
path: join(artifact, `${theme}-${width}-pending.png`),
|
||||
});
|
||||
await page.reload();
|
||||
await page.locator('[data-delete-state="unknown"]').waitFor();
|
||||
assertEquals(await page.locator(".subject-overview").count(), 0);
|
||||
await page.locator(".subject-delete-retry").click();
|
||||
await page.locator('[data-delete-state="completed"]').waitFor();
|
||||
assertEquals(requests.length, 2);
|
||||
assertEquals(requests[0], requests[1]);
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
Object.keys(sessionStorage).filter((key) =>
|
||||
key.startsWith("yoi.subject-delete:")
|
||||
)
|
||||
),
|
||||
[],
|
||||
);
|
||||
await page.screenshot({
|
||||
path: join(artifact, `${theme}-${width}-completed.png`),
|
||||
});
|
||||
await assertNoPageWideOverflow(page);
|
||||
assertEquals(pageErrors, []);
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
for (const subject of ["unknown-subject", "legacy-subject"]) {
|
||||
const page = await browser.newPage();
|
||||
const requests: unknown[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "DELETE") {
|
||||
requests.push(request.postDataJSON());
|
||||
}
|
||||
});
|
||||
await page.goto(`${origin}/w/memory-review/memory/${subject}`);
|
||||
await page.locator(".subject-delete-open").waitFor();
|
||||
if (subject === "legacy-subject") {
|
||||
assert(
|
||||
await page.getByText("Legacy ended Subject", { exact: true })
|
||||
.isVisible(),
|
||||
);
|
||||
}
|
||||
await confirm(page);
|
||||
await page.locator(
|
||||
`[data-delete-state="${
|
||||
subject === "unknown-subject" ? "unknown" : "pending"
|
||||
}"]`,
|
||||
).waitFor();
|
||||
await page.locator(".subject-delete-retry").click();
|
||||
await page.locator('[data-delete-state="completed"]').waitFor();
|
||||
assertEquals(requests.length, 2);
|
||||
assertEquals(requests[0], requests[1]);
|
||||
await page.close();
|
||||
}
|
||||
// 4xxでもkeyを捨てない。権限の変化を新しい依頼へ置換しない。
|
||||
{
|
||||
const page = await browser.newPage();
|
||||
const requests: unknown[] = [];
|
||||
await page.route(
|
||||
"**/subjektiv/subjects/pending-subject",
|
||||
async (route) => {
|
||||
if (route.request().method() !== "DELETE") return route.continue();
|
||||
requests.push(route.request().postDataJSON());
|
||||
if (requests.length === 1) {
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
json: { error: "forbidden", message: "Permission changed" },
|
||||
});
|
||||
} else {await route.fulfill({
|
||||
json: {
|
||||
subject_id: "pending-subject",
|
||||
operation_id: (requests[0] as any).operation_id,
|
||||
completed: true,
|
||||
},
|
||||
});}
|
||||
},
|
||||
);
|
||||
await page.goto(`${origin}/w/memory-review/memory/pending-subject`);
|
||||
await confirm(page);
|
||||
await page.locator('[data-delete-state="failed"]').waitFor();
|
||||
await page.screenshot({ path: join(artifact, "rejected.png") });
|
||||
await page.locator(".subject-delete-retry").click();
|
||||
await page.locator('[data-delete-state="completed"]').waitFor();
|
||||
assertEquals(requests[0], requests[1]);
|
||||
await page.close();
|
||||
}
|
||||
// 同一SPA内で他対象へ移動してから旧応答が届いても、新対象を消さない。
|
||||
{
|
||||
const page = await browser.newPage();
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(
|
||||
"**/subjektiv/subjects/pending-subject",
|
||||
async (route) => {
|
||||
if (route.request().method() !== "DELETE") return route.continue();
|
||||
const body = route.request().postDataJSON();
|
||||
await held;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
subject_id: "pending-subject",
|
||||
operation_id: body.operation_id,
|
||||
completed: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto(`${origin}/w/memory-review/memory/pending-subject`);
|
||||
await confirm(page);
|
||||
await page.locator('[data-delete-state="running"]').waitFor();
|
||||
await page.getByRole("link", { name: "← All subjects", exact: true })
|
||||
.click();
|
||||
await page.locator(".subject-list a").first().click();
|
||||
await page.locator(".subject-overview").waitFor();
|
||||
const target = page.url();
|
||||
const response = page.waitForResponse((r) =>
|
||||
r.request().method() === "DELETE"
|
||||
);
|
||||
release();
|
||||
await response;
|
||||
assertEquals(page.url(), target);
|
||||
assertEquals(
|
||||
await page.locator('[data-delete-state="completed"]').count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await page.locator(".subject-overview").count(), 1);
|
||||
await page.close();
|
||||
}
|
||||
{
|
||||
const page = await browser.newPage();
|
||||
await page.goto(
|
||||
`${origin}/w/memory-review/memory/legacy-subject?persona=reader`,
|
||||
);
|
||||
await page.locator(".subject-overview").waitFor();
|
||||
assertEquals(await page.locator(".subject-deletion").count(), 0);
|
||||
await page.screenshot({ path: join(artifact, "non-owner.png") });
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user