From dea44755b07084dc28e0c6afb50f065be19f909f Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 28 Oct 2026 07:41:46 -0500 Subject: [PATCH] fix(evals): prepare prerequisite people through public membership routes Co-Authored-By: Paperclip --- tests/runner-e2e/SECURITY.md | 18 +++++ .../external-assistant-consent.ts | 4 +- .../external-personal-secret.ts | 9 +-- .../external-wrong-resolver.ts | 4 +- .../chat-catalog-domains/identity-auth.ts | 52 ++++++++++++ .../identity-navigation-boundaries.ts | 6 +- .../identity-navigation-preferences.ts | 4 +- .../identity-prerequisite-membership.test.ts | 80 +++++++++++++++++++ .../chat-catalog-domains/identity-profile.ts | 4 +- .../identity-questions-and-approvals.ts | 4 +- .../identity-sidebar-order.ts | 4 +- .../internal-decision-search.ts | 4 +- .../internal-inbox-triage.ts | 4 +- .../chat-catalog-domains/internal-privacy.ts | 4 +- .../internal-restore-concurrency.ts | 4 +- .../internal-task-completion.ts | 4 +- 16 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 tests/runner-e2e/chat-catalog-domains/identity-prerequisite-membership.test.ts diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index 0769dd0d542..3c015e9507f 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -563,3 +563,21 @@ Runner hook performs it. Only synthetic source bytes and proof JSON enter the existing private attempt evidence and registered-artifact verification pipeline. A failed attempt remains retained. These development checks are not staging or Slack qualification, and they do not publish trusted archives or credentials. + +## Authenticated prerequisite people + +Twenty explicitly selected OpenCode/local chat journeys use ordinary public +signup and instance-admin company-access management to prepare a fresh second +person. The setup verifies the administrator's real session, assigns only the +requested company and its existing operator/viewer role grants, and independently +reads the saved membership and grant attribution. The person's own session must +prove company access and denial of instance administration. Setup consumes the +original observation clock and neither dispatches native work nor performs the +human action under evaluation. Private-resource and other-person boundaries +remain part of each journey's independent oracle. + +Actual invitation journeys retain the invitation/acceptance path and its logger +qualification prerequisite. This alternative does not qualify that path, create +an instance administrator, bypass route authorization, or change retained failed +outcomes. Passwords and cookies remain in the existing private session registry; +the setup receipt contains only public identity, role, grant keys and statuses. diff --git a/tests/runner-e2e/chat-catalog-domains/external-assistant-consent.ts b/tests/runner-e2e/chat-catalog-domains/external-assistant-consent.ts index 8f52e6d508e..7317371681e 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-assistant-consent.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-assistant-consent.ts @@ -3,7 +3,7 @@ import { expect } from "@playwright/test"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; import type { McpConnection } from "../../../packages/shared/src/public-mcp.js"; import { connect, mcp, type ToolResult } from "../public-mcp-client.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { prepareAssistantSetup } from "./external-assistant-connections.js"; import { definition, externalObservation, observeExternal, root, checks, verifyRenderedReply } from "./external-support.js"; import { waitForReply } from "./identity-support.js"; @@ -38,7 +38,7 @@ export const assistantConsentExternalCases = [definition("assistant-connections. const viewer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets, name: "Viewer Casey" }); people.set(ctx, viewer); try { const caller = await ctx.input.api.get<{ user: { id: string } }>("/api/auth/get-session"); - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, viewer, secrets, "viewer"); + await enrollPrerequisitePerson(ctx, viewer, "viewer"); const marker = `Viewer allowed fixture ${randomBytes(12).toString("hex")}`; const task = await ctx.input.api.post<{ id: string; title: string; description: string }>(`${root(ctx)}/issues`, { title: "Viewer consent sample task", description: marker, status: "backlog" }); return { viewerId: viewer.id, chatCallerId: caller.user.id, phaseBoundary: "operator-chat-to-viewer-browser-consent", taskId: task.id, taskBefore: task, marker, forbiddenTitle: `Forbidden viewer task ${ctx.input.nonce}` }; diff --git a/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts b/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts index fd3c5742d55..3fc4143e663 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-personal-secret.ts @@ -16,7 +16,7 @@ import { } from "./identity-support.js"; import { createAuthenticatedPerson, - enrollAuthenticatedPerson, + enrollPrerequisitePerson, } from "./identity-auth.js"; import { privateBrowserEntry } from "./external-private-browser.js"; import { secretTestEndpoint } from "./external-secret-endpoint.js"; @@ -204,12 +204,7 @@ export const personalSecretExternalCases = [ }); let endpoint: PrivateFixture["endpoint"] | undefined; try { - await enrollAuthenticatedPerson( - ctx.input.api, - ctx.input.fixtures.company.id, - other, - secrets, - ); + await enrollPrerequisitePerson(ctx, other); const definition = await ctx.input.api.post( `${root(ctx)}/user-secret-definitions`, { diff --git a/tests/runner-e2e/chat-catalog-domains/external-wrong-resolver.ts b/tests/runner-e2e/chat-catalog-domains/external-wrong-resolver.ts index 5305066d3cb..514250befc4 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-wrong-resolver.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-wrong-resolver.ts @@ -3,7 +3,7 @@ import {expect} from "@playwright/test"; import type {ChatCatalogContext} from "../chat-catalog-contract.js"; import type {ConnectionIntentInteraction} from "../../../packages/shared/src/types/issue.js"; import {pollUntil} from "../api.js"; -import {createAuthenticatedPerson,enrollAuthenticatedPerson} from "./identity-auth.js"; +import {createAuthenticatedPerson,enrollPrerequisitePerson} from "./identity-auth.js"; import {openStandingChat,waitForReply} from "./identity-support.js"; import {definition,externalObservation,observeExternal,checks,root,verifyRenderedReply} from "./external-support.js"; import type {Entity} from "./internal-support.js"; @@ -27,7 +27,7 @@ export const wrongResolverExternalCases=[definition("connection-setup.wrong-pers const session=await ctx.input.api.get<{user:{id:string}}>("/api/auth/get-session");if(!session.user?.id||session.user.id==="local-board")throw new Error("Actual signed-in other member is required"); const alex=await createAuthenticatedPerson({api:ctx.input.api,browser,secrets,name:"Alex"});people.set(ctx,alex); try{ - await enrollAuthenticatedPerson(ctx.input.api,ctx.input.fixtures.company.id,alex,secrets); + await enrollPrerequisitePerson(ctx, alex); for(const scope of [{scopeType:"company",scopeId:ctx.input.fixtures.company.id},{scopeType:"agent",scopeId:ctx.input.fixtures.agent.id}])await ctx.input.api.post(`${root(ctx)}/budgets/policies`,{...scope,amount:500,hardStopEnabled:true,isActive:true,unpricedUsagePolicy:"allow"}); if((await ctx.input.api.get<{connections:unknown[]}>(`${root(ctx)}/tools/connections`)).connections.length)throw new Error("Wrong resolver requires no existing Notion account"); const response=await alex.request(`${root(ctx)}/issues`,"POST",{title:"Alex's launch notes",status:"todo",assigneeAgentId:ctx.input.fixtures.agent.id,description:"Read my Notion and summarize the current launch notes. I have not connected my Notion account yet."}); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-auth.ts b/tests/runner-e2e/chat-catalog-domains/identity-auth.ts index 0753205ce4a..bd3930d6c0b 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-auth.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-auth.ts @@ -1,3 +1,6 @@ +import type { ChatCatalogContext } from "../chat-catalog-contract.js"; +import type { CompanyMemberRecord, CompanyMembersResponse, UserCompanyAccessResponse } from "../../../packages/shared/src/types/access.js"; +import { grantsForHumanRole } from "../../../server/src/services/company-member-roles.js"; import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -99,3 +102,52 @@ export async function enrollAuthenticatedPerson(api: RunnerApi, companyId: strin if (!companiesResponse.ok || !(await companiesResponse.json() as Array<{ id: string }>).some(row => row.id === companyId)) throw new Error("Approved person cannot read the intended company"); return { userId: person.id, companyId, inviteId: invite.id, joinRequestId: pending[0]!.id }; } + +export const prerequisiteMembershipScenarioIds = Object.freeze([ + "assistant-connections.consent-read-only", "connection-setup.wrong-person-resolves", + "documents-artifacts.private-download", "documents-artifacts.restore-document", + "inbox-search.archive-done", "inbox-search.notification-audience", "inbox-search.restore-archive", "inbox-search.show-decisions", + "navigation-preferences.broken-link", "navigation-preferences.dismiss-announcement", "navigation-preferences.edit-profile", "navigation-preferences.quick-search", "navigation-preferences.sidebar-order", + "projects.private-project", "projects.share-project", "questions-and-approvals.wrong-reviewer", "secrets.personal-secret", + "tasks.completion-with-gate", "tasks.private-task", "tasks.share-private-task", +]); +export async function enrollPrerequisitePerson(ctx: ChatCatalogContext, person: Awaited>, role: "operator" | "viewer" = "operator") { + if (!prerequisiteMembershipScenarioIds.includes(ctx.scenario.id) || ctx.input.execution.id !== `chat-features.runner-opencode.local.${ctx.scenario.id}` || !["operator", "viewer"].includes(role)) + throw new Error("Public prerequisite membership is not admitted for this scenario or role"); + const deadline = ctx.observationDeadlineAt; + const remaining = () => { const now = Date.now(); if (!Number.isFinite(deadline) || deadline! <= now) throw new Error("Original prerequisite observation budget exhausted or missing"); return Math.min(15000, deadline! - now); }; + const { api, fixtures } = ctx.input, companyId = fixtures.company.id; + const read = async (route: string) => { const value = await api.get(route, { timeout: remaining() }); remaining(); return value; }; + const personal = async (route: string) => { remaining(); const response = await person.request(route); remaining(); return response; }; + const health = await read<{ deploymentMode: string }>("/api/health"); + if (health.deploymentMode !== "authenticated") throw new Error("Public membership requires real authenticated sessions"); + const session = await read<{ user: { id: string } }>("/api/auth/get-session"); + if (!session.user?.id || session.user.id === person.id || session.user.id === "local-board") throw new Error("Distinct real first-admin authority is required"); + const admin = await read(`/api/admin/users/${session.user.id}/company-access`); + if (admin.user?.id !== session.user.id || admin.user.isInstanceAdmin !== true) throw new Error("Authenticated fixture administrator authority was not verified"); + const targetBefore = await read(`/api/admin/users/${person.id}/company-access`); + if (targetBefore.user?.id !== person.id || targetBefore.user.isInstanceAdmin !== false || targetBefore.companyAccess.length !== 0) throw new Error("Prerequisite person must be a fresh non-admin user"); + const own = await personal("/api/auth/get-session"); + if (own.status !== 200) { await own.body?.cancel(); throw new Error("Prerequisite person's real session unavailable"); } + if (((await own.json()) as { user?: { id?: string } }).user?.id !== person.id) throw new Error("Prerequisite session belongs to another person"); + const before = await personal(`/api/companies/${companyId}`), beforeStatus = before.status; await before.body?.cancel(); + if (![403, 404].includes(beforeStatus)) throw new Error("Fresh person already has company access or denial proof failed"); + remaining(); const assigned = await api.putSensitive(`/api/admin/users/${person.id}/company-access`, { companyIds: [companyId] }); remaining(); + const membership = assigned.companyAccess.find(row => row.companyId === companyId); + if (assigned.user?.id !== person.id || assigned.user.isInstanceAdmin !== false || assigned.companyAccess.length !== 1 || membership?.principalId !== person.id || membership.principalType !== "user" || membership.status !== "active" || membership.membershipRole !== "operator") throw new Error("Public company access response did not establish the exact prerequisite member"); + const grants = grantsForHumanRole(role); + remaining(); await api.patchSensitive(`/api/companies/${companyId}/members/${membership.id}/role-and-grants`, { membershipRole: role, status: "active", grants }); remaining(); + const members = await read(`/api/companies/${companyId}/members`), actual = members.members.find(row => row.id === membership.id); + const keys = (rows: Array<{ permissionKey: string; scope?: Record | null }>) => rows.map(row => `${row.permissionKey}:${JSON.stringify(row.scope ?? null)}`).sort().join("\n"); + if (!actual || actual.companyId !== companyId || actual.principalId !== person.id || actual.principalType !== "user" || actual.membershipRole !== role || actual.status !== "active" || keys(actual.grants) !== keys(grants) || actual.grants.some(row => row.companyId !== companyId || row.principalId !== person.id || row.principalType !== "user" || row.grantedByUserId !== session.user.id)) throw new Error("Persisted prerequisite role or grants differ from the public requested contract"); + const targetAfter = await read(`/api/admin/users/${person.id}/company-access`); + if (targetAfter.user?.id !== person.id || targetAfter.user.isInstanceAdmin !== false || targetAfter.companyAccess.length !== 1 || targetAfter.companyAccess[0]?.id !== membership.id || targetAfter.companyAccess[0]?.companyId !== companyId || targetAfter.companyAccess[0]?.membershipRole !== role || targetAfter.companyAccess[0]?.status !== "active") throw new Error("Prerequisite membership or non-admin identity changed after role assignment"); + const allowed = await personal(`/api/companies/${companyId}`); + if (allowed.status !== 200) { await allowed.body?.cancel(); throw new Error("Real prerequisite person cannot read its company"); } + if (((await allowed.json()) as { id?: string }).id !== companyId) throw new Error("Real prerequisite person read another company"); + const denied = await personal("/api/admin/users"), deniedStatus = denied.status; await denied.body?.cancel(); + if (deniedStatus !== 403) throw new Error("Prerequisite person acquired instance authority or denial proof failed"); + const receipt = { userId: person.id, companyId, membershipId: membership.id, role, grantKeys: grants.map(row => row.permissionKey).sort(), authentication: "real-person-session", administrator: "verified-instance-admin", enrollment: "public-company-access", beforeStatus, companyReadStatus: 200, instanceAdminReadStatus: deniedStatus }; + remaining(); await ctx.input.evidence("identity-public-prerequisite-membership.json", receipt); remaining(); + return receipt; +} diff --git a/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.ts b/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.ts index b2b6e04b304..f9158e7b066 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-navigation-boundaries.ts @@ -1,7 +1,7 @@ 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 { createAuthenticatedPerson, enrollPrerequisitePerson } 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"; @@ -64,7 +64,7 @@ export const navigationBoundaryIdentityCases: readonly ChatCatalogCase[] = [ 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"); + await enrollPrerequisitePerson(ctx, observer, "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; } @@ -97,7 +97,7 @@ export const navigationBoundaryIdentityCases: readonly ChatCatalogCase[] = [ 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"); + await enrollPrerequisitePerson(ctx, observer, "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(); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-navigation-preferences.ts b/tests/runner-e2e/chat-catalog-domains/identity-navigation-preferences.ts index 4e43434ef9d..3102e64243b 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-navigation-preferences.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-navigation-preferences.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; import { checks, entity, hasLink, project, task, verifyInternalResult } from "./internal-support.js"; import { identityDefinition, identityObservation, waitForReply, probeAgentPermission } from "./identity-support.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; const searchObservers = new WeakMap>>(); @@ -65,7 +65,7 @@ export const navigationIdentityCases = [ if (!browser || !ctx.input.privateSecrets) throw new Error("Quick search audience requires actual authenticated browser identities"); const observer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); searchObservers.set(ctx, observer); try { - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, observer, ctx.input.privateSecrets, "viewer"); + await enrollPrerequisitePerson(ctx, observer, "viewer"); const privateTask = await task(ctx, "Launch private acquisition", state.project.id, { visibility: "private", description: `Private launch note ${ctx.input.nonce}` }); return { ...state, privateTask, observerId: observer.id }; } catch (error) { await observer.dispose(); searchObservers.delete(ctx); throw error; } diff --git a/tests/runner-e2e/chat-catalog-domains/identity-prerequisite-membership.test.ts b/tests/runner-e2e/chat-catalog-domains/identity-prerequisite-membership.test.ts new file mode 100644 index 00000000000..ef7b0e1d1d7 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/identity-prerequisite-membership.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, it, vi } from "vitest"; +import type { ChatCatalogContext } from "../chat-catalog-contract.js"; +import { grantsForHumanRole } from "../../../server/src/services/company-member-roles.js"; +import { enrollPrerequisitePerson, prerequisiteMembershipScenarioIds } from "./identity-auth.js"; +afterEach(() => vi.useRealTimers()); +function fixture(role: "viewer" | "operator" = "operator") { + const membership = { id: "membership", companyId: "company", principalId: "person", principalType: "user", status: "active", membershipRole: "operator" }; + const state = { assigned: false, administrator: true, targetAdministrator: false, preexisting: false, personalId: "person", companyReadId: "company", beforeStatus: 403, adminStatus: 403, readRole: role, extraGrant: false, foreignGrant: false, grantedBy: "admin", slowPut: false }; + const writes: Array<{ route: string; body: unknown }> = [], reads: string[] = [], personalReads: string[] = []; + const api = { + get: vi.fn(async (route: string) => { + reads.push(route); + if (route === "/api/health") return { deploymentMode: "authenticated" }; + if (route === "/api/auth/get-session") return { user: { id: "admin" } }; + if (route === "/api/admin/users/admin/company-access") return { user: { id: "admin", isInstanceAdmin: state.administrator }, companyAccess: [] }; + if (route === "/api/admin/users/person/company-access") return { user: { id: "person", isInstanceAdmin: state.targetAdministrator }, companyAccess: state.assigned ? [{ ...membership, membershipRole: state.readRole }] : state.preexisting ? [membership] : [] }; + if (route === "/api/companies/company/members") return { members: [{ ...membership, membershipRole: state.readRole, grants: [...grantsForHumanRole(role), ...(state.extraGrant ? [{ permissionKey: "users:manage_permissions", scope: null }] : [])].map(grant => ({ ...grant, companyId: state.foreignGrant ? "foreign" : "company", principalId: "person", principalType: "user", grantedByUserId: state.grantedBy })) }] }; + throw new Error("Unexpected fixture read"); + }), + putSensitive: vi.fn(async (route: string, body: unknown) => { writes.push({ route, body }); state.assigned = true; if (state.slowPut) vi.setSystemTime(100000); return { user: { id: "person", isInstanceAdmin: state.targetAdministrator }, companyAccess: [membership] }; }), + patchSensitive: vi.fn(async (route: string, body: unknown) => { writes.push({ route, body }); return membership; }), + }; + const person = { id: "person", request: vi.fn(async (route: string) => { + personalReads.push(route); + if (route === "/api/auth/get-session") return new Response(JSON.stringify({ user: { id: state.personalId } })); + if (route === "/api/admin/users") return new Response("closed", { status: state.adminStatus }); + if (route === "/api/companies/company") return new Response(JSON.stringify({ id: state.companyReadId }), { status: state.assigned ? 200 : state.beforeStatus }); + throw new Error("Unexpected person request"); + }) } as unknown as Parameters[1]; + const evidence = vi.fn(); + const ctx = { scenario: { id: "inbox-search.show-decisions" }, observationDeadlineAt: 100000, input: { execution: { id: "chat-features.runner-opencode.local.inbox-search.show-decisions" }, api, fixtures: { company: { id: "company" } }, evidence } } as unknown as ChatCatalogContext; + vi.useFakeTimers(); vi.setSystemTime(0); + return { ctx, person, api, state, writes, reads, personalReads, evidence }; +} +it.each(["operator", "viewer"] as const)("establishes only the real public %s prerequisite and proves the person's own scope", async role => { + const f = fixture(role); const receipt = await enrollPrerequisitePerson(f.ctx, f.person, role); + expect(f.writes).toEqual([{ route: "/api/admin/users/person/company-access", body: { companyIds: ["company"] } }, { route: "/api/companies/company/members/membership/role-and-grants", body: { membershipRole: role, status: "active", grants: grantsForHumanRole(role) } }]); + expect(f.personalReads).toEqual(["/api/auth/get-session", "/api/companies/company", "/api/companies/company", "/api/admin/users"]); + expect(receipt).toMatchObject({ userId: "person", companyId: "company", role, beforeStatus: 403, companyReadStatus: 200, instanceAdminReadStatus: 403 }); + expect(f.evidence).toHaveBeenCalledOnce(); expect([...f.reads, ...f.writes.map(row => row.route)]).not.toEqual(expect.arrayContaining([expect.stringContaining("invites")])); +}); +it.each(["access.invite-teammate", "access.manage-membership", "activity.agent-feedback", "unknown"])("does not substitute public membership for unapproved/invitation journey %s", async scenarioId => { + const f = fixture(); Object.assign(f.ctx.scenario, { id: scenarioId }); f.ctx.input.execution.id = `chat-features.runner-opencode.local.${scenarioId}`; + await expect(enrollPrerequisitePerson(f.ctx, f.person)).rejects.toThrow("not admitted"); expect(f.api.get).not.toHaveBeenCalled(); expect(f.writes).toHaveLength(0); +}); +it("requires the exact OpenCode local execution rather than authorizing by scenario name alone", async () => { + const f = fixture(); f.ctx.input.execution.id = "chat-features.runner-codex.local.inbox-search.show-decisions"; + await expect(enrollPrerequisitePerson(f.ctx, f.person)).rejects.toThrow("not admitted"); expect(f.writes).toHaveLength(0); +}); +it.each(["administrator", "target administrator", "preexisting", "wrong person", "initial access"])("refuses %s before altering access", async invalid => { + const f = fixture(); + if (invalid === "administrator") f.state.administrator = false; + if (invalid === "target administrator") f.state.targetAdministrator = true; + if (invalid === "preexisting") f.state.preexisting = true; + if (invalid === "wrong person") f.state.personalId = "other"; + if (invalid === "initial access") f.state.beforeStatus = 200; + await expect(enrollPrerequisitePerson(f.ctx, f.person)).rejects.toThrow(); expect(f.writes).toHaveLength(0); expect(f.evidence).not.toHaveBeenCalled(); +}); +it.each(["wrong role", "extra grant", "foreign grant", "wrong author", "instance authority", "wrong company"])("rejects persisted %s or unauthorized own-session proof", async invalid => { + const f = fixture(); + if (invalid === "wrong role") f.state.readRole = "viewer"; + if (invalid === "extra grant") f.state.extraGrant = true; + if (invalid === "foreign grant") f.state.foreignGrant = true; + if (invalid === "wrong author") f.state.grantedBy = "other"; + if (invalid === "instance authority") f.state.adminStatus = 200; + if (invalid === "wrong company") f.state.companyReadId = "other"; + await expect(enrollPrerequisitePerson(f.ctx, f.person)).rejects.toThrow(); expect(f.evidence).not.toHaveBeenCalled(); +}); +it.each([0, -1, Number.NaN, undefined])("will not start setup without a finite positive original clock (%s)", async deadline => { + const f = fixture(); Object.assign(f.ctx, { observationDeadlineAt: deadline }); + await expect(enrollPrerequisitePerson(f.ctx, f.person)).rejects.toThrow("budget"); expect(f.api.get).not.toHaveBeenCalled(); expect(f.writes).toHaveLength(0); +}); +it("a slow public access write cannot reset the original clock or start role/grant mutation after expiry", async () => { + const f = fixture(); f.state.slowPut = true; + await expect(enrollPrerequisitePerson(f.ctx, f.person)).rejects.toThrow("budget"); expect(f.writes).toHaveLength(1); expect(f.api.patchSensitive).not.toHaveBeenCalled(); expect(f.evidence).not.toHaveBeenCalled(); +}); +it("the closed allowlist contains no access/invitation scenarios", () => { + expect(prerequisiteMembershipScenarioIds).toHaveLength(20); + expect(prerequisiteMembershipScenarioIds.some(id => id.startsWith("access."))).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 d1e2c9521fc..74347a6279d 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-profile.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-profile.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; import { checks } from "./internal-support.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { identityDefinition, identityObservation, observeIdentity, probeAgentPermission, waitForReply } from "./identity-support.js"; interface Session { user: { id: string; name: string; image?: string | null } } @@ -28,7 +28,7 @@ export const profileIdentityCases: readonly ChatCatalogCase[] = [identityDefinit 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); + await enrollPrerequisitePerson(ctx, observer); 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}`); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts b/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts index cd89b15ded4..d339e2025f6 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-questions-and-approvals.ts @@ -4,7 +4,7 @@ import { pollUntil } from "../api.js"; import { checks, project, entity, hasLink, putDocument } from "./internal-support.js"; import { identityDefinition, identityObservation, observeIdentity, openStandingChat, readAgentResource, waitForReply, type Interaction } from "./identity-support.js"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; const audiencePeople = new WeakMap>>(); async function cards(ctx: ChatCatalogContext) { @@ -144,7 +144,7 @@ export const questionIdentityCases = [ const renamed = await ctx.input.page.context().request.patch(`${ctx.input.api.baseURL}/api/auth/profile`, { data: { name: "Alex" }, headers: { Origin: ctx.input.api.baseURL } }); if (!renamed.ok()) throw new Error("Synthetic Alex profile setup failed"); const riley = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets, name: "Riley" }); audiencePeople.set(ctx, riley); - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, riley, secrets); + await enrollPrerequisitePerson(ctx, riley); const source = await openStandingChat(ctx); const original = await ctx.input.api.post(`/api/issues/${source.id}/interactions`, createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", title: "Riley's website proposal", resolverPolicy: "human_only", addresseeUserId: riley.id, diff --git a/tests/runner-e2e/chat-catalog-domains/identity-sidebar-order.ts b/tests/runner-e2e/chat-catalog-domains/identity-sidebar-order.ts index 420bd78cbb5..6f663f4eb0b 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-sidebar-order.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-sidebar-order.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; import { checks, entity, project } from "./internal-support.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { identityDefinition, identityObservation, probeAgentPermission, waitForReply } from "./identity-support.js"; type Person = Awaited>; const observers = new WeakMap(); @@ -40,7 +40,7 @@ export const sidebarOrderIdentityCases: readonly ChatCatalogCase[] = [identityDe const endpoint = `/api/companies/${ctx.input.fixtures.company.id}/sidebar-preferences/me`; await ctx.input.api.putSensitive(endpoint, { orderedIds: [first.id, launch.id] }); const observer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); observers.set(ctx, observer); - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, observer, ctx.input.privateSecrets); + await enrollPrerequisitePerson(ctx, observer); const saved = await observer.request(endpoint, "PUT", { orderedIds: [first.id, launch.id] }); if (!saved.ok) throw new Error("Riley's public sidebar fixture failed"); await saved.body?.cancel(); const before = await observer.request(endpoint); if (!before.ok) throw new Error("Riley sidebar read failed"); const observerBefore = (await before.json() as Order).orderedIds; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-decision-search.ts b/tests/runner-e2e/chat-catalog-domains/internal-decision-search.ts index c0990954bf9..b74db57aa9f 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-decision-search.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-decision-search.ts @@ -2,7 +2,7 @@ import { expect } from "@playwright/test"; import { createIssueThreadInteractionSchema } from "../../../packages/shared/src/validators/issue.js"; import type { ChatCatalogCase } from "../chat-catalog-contract.js"; import { makeDefinition } from "./internal-definition.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { mainPerson } from "./internal-privacy.js"; import { checks, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js"; interface Request extends Entity { addresseeUserId?: string; resolvedByUserId?: string; result?: unknown; payload?: unknown; decidedByUserId?: string | null } @@ -53,7 +53,7 @@ function showDecisions(): ChatCatalogCase { if (!browser || !ctx.input.privateSecrets) throw new Error("Decision audiences require an independent signed-in human and private credential redaction"); riley = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); try { - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, riley, ctx.input.privateSecrets, "operator"); + await enrollPrerequisitePerson(ctx, riley, "operator"); const p = await project(ctx); const t = await task(ctx, "Riley launch question", p.id); const history = await task(ctx, "Completed launch information", p.id); const question = (title: string, addresseeUserId: string) => createIssueThreadInteractionSchema.parse({ kind: "ask_user_questions", title, resolverPolicy: "human_only", addresseeUserId, continuationPolicy: "none", diff --git a/tests/runner-e2e/chat-catalog-domains/internal-inbox-triage.ts b/tests/runner-e2e/chat-catalog-domains/internal-inbox-triage.ts index a2b9d008188..ffd0a561f63 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-inbox-triage.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-inbox-triage.ts @@ -3,7 +3,7 @@ import { createIssueThreadInteractionSchema } from "../../../packages/shared/src import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; import { pollUntil } from "../api.js"; import { RunnerApiHttpError } from "../api.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { makeDefinition } from "./internal-definition.js"; import { checks, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js"; @@ -97,7 +97,7 @@ function triageCase(scenarioId: string): ChatCatalogCase { if (!browser || !ctx.input.privateSecrets) throw new Error("Personal triage requires authenticated independent people and private credential redaction"); other = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); try { - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, other, ctx.input.privateSecrets, "operator"); + await enrollPrerequisitePerson(ctx, other, "operator"); const session = await ctx.input.api.get<{ user?: { id: string } }>("/api/auth/get-session"); if (!session.user?.id) throw new Error("Personal inbox target is not a real authenticated human"); const p = await project(ctx); const originalTasks: Entity[] = []; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts index 631b0560ef2..26fb00c1caf 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { makeDefinition, fixtureWorker } from "./internal-definition.js"; import { project, task, entity, observation, checks, observeInternal, hasLink, sameTask, type Entity, type InternalObservation } from "./internal-support.js"; import { pollUntil } from "../api.js"; @@ -21,7 +21,7 @@ export async function observerPerson(ctx: ChatCatalogContext) { const browser = ctx.input.page.context().browser(); if (!browser || !ctx.input.privateSecrets) throw new Error("Independent authenticated observer requires a browser and attempt-private redaction sink"); const observer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); - try { await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, observer, ctx.input.privateSecrets, "viewer"); } + try { await enrollPrerequisitePerson(ctx, observer, "viewer"); } catch (error) { await observer.dispose(); throw error; } return observer; } diff --git a/tests/runner-e2e/chat-catalog-domains/internal-restore-concurrency.ts b/tests/runner-e2e/chat-catalog-domains/internal-restore-concurrency.ts index 76aeb3a3641..86218ee7a01 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-restore-concurrency.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-restore-concurrency.ts @@ -2,7 +2,7 @@ import { expect } from "@playwright/test"; import { randomUUID } from "node:crypto"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; import { pollUntil } from "../api.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { checks, entity, observation, observeInternal, sameTask, hasLink, type Entity, type InternalObservation } from "./internal-support.js"; interface Revision extends Entity { createdByUserId?: string | null } interface ConcurrentRestore extends InternalObservation { phaseReply: string } @@ -64,7 +64,7 @@ export function withRestoreConcurrency(primary: ChatCatalogCase): ChatCatalogCas sourceFiles: [...primary.sourceFiles, "chat-catalog-domains/internal-restore-concurrency.ts", "chat-catalog-domains/identity-auth.ts"], async setup(ctx) { const browser = ctx.input.page.context().browser(); if (!browser || !ctx.input.privateSecrets) throw new Error("Concurrent editor context requires actual authenticated people and private credential redaction"); editor = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Casey" }); - try { await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, editor, ctx.input.privateSecrets, "operator"); return { ...await primary.setup(ctx), editorId: editor.id }; } + try { await enrollPrerequisitePerson(ctx, editor, "operator"); return { ...await primary.setup(ctx), editorId: editor.id }; } catch (error) { await editor.dispose(); editor = undefined; throw error; } }, grade: value => gradeConcurrentRestore(value, primary.grade), async act(ctx, state) { diff --git a/tests/runner-e2e/chat-catalog-domains/internal-task-completion.ts b/tests/runner-e2e/chat-catalog-domains/internal-task-completion.ts index 484ef1b5f91..5e3c5e66e60 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-task-completion.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-task-completion.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { expect } from "@playwright/test"; import type { ChatCatalogCase } from "../chat-catalog-contract.js"; import { pollUntil } from "../api.js"; -import { createAuthenticatedPerson, enrollAuthenticatedPerson } from "./identity-auth.js"; +import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { fixtureWorker, makeDefinition } from "./internal-definition.js"; import { checks, entity, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js"; @@ -51,7 +51,7 @@ function completionGate(): ChatCatalogCase { const browser = ctx.input.page.context().browser(); if (!browser || !ctx.input.privateSecrets) throw new Error("Independent reviewer fixture requires a real authenticated browser and secret redactor"); reviewer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets: ctx.input.privateSecrets, name: "Riley" }); - await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, reviewer, ctx.input.privateSecrets, "operator"); + await enrollPrerequisitePerson(ctx, reviewer, "operator"); const worker = await fixtureWorker(ctx); const p = await project(ctx); const root = `/api/companies/${ctx.input.fixtures.company.id}`; for (const scope of [{ scopeType: "company", scopeId: ctx.input.fixtures.company.id }, { scopeType: "agent", scopeId: worker.id }])