diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json index a602e150f1b..3ca9fc32d6c 100644 --- a/tests/runner-e2e/chat-catalog-descriptors.json +++ b/tests/runner-e2e/chat-catalog-descriptors.json @@ -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, diff --git a/tests/runner-e2e/chat-catalog-domains/identity-personal.test.ts b/tests/runner-e2e/chat-catalog-domains/identity-personal.test.ts index f80f6845640..10ce6639071 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-personal.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-personal.test.ts @@ -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); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-profile.ts b/tests/runner-e2e/chat-catalog-domains/identity-profile.ts index 84d112cb797..6ab7642ae15 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-profile.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-profile.ts @@ -8,13 +8,24 @@ import { identityDefinition, identityObservation, observeIdentity, probeAgentPer interface Session { user: { id: string; name: string; image?: string | null } } type Person = Awaited>; const observers = new WeakMap(); +/** 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("/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" });