diff --git a/__tests__/enterprise/invitation-accept.test.ts b/__tests__/enterprise/invitation-accept.test.ts index af1bf4fc4..6b9e9ee7e 100644 --- a/__tests__/enterprise/invitation-accept.test.ts +++ b/__tests__/enterprise/invitation-accept.test.ts @@ -15,6 +15,9 @@ * predicates do the right thing. */ +import { readFileSync } from "fs"; +import { join } from "path"; + import { isOnboardingBlocked } from "@/lib/enterprise/org-status"; describe("invitation-accept guards", () => { @@ -42,3 +45,33 @@ describe("invitation-accept guards", () => { // A full unit test would require mocking the entire $transaction // chain, which the integration smoke covers more robustly. }); + +// #819 — who-is-acting identity rule, pinned at the source level (the +// route handlers need the full auth/tx harness, which the integration +// smoke owns; these pins stop the gates from drifting between surfaces). +// Rule: identity creation requires the user's OWN action. Invitation +// accept is the user's consenting click, so LEARNER lazy-creates the +// lightweight ConsulteeProfile there (lib/auth.ts sanctions exactly this) +// while EXPERT stays strict. Admin direct-add is strict for BOTH roles. +describe("who-is-acting identity gates (#819)", () => { + // __dirname-relative so the pins survive jest being invoked from any cwd. + const read = (p: string) => + readFileSync(join(__dirname, "..", "..", p), "utf8"); + + it("invitation-accept keeps the EXPERT strict gate but NOT a LEARNER gate", () => { + const src = read("app/api/organizations/invitations/accept/route.ts"); + expect(src).toContain("NOT_A_CONSULTANT"); + expect(src).not.toContain("NOT_A_CONSULTEE"); + }); + + it("admin direct-add (POST /members) keeps strict gates for BOTH roles", () => { + const src = read("app/api/organizations/[orgId]/members/route.ts"); + expect(src).toContain("NOT_A_CONSULTANT"); + expect(src).toContain("NOT_A_CONSULTEE"); + }); + + it("the lazy-create sanction for invite-accept-as-LEARNER still stands in lib/auth.ts", () => { + const src = read("lib/auth.ts"); + expect(src).toContain("invite-accept as LEARNER"); + }); +}); diff --git a/app/api/organizations/[orgId]/members/route.ts b/app/api/organizations/[orgId]/members/route.ts index 9f2b29b70..1a409a826 100644 --- a/app/api/organizations/[orgId]/members/route.ts +++ b/app/api/organizations/[orgId]/members/route.ts @@ -265,15 +265,18 @@ export async function POST( } const userId = user.id; - // #729 §AC4/AC5 — strict identity gate. The shared - // `applyMembershipRoleEffects` helper lazy-creates the matching - // profile when one is missing, which silently promotes strangers - // to consultant / consumer identities. For the dashboard - // add-member surface we want the safer interpretation: the target - // must already have the correct profile before being added under - // that role. SSO JIT auto-join keeps the lazy-create path because - // that is a separate provisioning channel with its own - // authorization layer. + // #729 §AC4/AC5 + #819 — who-is-acting identity rule. Direct-add is an + // ADMIN acting on someone else, so BOTH roles require a pre-existing + // profile: the shared `applyMembershipRoleEffects` helper would + // otherwise lazy-create one and silently promote a stranger to a + // consultant / consumer identity they never asked for. Contrast + // invitation-accept, where the LEARNER gate is deliberately absent — + // accepting is the user's OWN consenting click, so the lightweight + // ConsulteeProfile lazy-creates there (EXPERT stays strict on every + // surface). SSO JIT auto-join keeps its lazy path as a separate + // provisioning channel with its own authorization layer. The + // who-is-acting pins in __tests__/enterprise/invitation-accept.test.ts + // keep the three surfaces from drifting apart. if (role === "EXPERT") { const existingConsultant = await prisma.consultantProfile.findUnique({ where: { userId }, diff --git a/app/api/organizations/invitations/accept/route.ts b/app/api/organizations/invitations/accept/route.ts index ee7a1c735..a554b2cef 100644 --- a/app/api/organizations/invitations/accept/route.ts +++ b/app/api/organizations/invitations/accept/route.ts @@ -214,14 +214,15 @@ export async function POST(req: NextRequest) { return { membership: existing, organization: org, alreadyMember: true }; } - // #729 §AC4/AC5 — strict identity gate. `applyMembershipRoleEffects` - // lazy-creates a missing profile, which would silently promote - // an invited user to consultant / consumer identity. For the - // dashboard-driven invitation accept flow we refuse instead: - // the invitee must already have the matching profile on - // Familiarise. SSO JIT auto-join keeps the lazy-create path - // because that is a separate provisioning channel with its own - // authorization layer. + // #729 §AC4/AC5 + #819 — who-is-acting identity rule. Accepting an + // invitation is the USER'S OWN consenting action, so the lightweight + // ConsulteeProfile may be lazy-created here (lib/auth.ts names + // "invite-accept as LEARNER" as a sanctioned creation point — gating + // it broke sponsored-employee onboarding). EXPERT stays strict: a + // consultant identity carries domain/rates/verification/payout + // prerequisites that no invite click can substitute for. Admin + // direct-add (POST /members) stays strict for BOTH roles, and SSO + // JIT keeps its own lazy path. if (normalizedRole === "EXPERT") { const existingConsultant = await tx.consultantProfile.findUnique({ where: { userId }, @@ -234,32 +235,15 @@ export async function POST(req: NextRequest) { ); } } - if (normalizedRole === "LEARNER") { - const existingConsultee = await tx.consulteeProfile.findUnique({ - where: { userId }, - select: { id: true }, - }); - if (!existingConsultee) { - throw Object.assign( - new Error("NOT_A_CONSULTEE"), - { httpStatus: 400 }, - ); - } - } // Profile FK + payoutRecipient defaults are computed by the // shared helper (see lib/api/organizations/membership-transitions.ts). - // LEARNER lazy-creates ConsulteeProfile; EXPERT lazy-creates - // ConsultantProfile with the "General" placeholder domain + - // WEEKLY schedule + PENDING_VERIFICATION (hidden from - // /explore/experts until a platform admin reviews). Operator - // roles (OWNER/MAINTAINER/MANAGER/SUPPORT) leave both FKs null. - // - // The EXPERT pre-check above means we'll never reach the - // lazy-create branch for EXPERT here — but we keep the call - // unchanged so LEARNER's lazy-create stays consistent with - // the rest of the flow. Multi-org experts are still - // first-class; see docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md. + // LEARNER lazy-creates ConsulteeProfile (first consumer action). + // Operator roles (OWNER/MAINTAINER/MANAGER/SUPPORT) leave both FKs + // null. The EXPERT pre-check above means the helper never reaches + // its EXPERT lazy-create branch from this surface. Multi-org experts + // and learners stay first-class; see + // docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md. const roleEffects = await applyMembershipRoleEffects(tx, { userId, role: normalizedRole,