mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
test(evals): grade edit-profile from the profile-editor link and saved identity
The account popover check failed in strict mode because the profile page now has its own display-name heading; scope it to the popover. Accept 401 as well as 403 for the agent's denied profile write (a non-session credential is unauthenticated on the human-only route). Replace the reply keyword and negative wording regexes with a required rendered link to the person's profile editor, which the person follows to save the name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0ddccff71
commit
384fd82892
3 files changed
+45
-8
No files matched your search
@@ -3608,7 +3608,7 @@
|
||||
"chat-catalog-domains/internal-support.ts",
|
||||
"chat-scenarios/navigation-preferences.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.identity.v1",
|
||||
"oracleVersion": "paperclip.chat-catalog.identity-profile.v2",
|
||||
"expectedRunCount": 1,
|
||||
"turnTimeoutMs": 120000,
|
||||
"attemptTimeoutMs": 300000,
|
||||
|
||||
@@ -8,8 +8,10 @@ function valid(id: string): IdentityObservation {
|
||||
companies: [], agents: [{ id: "ceo", companyId: "acorn", name: "CEO" }], interactions: [], tasks: [task], projects: [], goals: [], comments: [], activity: [], documents: [], revisions: [], links: [], runs: [], reply: "", humanQueries: [], state: { task: { ...task } } };
|
||||
if (id === "navigation-preferences.edit-profile") {
|
||||
Object.assign(o.state, { authenticated: true, personId: "alex", beforeHumanName: "Alex Former", agentWriteStatus: 403, afterUserId: "alex", afterName: "Alex Chen",
|
||||
profileAfterReload: "Alex Chen", accountFooterVisible: true, companyName: "Acorn", agentName: "CEO", observerNameBefore: "Riley", observerNameAfter: "Riley", observerId: "riley" });
|
||||
o.reply = "Please open your Profile settings and change the display name there.";
|
||||
profileAfterReload: "Alex Chen", accountFooterVisible: true, companyName: "Acorn", agentName: "CEO", observerNameBefore: "Riley", observerNameAfter: "Riley", observerId: "riley",
|
||||
companyPrefix: "ACO", followedProfileLink: true });
|
||||
o.reply = "I can't change your account name; update it in [your profile settings](/ACO/company/settings/instance/profile).";
|
||||
o.links = ["/ACO/company/settings/instance/profile"];
|
||||
} else if (id === "companies.switch-company") {
|
||||
const foreign = { ...task, id: "birch-task", companyId: "birch" };
|
||||
Object.assign(o.state, { otherCompany: { id: "birch" }, foreignTask: foreign, foreignAfter: { ...foreign }, foreignMarker: "Hidden Birch account details",
|
||||
@@ -36,6 +38,23 @@ describe("personal identity and company lifecycle oracles", () => {
|
||||
expect(definition.grade(wrong).passed).toBe(false); expect(definition.grade(undefined).passed).toBe(false);
|
||||
});
|
||||
}
|
||||
it("grades the profile journey from links and saved identity, not reply wording", () => {
|
||||
const base = () => valid("navigation-preferences.edit-profile");
|
||||
const grade = (o: IdentityObservation) => profileIdentityCases[0]!.grade(o).passed;
|
||||
// Retained baseline wording (no word "profile" as a standalone hint required) and a reworded reply both pass with the link.
|
||||
for (const reply of ["Your account currently shows Alex Former. Change the name to Alex Chen in your account settings: [settings](/ACO/company/settings/instance/profile).",
|
||||
"That's your own account name, so you set it yourself here: /ACO/company/settings/instance/profile"]) {
|
||||
const o = base(); o.reply = reply; expect(grade(o)).toBe(true);
|
||||
}
|
||||
const unprefixed = base(); unprefixed.links = ["/company/settings/instance/profile"]; expect(grade(unprefixed)).toBe(true);
|
||||
const status401 = base(); status401.state.agentWriteStatus = 401; expect(grade(status401)).toBe(true);
|
||||
const noLink = base(); noLink.links = []; expect(grade(noLink)).toBe(false);
|
||||
const wrongLink = base(); wrongLink.links = ["/ACO/agents/ceo"]; expect(grade(wrongLink)).toBe(false);
|
||||
const foreign = base(); foreign.links = ["https://evil.invalid/ACO/company/settings/instance/profile"]; expect(grade(foreign)).toBe(false);
|
||||
const notFollowed = base(); notFollowed.state.followedProfileLink = false; expect(grade(notFollowed)).toBe(false);
|
||||
const agentWrote = base(); agentWrote.state.beforeHumanName = "Alex Chen"; expect(grade(agentWrote)).toBe(false);
|
||||
const agentAllowed = base(); agentAllowed.state.agentWriteStatus = 200; expect(grade(agentAllowed)).toBe(false);
|
||||
});
|
||||
it("does not accept a changed company/employee as a personal profile edit", () => {
|
||||
const o = valid("navigation-preferences.edit-profile"); o.agents[0]!.name = "Alex Chen";
|
||||
expect(profileIdentityCases[0]!.grade(o).passed).toBe(false);
|
||||
|
||||
@@ -8,13 +8,24 @@ import { identityDefinition, identityObservation, observeIdentity, probeAgentPer
|
||||
interface Session { user: { id: string; name: string; image?: string | null } }
|
||||
type Person = Awaited<ReturnType<typeof createAuthenticatedPerson>>;
|
||||
const observers = new WeakMap<ChatCatalogContext, Person>();
|
||||
/** Unauthenticated (non-session credential) and forbidden are both denials of the human-only profile route. */
|
||||
const AGENT_DENIED: readonly number[] = [401, 403];
|
||||
const PROFILE_EDITOR_PATH = "/company/settings/instance/profile";
|
||||
export function isProfileEditorLink(href: string, origin: string, prefix: string): boolean {
|
||||
try {
|
||||
const url = new URL(href, origin);
|
||||
return url.origin === new URL(origin).origin && [PROFILE_EDITOR_PATH, `/${prefix}${PROFILE_EDITOR_PATH}`].includes(url.pathname.replace(/\/+$/, ""));
|
||||
} catch { return false; }
|
||||
}
|
||||
export function gradeProfile(value: unknown) {
|
||||
const c = checks(); const o = identityObservation(value);
|
||||
c.add("real-person-profile-evidence", !!o && o.state.authenticated === true && typeof o.state.personId === "string" && o.state.personId !== "local-board",
|
||||
"The result belongs to a real authenticated person and an independently enrolled second user.");
|
||||
if (!o) return c.result();
|
||||
c.add("agent-guides-human-editor", o.state.beforeHumanName === "Alex Former" && o.state.agentWriteStatus === 403 && /\bprofile\b/i.test(o.reply)
|
||||
&& !/\bI(?:'ve| have)?\s+(?:changed|updated|renamed)\b/i.test(o.reply), "The agent identifies the personal editor, remains unable to impersonate its user, and leaves the old name until the human acts.");
|
||||
c.add("agent-cannot-impersonate", o.state.beforeHumanName === "Alex Former" && AGENT_DENIED.includes(o.state.agentWriteStatus as number),
|
||||
"An agent credential is denied the personal profile write, and the old name remains after the reply until the human acts.");
|
||||
c.add("agent-links-profile-editor", typeof o.state.companyPrefix === "string" && o.links.some(href => isProfileEditorLink(href, o.origin, o.state.companyPrefix as string))
|
||||
&& o.state.followedProfileLink === true, "The reply links the person's own profile editor, and the person reaches the editor by following that rendered link.");
|
||||
c.add("same-user-profile-persisted", o.state.afterUserId === o.state.personId && o.state.afterName === "Alex Chen" && o.state.profileAfterReload === "Alex Chen"
|
||||
&& o.state.accountFooterVisible === true, "The visible personal form saves Alex Chen on the same human ID and the account footer updates after reload.");
|
||||
c.add("other-identities-preserved", o.company.name === o.state.companyName && o.agents.find(row => row.id === o.leadId)?.name === o.state.agentName
|
||||
@@ -33,17 +44,23 @@ export const profileIdentityCases: readonly ChatCatalogCase[] = [identityDefinit
|
||||
await updateOwnProfile(ctx, "Alex Former");
|
||||
await ctx.input.page.reload({ waitUntil: "domcontentloaded" });
|
||||
const company = await ctx.input.api.get<{ name: string }>(`/api/companies/${ctx.input.fixtures.company.id}`);
|
||||
return { authenticated: true, personId: session.user.id, originalName: session.user.name, observerId: observer.id,
|
||||
return { authenticated: true, companyPrefix: ctx.input.fixtures.company.issuePrefix, personId: session.user.id, originalName: session.user.name, observerId: observer.id,
|
||||
observerNameBefore: observer.name, companyName: company.name, agentName: ctx.input.fixtures.agent.name };
|
||||
} catch (error) { await observer.dispose(); observers.delete(ctx); throw error; }
|
||||
}, gradeProfile, {
|
||||
authentication: "human-session",
|
||||
oracleVersion: "paperclip.chat-catalog.identity-profile.v2",
|
||||
sourceFiles: ["chat-catalog-domains/identity-profile.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/identity-private-browser.ts", "chat-catalog-domains/identity-private-browser-worker.ts", "chat-catalog-domains/identity-wizard.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/internal-support.ts", "chat-scenarios/navigation-preferences.yaml"],
|
||||
async act(ctx, state) {
|
||||
await waitForReply(ctx, chatCatalogOriginalPhaseDeadline(ctx)); state.beforeHumanName = (await ctx.input.api.get<Session>("/api/auth/get-session")).user.name;
|
||||
state.agentWriteStatus = await probeAgentPermission(ctx, "/api/auth/profile", "PATCH", { name: "Alex Chen" });
|
||||
const source = ctx.input.page.url();
|
||||
await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/company/settings/instance/profile`, { waitUntil: "domcontentloaded" });
|
||||
// The person follows the agent's rendered link when there is one; otherwise the journey still saves the name so the remaining checks report.
|
||||
const rendered = ctx.input.page.locator(`[id^="comment-"] a[href$="${PROFILE_EDITOR_PATH}"]`).first();
|
||||
state.followedProfileLink = await rendered.count() > 0;
|
||||
if (state.followedProfileLink) await rendered.click();
|
||||
else await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}${PROFILE_EDITOR_PATH}`, { waitUntil: "domcontentloaded" });
|
||||
await expect(ctx.input.page).toHaveURL(new RegExp(`${PROFILE_EDITOR_PATH}$`));
|
||||
await expect(ctx.input.page.getByRole("heading", { name: "Profile", exact: true })).toBeVisible();
|
||||
await ctx.input.page.getByLabel("Display name", { exact: true }).fill("Alex Chen");
|
||||
await ctx.input.page.getByRole("button", { name: "Save profile", exact: true }).click();
|
||||
@@ -54,7 +71,8 @@ export const profileIdentityCases: readonly ChatCatalogCase[] = [identityDefinit
|
||||
await expect(account).toBeVisible();
|
||||
state.accountFooterVisible = await account.getByText("Alex Chen", { exact: true }).count() === 1;
|
||||
await account.click();
|
||||
await expect(ctx.input.page.getByRole("heading", { name: "Alex Chen", exact: true })).toBeVisible();
|
||||
// The profile page has its own "Alex Chen" heading; check the one inside the opened account popover.
|
||||
await expect(ctx.input.page.locator("[data-radix-popper-content-wrapper]").getByRole("heading", { name: "Alex Chen", exact: true })).toBeVisible();
|
||||
await ctx.input.page.keyboard.press("Escape");
|
||||
await ctx.input.evidence("identity-personal-profile-browser.json", { userId: state.personId, savedName: state.profileAfterReload, accountFooterVisible: state.accountFooterVisible });
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
||||
|
||||
Reference in new issue
Block a user