diff --git a/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.test.ts b/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.test.ts new file mode 100644 index 00000000000..5c9e482fecb --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { navigationBoundaryIdentityCases } from "./identity-navigation-boundaries.js"; +import type { IdentityObservation } from "./identity-support.js"; +function valid(scenarioId: string): IdentityObservation { + const similar = { id: "similar", identifier: "ACO-2", title: "Launch draft", companyId: "acorn", status: "backlog" }; + const o: IdentityObservation = { scenarioId, origin: "http://fixture.invalid", companyId: "acorn", leadId: "ceo", company: { id: "acorn" }, companies: [], agents: [], interactions: [], + tasks: [similar], projects: [], goals: [], comments: [], documents: [], revisions: [], links: [], runs: [], humanQueries: [], reply: "The original task was removed; the other task is a separate record.", activity: [], state: {} }; + if (scenarioId.endsWith("broken-link")) { + Object.assign(o.state, { stale: { id: "stale" }, similar, restricted: { id: "private" }, restrictedMarker: "Private Orchid", deletedStatus: 404, afterDeletedStatus: 404, + missingVisible: true, stalePathPreserved: true, similarNotOpened: true, similarAfter: structuredClone(similar), observerRestrictedStatus: 404, observerRestrictedHidden: true }); + o.activity.push({ id: "deleted", action: "issue.deleted", entityId: "stale", actorType: "user" }); + } else { + Object.assign(o.state, { publication: { id: "publication", title: "New release" }, humanId: "alex", primaryVisible: true, secondaryVisible: true, samePublicationAcrossCompanies: true, + agentDismissStatus: 403, beforeHumanPublicationId: "publication", dismissRequestStatus: 204, afterHumanPublication: null, secondaryAfterReloadHidden: true, + observerBeforePublicationId: "publication", observerAfterPublicationId: "publication", observerStillVisible: true, invalidAuditCompanyStatus: 404 }); + o.activity.push({ id: "dismiss", action: "announcement.dismissed", entityId: "publication", actorType: "user", actorId: "alex" }); + } + return o; +} +describe("real missing link and instance-wide personal dismissal", () => { + for (const d of navigationBoundaryIdentityCases) { + it(`${d.scenarioId}: valid and missing observations`, () => { const o = valid(d.scenarioId); expect(d.grade(o), JSON.stringify(d.grade(o))).toMatchObject({ passed: true }); expect(d.grade(undefined).passed).toBe(false); }); + it(`${d.scenarioId}: wrong audience or replacement`, () => { const o = valid(d.scenarioId); if (d.scenarioId.endsWith("broken-link")) o.state.similarNotOpened = false; else o.state.observerAfterPublicationId = undefined; expect(d.grade(o).passed).toBe(false); }); + it(`${d.scenarioId}: plausible prose without persisted proof`, () => { const o = valid(d.scenarioId); o.activity = []; expect(d.grade(o).passed).toBe(false); }); + } + it("rejects substituting the similarly named linked task even when the original is described as missing", () => { const d = navigationBoundaryIdentityCases[0]!; const o = valid(d.scenarioId); o.links = ["/ACO/issues/ACO-2"]; expect(d.grade(o).passed).toBe(false); }); + it("rejects company content deletion disguised as personal dismissal", () => { const d = navigationBoundaryIdentityCases[1]!; const o = valid(d.scenarioId); o.activity.push({ id: "bad", action: "announcement.deleted" }); expect(d.grade(o).passed).toBe(false); }); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.ts b/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.ts new file mode 100644 index 00000000000..97f2b99522b --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.ts @@ -0,0 +1,130 @@ +import { expect } from "@playwright/test"; +import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; +import { RunnerApiHttpError } from "../api.js"; +import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { checks, entity, project, sameTask, task, type Entity } from "./internal-support.js"; +import { identityDefinition, identityObservation, observeIdentity, openStandingChat, probeAgentPermission, waitForReply } from "./identity-support.js"; + +type Person = Awaited>; +const observers = new WeakMap(); +const otherCompanies = new WeakMap(); +interface Publication { id: string; title: string } +async function status(ctx: ChatCatalogContext, path: string) { + try { await ctx.input.api.get(path); return 200; } + catch (error) { if (error instanceof RunnerApiHttpError) return error.status; throw error; } +} +export function gradeNavigationBoundary(value: unknown) { + const c = checks(); const o = identityObservation(value); + c.add("complete-personal-navigation", !!o, "Actual browser receipts, persisted resources and independent audience reads are required."); if (!o) return c.result(); + if (o.scenarioId === "navigation-preferences.broken-link") { + const stale = entity(o.state, "stale"); const similar = entity(o.state, "similar"); const restricted = entity(o.state, "restricted"); + c.add("real-deleted-original", stale.id !== similar.id && o.state.deletedStatus === 404 && o.state.afterDeletedStatus === 404 + && o.activity.some(row => row.action === "issue.deleted" && row.entityId === stale.id && row.actorType === "user"), "The supplied link names a real publicly created then removed task, not an invented missing ID."); + c.add("correct-missing-browser", o.state.missingVisible === true && o.state.stalePathPreserved === true && o.state.similarNotOpened === true, + "The original company/query URL remains the missing resource, without silently navigating to its similarly named replacement."); + c.add("no-substitution-or-write", sameTask(similar, o.state.similarAfter as Entity | undefined) && !o.links.some(link => link.includes(similar.id) || link.includes(String(similar.identifier))) + && /removed|deleted|not found|missing|stale|no longer/i.test(o.reply) && !o.activity.some(row => row.agentId === o.leadId && [stale.id, similar.id, restricted.id].includes(row.entityId ?? "")), + "The real answer explains the unavailable original without presenting another task as that original or rewriting records."); + c.add("restricted-nondisclosure", o.state.observerRestrictedStatus === 404 && o.state.observerRestrictedHidden === true && !o.reply.includes(String(o.state.restrictedMarker)), + "A separate real member cannot distinguish a private resource from a missing one or see its secret content."); + } else if (o.scenarioId === "navigation-preferences.dismiss-announcement") { + const publication = o.state.publication as Publication | undefined; + c.add("real-publication-two-companies", !!publication?.id && o.state.primaryVisible === true && o.state.secondaryVisible === true && o.state.samePublicationAcrossCompanies === true, + "The same actual server publication is visible to the person in both owned companies before dismissal."); + c.add("human-only-personal-dismissal", o.state.agentDismissStatus === 403 && o.state.beforeHumanPublicationId === publication?.id && o.state.dismissRequestStatus === 204 + && o.state.afterHumanPublication === null && o.state.secondaryAfterReloadHidden === true, + "The agent cannot impersonate the person; their actual visible dismissal persists across company selection and full reload."); + c.add("audience-and-membership-preserved", o.state.observerBeforePublicationId === publication?.id && o.state.observerAfterPublicationId === publication?.id + && o.state.observerStillVisible === true && o.state.invalidAuditCompanyStatus === 404, + "Another real person still sees the same publication, and their dismissal cannot use an unauthorized company's audit context."); + c.add("one-human-dismissal-audit", o.activity.filter(row => row.action === "announcement.dismissed" && row.entityId === publication?.id + && row.actorType === "user" && row.actorId === o.state.humanId && !row.agentId).length === 1 + && !o.activity.some(row => row.action === "company.deleted" || row.action === "announcement.deleted"), + "The instance-wide preference records exactly one human dismissal under validated source-company audit context, without deleting content."); + } else c.add("known-navigation-boundary", false, "No oracle exists for this case."); + return c.result(); +} +const sources = ["chat-catalog-domains/identity-navigation-boundaries.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/internal-support.ts", "chat-scenarios/navigation-preferences.yaml"]; +export const navigationBoundaryIdentityCases: readonly ChatCatalogCase[] = [ + identityDefinition("navigation-preferences.broken-link", async ctx => { + const source = await openStandingChat(ctx); const p = await project(ctx); + const stale = await task(ctx, "Launch website draft", p.id, { description: "Original removed draft" }); + const elsewhere = await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/projects`, { name: "Archived launch context" }); + const similar = await task(ctx, "Launch website draft", elsewhere.id, { description: "A different current draft; never substitute for the removed original." }); + const restrictedMarker = `Private unrelated acquisition ${ctx.input.nonce}`; + const restricted = await task(ctx, "Private acquisition", elsewhere.id, { visibility: "private", description: restrictedMarker }); + const path = `/${ctx.input.fixtures.company.issuePrefix}/issues/${stale.identifier}?tab=activity`; + await ctx.input.api.delete(`/api/issues/${stale.id}`); + await ctx.input.api.patch(`/api/issues/${source.id}`, { description: `The person supplied this old task link: ${ctx.input.api.baseURL}${path}. It refers to the removed Launch website draft. Preserve its exact identity and company when explaining the missing resource. Another similarly named task is a separate record, not a redirect.` }); + const browser = ctx.input.page.context().browser(); if (!browser || !ctx.input.privateSecrets) throw new Error("Missing independent real-user setup"); + const observer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); observers.set(ctx, observer); + try { + await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, observer, ctx.input.privateSecrets, "viewer"); + const denial = await observer.request(`/api/issues/${restricted.id}`); const observerRestrictedStatus = denial.status; await denial.body?.cancel(); + return { project: p, stale, similar, restricted, restrictedMarker, stalePath: path, deletedStatus: await status(ctx, `/api/issues/${stale.id}`), observerRestrictedStatus }; + } catch (error) { await observer.dispose(); observers.delete(ctx); throw error; } + }, gradeNavigationBoundary, { + sourceFiles: sources, authentication: "human-session", + async act(ctx, state) { + await waitForReply(ctx); const source = ctx.input.page.url(); const path = String(state.stalePath); + await ctx.input.page.goto(path, { waitUntil: "domcontentloaded" }); + await expect(ctx.input.page.getByText("Issue not found", { exact: true })).toBeVisible(); state.missingVisible = true; + const actual = new URL(ctx.input.page.url()); state.stalePathPreserved = actual.pathname + actual.search === path; + state.similarNotOpened = await ctx.input.page.getByRole("heading", { name: entity(state, "similar").title!, exact: true }).count() === 0; + const observer = observers.get(ctx)!; const page = await observer.context.newPage(); + try { + await page.goto(`/${ctx.input.fixtures.company.issuePrefix}/issues/${entity(state, "restricted").id}`, { waitUntil: "domcontentloaded" }); + await expect(page.getByText("Issue not found", { exact: true })).toBeVisible(); + state.observerRestrictedHidden = !(await page.locator("body").innerText()).includes(String(state.restrictedMarker)); + } finally { await page.close(); } + await ctx.input.evidence("identity-stale-link-browser.json", { originalId: entity(state, "stale").id, path, actualPath: actual.pathname + actual.search, + missingVisible: state.missingVisible, similarNotOpened: state.similarNotOpened, observerRestrictedHidden: state.observerRestrictedHidden }); + await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); + }, + async observe(ctx, state) { state.afterDeletedStatus = await status(ctx, `/api/issues/${entity(state, "stale").id}`); state.similarAfter = await ctx.input.api.get(`/api/issues/${entity(state, "similar").id}`); return observeIdentity(ctx, state); }, + async cleanup(ctx) { await observers.get(ctx)?.dispose(); observers.delete(ctx); }, + }), + identityDefinition("navigation-preferences.dismiss-announcement", async ctx => { + const publication = await ctx.input.api.get("/api/announcements/current"); + if (!publication?.id || !publication.title) throw new Error("Setup blocker: a real eligible server announcement publication is required; a fabricated browser card cannot qualify"); + const session = await ctx.input.api.get<{ user: { id: string } }>("/api/auth/get-session"); + const browser = ctx.input.page.context().browser(); if (!browser || !ctx.input.privateSecrets) throw new Error("Missing real publication observer"); + const observer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); observers.set(ctx, observer); + try { + const otherCompany = await ctx.input.api.post("/api/companies", { name: `Birch announcements ${ctx.input.nonce}` }); otherCompanies.set(ctx, otherCompany.id); + await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, observer, ctx.input.privateSecrets, "viewer"); + const before = await observer.request("/api/announcements/current"); const observerPublication = await before.json() as Publication | null; + const invalid = await observer.request(`/api/announcements/${encodeURIComponent(publication.id)}/dismiss`, "POST", { companyId: otherCompany.id }); + const invalidAuditCompanyStatus = invalid.status; await invalid.body?.cancel(); + const source = ctx.input.page.url(); + await expect(ctx.input.page.getByRole("complementary", { name: "Paperclip announcements" }).getByRole("heading", { name: publication.title, exact: true })).toBeVisible(); + await ctx.input.page.goto(`/${otherCompany.issuePrefix}/dashboard`, { waitUntil: "domcontentloaded" }); + await expect(ctx.input.page.getByRole("complementary", { name: "Paperclip announcements" }).getByRole("heading", { name: publication.title, exact: true })).toBeVisible(); + const secondary = await ctx.input.api.get("/api/announcements/current"); + await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); + return { publication, humanId: session.user.id, otherCompany, primaryVisible: true, secondaryVisible: true, samePublicationAcrossCompanies: secondary?.id === publication.id, + observerBeforePublicationId: observerPublication?.id, invalidAuditCompanyStatus }; + } catch (error) { try { await observer.dispose(); } finally { observers.delete(ctx); const id = otherCompanies.get(ctx); if (id) await ctx.input.api.delete(`/api/companies/${id}`, { allowNotFound: true }); otherCompanies.delete(ctx); } throw error; } + }, gradeNavigationBoundary, { + sourceFiles: sources, authentication: "human-session", + async act(ctx, state) { + await waitForReply(ctx); const publication = state.publication as Publication; const source = ctx.input.page.url(); + state.beforeHumanPublicationId = (await ctx.input.api.get("/api/announcements/current"))?.id; + state.agentDismissStatus = await probeAgentPermission(ctx, `/api/announcements/${encodeURIComponent(publication.id)}/dismiss`, "POST", { companyId: ctx.input.fixtures.company.id }); + const response = ctx.input.page.waitForResponse(row => row.request().method() === "POST" && new URL(row.url()).pathname === `/api/announcements/${publication.id}/dismiss`); + await ctx.input.page.getByRole("complementary", { name: "Paperclip announcements" }).getByRole("button", { name: "Dismiss announcement", exact: true }).click(); + state.dismissRequestStatus = (await response).status(); state.afterHumanPublication = await ctx.input.api.get("/api/announcements/current"); + const other = state.otherCompany as Entity & { issuePrefix: string }; + await ctx.input.page.goto(`/${other.issuePrefix}/dashboard`, { waitUntil: "domcontentloaded" }); await ctx.input.page.reload({ waitUntil: "domcontentloaded" }); + await expect(ctx.input.page.getByRole("complementary", { name: "Paperclip announcements" })).toHaveCount(0); state.secondaryAfterReloadHidden = true; + const observer = observers.get(ctx)!; const receipt = await observer.request("/api/announcements/current"); state.observerAfterPublicationId = ((await receipt.json()) as Publication | null)?.id; + const page = await observer.context.newPage(); + try { await page.goto(`/${ctx.input.fixtures.company.issuePrefix}/dashboard`, { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("complementary", { name: "Paperclip announcements" }).getByRole("heading", { name: publication.title, exact: true })).toBeVisible(); state.observerStillVisible = true; + } finally { await page.close(); } + await ctx.input.evidence("identity-personal-announcement-dismissal.json", { publicationId: publication.id, userId: state.humanId, secondaryAfterReloadHidden: state.secondaryAfterReloadHidden, observerStillVisible: state.observerStillVisible, invalidAuditCompanyStatus: state.invalidAuditCompanyStatus }); + await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); + }, + async cleanup(ctx) { try { await observers.get(ctx)?.dispose(); } finally { observers.delete(ctx); const id = otherCompanies.get(ctx); if (id) await ctx.input.api.delete(`/api/companies/${id}`, { allowNotFound: true }); otherCompanies.delete(ctx); } }, + }), +];