mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
test(e2e): cover missing links and personal announcements
This commit is contained in:
1 parent
9be9c65e85
commit
5cc4d6f1fa
2 files changed
+158
No files matched your search
@@ -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); });
|
||||
});
|
||||
@@ -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<ReturnType<typeof createAuthenticatedPerson>>;
|
||||
const observers = new WeakMap<ChatCatalogContext, Person>();
|
||||
const otherCompanies = new WeakMap<ChatCatalogContext, string>();
|
||||
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<Entity>(`/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<Publication | null>("/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<Entity & { issuePrefix: string }>("/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<Publication | null>("/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<Publication | null>("/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); } },
|
||||
}),
|
||||
];
|
||||
Reference in new issue
Block a user