From 9f944a0df8ea52dcaf209dce3d25ec17bc02b4c0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 07:38:32 -0400 Subject: [PATCH 1/8] fix(trust-portal): sync iso 27001 certification mapping with vendor-risk task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The Trust Centre subprocessor page shows incomplete compliance badges for Scaleway, displaying only GDPR while missing ISO/IEC 27001 certification that is verified in the Vendors tab. This misleads auditors and prospective customers about the vendor's security posture. ## Root cause The certification-to-badge mapping in trust-portal.service.ts normalizes cert names by stripping non-alphanumeric chars, turning "ISO/IEC 27001:2022" into "isoiec270012022". The check then looks for 'iso27001' or 'iso 27001' (the latter impossible post-normalization), so the cert is not recognized and gets dropped. The parallel code path in vendor-risk-assessment-task.ts was hardened in April to handle this (bare '27001' substring check), but trust-portal was left behind, creating an asymmetry. ## Fix Update the mapCertificationToBadgeType logic in trust-portal.service.ts to include a '27001' substring check, matching the vendor-risk-assessment-task implementation. This recognizes the normalized cert string and maps it correctly to the ISO 27001 badge type. ## Explicitly NOT touched Data in the Vendors tab (Capawesome) remains unchanged. The fix only corrects the mapping logic to properly recognize existing cert data. HDS badge handling is out of scope for this PR. ## Verification ✅ Scaleway vendor card now displays ISO 27001 badge alongside GDPR on Trust Centre Subprocessors page ✅ Badge set matches verified certifications from Vendors tab ✅ No regression on other vendor mappings --- .../trust-portal/trust-portal.service.spec.ts | 88 +++++++++++++++++++ .../src/trust-portal/trust-portal.service.ts | 12 +-- 2 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/trust-portal/trust-portal.service.spec.ts diff --git a/apps/api/src/trust-portal/trust-portal.service.spec.ts b/apps/api/src/trust-portal/trust-portal.service.spec.ts new file mode 100644 index 0000000000..3ff54e7ce5 --- /dev/null +++ b/apps/api/src/trust-portal/trust-portal.service.spec.ts @@ -0,0 +1,88 @@ +import { db } from '@db'; +import { TrustPortalService } from './trust-portal.service'; + +jest.mock('@db', () => ({ + db: { + vendor: { + findMany: jest.fn(), + update: jest.fn(), + }, + globalVendors: { + findUnique: jest.fn(), + }, + }, + Prisma: {}, + TrustFramework: { + iso_27001: 'iso_27001', + iso_42001: 'iso_42001', + gdpr: 'gdpr', + hipaa: 'hipaa', + soc2_type1: 'soc2_type1', + soc2_type2: 'soc2_type2', + pci_dss: 'pci_dss', + nen_7510: 'nen_7510', + iso_9001: 'iso_9001', + }, +})); + +jest.mock('../app/s3', () => ({ + APP_AWS_ORG_ASSETS_BUCKET: 'org-assets', + s3Client: { send: jest.fn() }, + getSignedUrl: jest.fn(), +})); + +const mockDb = db as unknown as { + vendor: { findMany: jest.Mock; update: jest.Mock }; + globalVendors: { findUnique: jest.Mock }; +}; + +describe('TrustPortalService getAllVendorsWithSync compliance badges', () => { + const service = new TrustPortalService(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Regression: Scaleway's GlobalVendors record lists "ISO/IEC 27001:2022", + // "HDS" and "GDPR Compliance" as verified, but the Trust Centre only showed + // GDPR. The "IEC" infix and ":2022" suffix caused the ISO 27001 certification + // to be dropped during badge sync, understating the vendor's posture. + it('maps "ISO/IEC 27001:2022" to the iso27001 badge alongside gdpr', async () => { + const baseVendor = { + id: 'vnd_scaleway', + name: 'Scaleway', + description: null, + website: 'scaleway.com', + showOnTrustPortal: true, + logoUrl: 'https://logo.example/scaleway.png', + complianceBadges: null, + trustPortalOrder: 0, + }; + + mockDb.vendor.findMany.mockResolvedValue([baseVendor]); + mockDb.globalVendors.findUnique.mockResolvedValue({ + riskAssessmentData: { + certifications: [ + { type: 'ISO/IEC 27001:2022', status: 'verified' }, + { type: 'HDS', status: 'verified' }, + { type: 'GDPR Compliance', status: 'verified' }, + ], + }, + }); + mockDb.vendor.update.mockImplementation( + async ({ data }: { data: Record }) => ({ + ...baseVendor, + ...data, + }), + ); + + const result = await service.getAllVendorsWithSync('org_1'); + + const badgeTypes = ( + result[0].complianceBadges as unknown as Array<{ type: string }> + ).map((badge) => badge.type); + + expect(badgeTypes).toContain('iso27001'); + expect(badgeTypes).toContain('gdpr'); + }); +}); diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index f58099f8a1..697ff92808 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -1950,10 +1950,11 @@ export class TrustPortalService { if (normalized.includes('soc2') || normalized.includes('soc 2')) return 'soc2'; - if (normalized.includes('iso27001') || normalized.includes('iso 27001')) - return 'iso27001'; - if (normalized.includes('iso42001') || normalized.includes('iso 42001')) - return 'iso42001'; + // Match ISO standards by their number. Vendors write these many ways + // ("ISO 27001", "ISO/IEC 27001:2022"), and the "IEC" infix breaks a naive + // includes('iso27001') check ("iso27001" is not a substring of "isoiec27001…"). + if (normalized.includes('27001')) return 'iso27001'; + if (normalized.includes('42001')) return 'iso42001'; if (normalized.includes('gdpr')) return 'gdpr'; if (normalized.includes('hipaa')) return 'hipaa'; if ( @@ -1964,8 +1965,7 @@ export class TrustPortalService { return 'pci_dss'; if (normalized.includes('nen7510') || normalized.includes('nen 7510')) return 'nen7510'; - if (normalized.includes('iso9001') || normalized.includes('iso 9001')) - return 'iso9001'; + if (normalized.includes('9001')) return 'iso9001'; return null; } From 2aa20e67c00514d0bc17aa8acae37b1a255de312 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 10:24:10 -0400 Subject: [PATCH 2/8] fix(auth): stop offboarded users from looping into a spurious new org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user is removed/offboarded from their only organization, their sole membership is soft-deleted (deactivated=true, isActive=false). GET /v1/auth/me filters those out, so the app saw the user as having zero organizations and silently redirected them to /setup — the onboarding flow. Completing it created a brand-new empty org and pointed the session at it, so every subsequent login re-entered onboarding: an offboarding loop that produced throwaway orgs and an "Unauthorized" screen on the real org. The three code paths that read membership disagreed on which memberships "count", so nothing distinguished a genuinely new user from an offboarded one. Fix (targeted, additive): - /v1/auth/me now returns hasInactiveMembership so the app can tell an offboarded user (0 active, >=1 deactivated) apart from a brand-new user (no memberships at all). - The landing page and the /setup entry route an offboarded user to a new /auth/access-removed interstitial instead of onboarding. New users and users adding an additional org are unaffected. - createOrganizationMinimal refuses to create an org for an offboarded user as a DB-level backstop (immune to a transient /v1/auth/me failure). The access-removed page's primary action is Sign out, since the common trigger is a domain change where the old account keeps signing in after being removed. Tests: page-level routing guard (new user vs offboarded vs pending invite) and the /v1/auth/me hasInactiveMembership computation. Refs: CS-569 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M6zcF9bvPP1UwZgzK21cw1 --- apps/api/src/auth/auth.controller.spec.ts | 105 ++++++++++++++++++ apps/api/src/auth/auth.controller.ts | 84 ++++++++------ .../actions/create-organization-minimal.ts | 30 +++++ apps/app/src/app/(app)/setup/route.ts | 15 +++ .../app/(public)/auth/access-removed/page.tsx | 34 ++++++ apps/app/src/app/page.test.tsx | 96 ++++++++++++++++ apps/app/src/app/page.tsx | 11 ++ 7 files changed, 339 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/auth/auth.controller.spec.ts create mode 100644 apps/app/src/app/(public)/auth/access-removed/page.tsx create mode 100644 apps/app/src/app/page.test.tsx diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000000..2831954d20 --- /dev/null +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -0,0 +1,105 @@ +const mockUserFindUnique = jest.fn(); +const mockMemberFindMany = jest.fn(); +const mockMemberCount = jest.fn(); +const mockInvitationFindFirst = jest.fn(); + +jest.mock('@db', () => ({ + db: { + user: { findUnique: (...a: unknown[]) => mockUserFindUnique(...a) }, + member: { + findMany: (...a: unknown[]) => mockMemberFindMany(...a), + count: (...a: unknown[]) => mockMemberCount(...a), + }, + invitation: { findFirst: (...a: unknown[]) => mockInvitationFindFirst(...a) }, + }, +})); + +// Both guards import ./auth.server, which validates SECRET_KEY and pulls in +// better-auth/redis at module load. Stub them so importing the controller is +// hermetic — the test calls the method directly and never runs the guards. +jest.mock('./hybrid-auth.guard', () => ({ HybridAuthGuard: class {} })); +jest.mock('./permission.guard', () => ({ + PermissionGuard: class {}, + PERMISSIONS_KEY: 'permissions', +})); + +import { AuthController } from './auth.controller'; +import type { AuthContext } from './types'; + +const sessionContext = (): AuthContext => ({ + organizationId: 'org_1', + authType: 'session', + isApiKey: false, + isPlatformAdmin: false, + userRoles: null, + userId: 'user_1', + userEmail: 'user@example.com', +}); + +describe('AuthController.getMe — hasInactiveMembership (CS-569)', () => { + const controller = new AuthController(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUserFindUnique.mockResolvedValue({ + id: 'user_1', + email: 'user@example.com', + name: 'User', + image: null, + role: 'user', + }); + mockInvitationFindFirst.mockResolvedValue(null); + }); + + it('returns hasInactiveMembership: false for a genuinely new user (no memberships at all)', async () => { + mockMemberFindMany.mockResolvedValue([]); + mockMemberCount.mockResolvedValue(0); + + const res = await controller.getMe(sessionContext()); + + expect(res.organizations).toEqual([]); + expect(res.hasInactiveMembership).toBe(false); + }); + + it('returns hasInactiveMembership: true for an offboarded user (no active org, only deactivated memberships)', async () => { + mockMemberFindMany.mockResolvedValue([]); // no ACTIVE memberships + mockMemberCount.mockResolvedValue(1); // one deactivated/inactive membership + + const res = await controller.getMe(sessionContext()); + + expect(res.organizations).toEqual([]); + expect(res.hasInactiveMembership).toBe(true); + // The count that distinguishes "offboarded" from "new" must match + // memberships that are deactivated OR no longer active. + expect(mockMemberCount).toHaveBeenCalledWith({ + where: { + userId: 'user_1', + OR: [{ deactivated: true }, { isActive: false }], + }, + }); + }); + + it('returns hasInactiveMembership: false for an active member', async () => { + mockMemberFindMany.mockResolvedValue([ + { + id: 'member_1', + role: 'owner', + organizationId: 'org_1', + organization: { + id: 'org_1', + name: 'Org', + logo: null, + onboardingCompleted: true, + hasAccess: true, + createdAt: new Date(), + }, + }, + ]); + mockMemberCount.mockResolvedValue(0); + + const res = await controller.getMe(sessionContext()); + + expect(res.organizations).toHaveLength(1); + expect(res.hasInactiveMembership).toBe(false); + }); +}); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 77d4126c3d..5848b72778 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -35,44 +35,55 @@ export class AuthController { return { user: null, organizations: [], pendingInvitation: null }; } - const [user, memberships, pendingInvitation] = await Promise.all([ - db.user.findUnique({ - where: { id: userId }, - select: { - id: true, - email: true, - name: true, - image: true, - role: true, - }, - }), - db.member.findMany({ - where: { userId, isActive: true, deactivated: false }, - select: { - id: true, - role: true, - organizationId: true, - organization: { - select: { - id: true, - name: true, - logo: true, - onboardingCompleted: true, - hasAccess: true, - createdAt: true, + const [user, memberships, pendingInvitation, inactiveMembershipCount] = + await Promise.all([ + db.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + image: true, + role: true, + }, + }), + db.member.findMany({ + where: { userId, isActive: true, deactivated: false }, + select: { + id: true, + role: true, + organizationId: true, + organization: { + select: { + id: true, + name: true, + logo: true, + onboardingCompleted: true, + hasAccess: true, + createdAt: true, + }, }, }, - }, - orderBy: { createdAt: 'desc' }, - }), - db.invitation.findFirst({ - where: { - email: authContext.userEmail ?? '', - status: 'pending', - }, - select: { id: true }, - }), - ]); + orderBy: { createdAt: 'desc' }, + }), + db.invitation.findFirst({ + where: { + email: authContext.userEmail ?? '', + status: 'pending', + }, + select: { id: true }, + }), + // Count memberships that exist but are no longer active (deactivated + // or removed). Lets the app tell a genuinely new user (no memberships + // at all → onboarding) apart from an offboarded user whose access was + // revoked (→ "access removed" instead of a spurious new org). CS-569. + db.member.count({ + where: { + userId, + OR: [{ deactivated: true }, { isActive: false }], + }, + }), + ]); return { user, @@ -82,6 +93,7 @@ export class AuthController { memberId: m.id, })), pendingInvitation, + hasInactiveMembership: inactiveMembershipCount > 0, }; } diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 665fd5eb53..8fd3ff9398 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -43,6 +43,36 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }; } + // CS-569 backstop: never create an org for a user whose only memberships + // are deactivated (0 active, >=1 inactive) — that user was offboarded and + // the routing layer already sends them to /auth/access-removed. This + // guards against a stale/replayed form POST slipping past that redirect + // and spawning a spurious empty org. Genuinely new users (no memberships) + // and users adding an additional org (have active memberships) pass. + const [activeMembershipCount, inactiveMembershipCount] = await Promise.all([ + db.member.count({ + where: { + userId: session.user.id, + isActive: true, + deactivated: false, + }, + }), + db.member.count({ + where: { + userId: session.user.id, + OR: [{ deactivated: true }, { isActive: false }], + }, + }), + ]); + + if (activeMembershipCount === 0 && inactiveMembershipCount > 0) { + return { + success: false, + error: + 'Your access to this organization was removed. Contact your administrator to be re-invited.', + }; + } + // Internal team accounts (verified @trycomp.ai) have access provisioned up front. const userEmail = session.user.email; const isVerifiedTryCompEmail = diff --git a/apps/app/src/app/(app)/setup/route.ts b/apps/app/src/app/(app)/setup/route.ts index fe0471cd90..647f7886e4 100644 --- a/apps/app/src/app/(app)/setup/route.ts +++ b/apps/app/src/app/(app)/setup/route.ts @@ -1,3 +1,4 @@ +import { serverApi } from '@/lib/api-server'; import { auth } from '@/utils/auth'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -17,6 +18,20 @@ export async function GET(request: NextRequest) { redirect(`/sign-in${queryString}`); } + // CS-569: guard the onboarding loop at the setup entry too (direct nav or the + // create-additional intent). A user with 0 active memberships but >=1 + // deactivated one was offboarded — never let them create a (spurious) org. + // New users (no memberships) and users adding an additional org (have active + // memberships) fall through unchanged. + const meRes = await serverApi.get<{ + organizations: unknown[]; + hasInactiveMembership?: boolean; + }>('/v1/auth/me'); + const activeOrgCount = meRes.data?.organizations?.length ?? 0; + if (activeOrgCount === 0 && meRes.data?.hasInactiveMembership) { + redirect('/auth/access-removed'); + } + const setupSession = await createSetupSession(session.user.id); redirect(`/setup/${setupSession.id}${queryString}`); } diff --git a/apps/app/src/app/(public)/auth/access-removed/page.tsx b/apps/app/src/app/(public)/auth/access-removed/page.tsx new file mode 100644 index 0000000000..5f2bbacd2f --- /dev/null +++ b/apps/app/src/app/(public)/auth/access-removed/page.tsx @@ -0,0 +1,34 @@ +import { SignOut } from '@/components/sign-out'; + +/** + * Shown to a signed-in user who has no active organization membership but was + * previously a member (all memberships deactivated/removed) — i.e. they were + * offboarded. Without this, such a user was silently dropped into onboarding, + * which spawned a spurious empty org and locked them into a loop (CS-569). + * + * The most common cause is a domain change: the old account keeps signing in + * after being offboarded. The primary action is therefore "sign out" so they + * can sign back in with their current account. + */ +export default function AccessRemovedPage() { + return ( +
+
+
+

+ {'Your access was removed'} +

+

+ { + 'This account is no longer a member of any organization. If your company recently changed email domains or offboarded this account, sign out and sign back in with your current account. Otherwise, contact your administrator to be re-invited.' + } +

+
+ +
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/page.test.tsx b/apps/app/src/app/page.test.tsx new file mode 100644 index 0000000000..ba887671fe --- /dev/null +++ b/apps/app/src/app/page.test.tsx @@ -0,0 +1,96 @@ +import { vi } from 'vitest'; + +// Mock the auth module before importing the page so getSession is controllable. +vi.mock('@/utils/auth', async () => { + const { mockAuth } = await import('@/test-utils/mocks/auth'); + return { auth: mockAuth }; +}); + +// serverApi.get('/v1/auth/me') drives every routing decision on this page. +// vi.hoisted so the fn exists when the hoisted vi.mock factory runs. +const { mockServerApiGet } = vi.hoisted(() => ({ mockServerApiGet: vi.fn() })); +vi.mock('@/lib/api-server', () => ({ + serverApi: { get: mockServerApiGet }, +})); + +// The page imports `db` from '@db/server' (only used for custom-role +// resolution, which the zero-org redirect paths never reach). Stub it so the +// test doesn't pull the real Prisma client. +vi.mock('@db/server', () => ({ db: {} })); + +// '@/lib/permissions' pulls in @trycompai/auth (a built package) and is only +// used past the zero-org redirects. Stub it so the test stays hermetic. +vi.mock('@/lib/permissions', () => ({ + getDefaultRoute: vi.fn(() => '/'), + mergePermissions: vi.fn(), + resolveBuiltInPermissions: vi.fn(() => ({ permissions: {}, customRoleNames: [] })), +})); + +import { describe, it, expect, beforeEach } from 'vitest'; +import { redirect } from 'next/navigation'; +import Page from './page'; +import { mockAuthApi, createMockSession, createMockUser } from '@/test-utils/mocks/auth'; + +const mockRedirect = vi.mocked(redirect); + +interface MeData { + organizations: unknown[]; + pendingInvitation: { id: string } | null; + hasInactiveMembership?: boolean; +} + +const mockMe = (data: MeData) => { + mockServerApiGet.mockResolvedValue({ data, status: 200 }); +}; + +describe('RootPage onboarding-loop guard (CS-569)', () => { + beforeEach(() => { + vi.clearAllMocks(); + // The real Next.js redirect() throws to halt rendering. Emulate that so + // execution stops at the first redirect and we can assert its target. + mockRedirect.mockImplementation((url: string) => { + throw new Error(`REDIRECT:${url}`); + }); + mockAuthApi.getSession.mockResolvedValue({ + session: createMockSession(), + user: createMockUser(), + }); + }); + + it('sends an offboarded user (no active org, has a deactivated membership) to the access-removed page, NOT onboarding', async () => { + // This is the bug: without the guard, this user was redirected to /setup, + // which silently spawned a spurious empty org and locked them into a loop. + mockMe({ organizations: [], pendingInvitation: null, hasInactiveMembership: true }); + + await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'REDIRECT:/auth/access-removed', + ); + + expect(mockRedirect).toHaveBeenCalledWith('/auth/access-removed'); + expect(mockRedirect).not.toHaveBeenCalledWith('/setup'); + }); + + it('still sends a genuinely new user (no memberships at all) to onboarding', async () => { + mockMe({ organizations: [], pendingInvitation: null, hasInactiveMembership: false }); + + await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow('REDIRECT:/setup'); + + expect(mockRedirect).toHaveBeenCalledWith('/setup'); + expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed'); + }); + + it('prefers a pending invitation over the access-removed page', async () => { + mockMe({ + organizations: [], + pendingInvitation: { id: 'inv_abc123' }, + hasInactiveMembership: true, + }); + + await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'REDIRECT:/invite/inv_abc123', + ); + + expect(mockRedirect).toHaveBeenCalledWith('/invite/inv_abc123'); + expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed'); + }); +}); diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index d2124d7f40..56b293d28f 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -15,6 +15,9 @@ interface OrgInfo { interface AuthMeResponse { organizations: OrgInfo[]; pendingInvitation: { id: string } | null; + // True when the user has memberships that are all deactivated/removed + // (i.e. they were offboarded), used to avoid the onboarding loop (CS-569). + hasInactiveMembership?: boolean; } export default async function RootPage({ @@ -59,6 +62,14 @@ export default async function RootPage({ if (pendingInvitation) { return redirect(await buildUrlWithParams(`/invite/${pendingInvitation.id}`)); } + // CS-569: a user whose only memberships are deactivated (offboarded) has + // no active org. Do NOT drop them into onboarding — that silently spawns a + // spurious empty org and locks them into an onboarding loop. Tell them + // their access was removed instead. Genuinely new users (no memberships at + // all) still go to /setup. + if (meRes.data?.hasInactiveMembership) { + return redirect(await buildUrlWithParams('/auth/access-removed')); + } return redirect(await buildUrlWithParams('/setup')); } From 3b426f7c8137865a9173a52808de63caa7b76a44 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 10:39:44 -0400 Subject: [PATCH 3/8] fix(trust-portal): tighten ISO cert badge matching to require the ISO prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem mapCertificationToBadgeType matched ISO standards with bare-number substring checks (`includes('27001')`, `includes('42001')`, `includes('9001')`). Any certification whose type merely contained those digits — e.g. a catalog id "19001" or a vendor-specific naming scheme — was misclassified as the corresponding ISO badge, surfacing a certification the vendor never held on the public Trust Centre. Cubic flagged the `9001` case (PR #3315); `27001` and `42001` shared the identical bug class. ## Fix Match by number but require an "iso" prefix via a bounded regex `/iso(?:iec)?/`. The optional "iec" preserves the original PR #3315 fix for joint ISO/IEC standards, whose "IEC" infix breaks a naive `includes('iso27001')` check ("ISO/IEC 27001:2022" normalizes to "isoiec270012022"). The digits alone are no longer enough to match. ## Tests - ISO 9001:2015 -> iso9001 (positive) - ISO/IEC 42001:2023 -> iso42001 (iec-infix parity, previously untested) - "Catalog 19001" does NOT map to iso9001, while a real GDPR cert still maps (the flagged false-positive regression) - Existing ISO/IEC 27001:2022 regression still passes Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BZotUzpY9RZt9JDsEJyrWS --- .../trust-portal/trust-portal.service.spec.ts | 66 +++++++++++++++++++ .../src/trust-portal/trust-portal.service.ts | 14 ++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/api/src/trust-portal/trust-portal.service.spec.ts b/apps/api/src/trust-portal/trust-portal.service.spec.ts index 3ff54e7ce5..3d24eb4158 100644 --- a/apps/api/src/trust-portal/trust-portal.service.spec.ts +++ b/apps/api/src/trust-portal/trust-portal.service.spec.ts @@ -43,6 +43,39 @@ describe('TrustPortalService getAllVendorsWithSync compliance badges', () => { jest.clearAllMocks(); }); + // Runs a set of verified certifications through the badge-sync path and + // returns the resulting badge types (empty when nothing maps). + const badgeTypesFor = async ( + certifications: Array<{ type: string; status: string }>, + ): Promise => { + const baseVendor = { + id: 'vnd_1', + name: 'Acme', + description: null, + website: 'acme.com', + showOnTrustPortal: true, + logoUrl: 'https://logo.example/acme.png', + complianceBadges: null, + trustPortalOrder: 0, + }; + + mockDb.vendor.findMany.mockResolvedValue([baseVendor]); + mockDb.globalVendors.findUnique.mockResolvedValue({ + riskAssessmentData: { certifications }, + }); + mockDb.vendor.update.mockImplementation( + async ({ data }: { data: Record }) => ({ + ...baseVendor, + ...data, + }), + ); + + const result = await service.getAllVendorsWithSync('org_1'); + const badges = + (result[0].complianceBadges as unknown as Array<{ type: string }>) ?? []; + return badges.map((badge) => badge.type); + }; + // Regression: Scaleway's GlobalVendors record lists "ISO/IEC 27001:2022", // "HDS" and "GDPR Compliance" as verified, but the Trust Centre only showed // GDPR. The "IEC" infix and ":2022" suffix caused the ISO 27001 certification @@ -85,4 +118,37 @@ describe('TrustPortalService getAllVendorsWithSync compliance badges', () => { expect(badgeTypes).toContain('iso27001'); expect(badgeTypes).toContain('gdpr'); }); + + it('maps "ISO 9001:2015" to the iso9001 badge', async () => { + const badgeTypes = await badgeTypesFor([ + { type: 'ISO 9001:2015', status: 'verified' }, + ]); + + expect(badgeTypes).toContain('iso9001'); + }); + + // The "IEC" infix must be tolerated for every joint ISO/IEC standard, not + // just 27001 — otherwise ISO/IEC 42001 certifications are silently dropped. + it('maps "ISO/IEC 42001:2023" to the iso42001 badge', async () => { + const badgeTypes = await badgeTypesFor([ + { type: 'ISO/IEC 42001:2023', status: 'verified' }, + ]); + + expect(badgeTypes).toContain('iso42001'); + }); + + // Regression: a bare substring match on the digits ("9001") misclassified + // unrelated identifiers that merely contain them (e.g. a catalog id "19001") + // as ISO 9001, surfacing a certification the vendor never held. The match now + // requires the "ISO" prefix, so the digits alone are not enough. + it('does not misclassify an unrelated id containing "9001" as iso9001', async () => { + const badgeTypes = await badgeTypesFor([ + { type: 'Catalog 19001', status: 'verified' }, + { type: 'GDPR Compliance', status: 'verified' }, + ]); + + expect(badgeTypes).not.toContain('iso9001'); + // Sanity check that the sync path still ran and produced real badges. + expect(badgeTypes).toContain('gdpr'); + }); }); diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index 697ff92808..e5fbee516f 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -1950,11 +1950,13 @@ export class TrustPortalService { if (normalized.includes('soc2') || normalized.includes('soc 2')) return 'soc2'; - // Match ISO standards by their number. Vendors write these many ways - // ("ISO 27001", "ISO/IEC 27001:2022"), and the "IEC" infix breaks a naive - // includes('iso27001') check ("iso27001" is not a substring of "isoiec27001…"). - if (normalized.includes('27001')) return 'iso27001'; - if (normalized.includes('42001')) return 'iso42001'; + // Match ISO standards by their number, but require an "iso" prefix so that + // unrelated ids that merely contain the digits (e.g. a catalog id "19001") + // aren't misclassified. The optional "iec" handles joint ISO/IEC standards, + // whose "IEC" infix would otherwise break a naive includes('iso27001') check + // ("ISO/IEC 27001:2022" normalizes to "isoiec270012022"). + if (/iso(?:iec)?27001/.test(normalized)) return 'iso27001'; + if (/iso(?:iec)?42001/.test(normalized)) return 'iso42001'; if (normalized.includes('gdpr')) return 'gdpr'; if (normalized.includes('hipaa')) return 'hipaa'; if ( @@ -1965,7 +1967,7 @@ export class TrustPortalService { return 'pci_dss'; if (normalized.includes('nen7510') || normalized.includes('nen 7510')) return 'nen7510'; - if (normalized.includes('9001')) return 'iso9001'; + if (/iso(?:iec)?9001/.test(normalized)) return 'iso9001'; return null; } From afb759eb297a7e4599287aa7a4f7ffa5be4625ea Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 10:46:26 -0400 Subject: [PATCH 4/8] fix(auth): let invite flows take precedence over the offboard guard at /setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic review (CS-569): the /setup entry route ran the offboarded-user redirect before invite handling, so an offboarded user with a pending invite entering via /setup?inviteCode=... (handled downstream by /setup/[setupId]) was bounced to /auth/access-removed before the invite could be accepted — a dead-end for the legitimate re-invite flow. Mirror the root landing page's precedence: skip the guard when an ?inviteCode= is present (handled downstream), and for an offboarded user with a pending invitation redirect to /invite/{id} instead of the access-removed dead-end. New/active users are unaffected. Adds route tests: offboarded+no-invite -> access-removed; offboarded+?inviteCode -> passes through; offboarded+pendingInvitation -> /invite/{id}; new user -> onboarding. Refs: CS-569 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M6zcF9bvPP1UwZgzK21cw1 --- apps/app/src/app/(app)/setup/route.test.ts | 80 ++++++++++++++++++++++ apps/app/src/app/(app)/setup/route.ts | 36 ++++++---- 2 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 apps/app/src/app/(app)/setup/route.test.ts diff --git a/apps/app/src/app/(app)/setup/route.test.ts b/apps/app/src/app/(app)/setup/route.test.ts new file mode 100644 index 0000000000..5e792b1057 --- /dev/null +++ b/apps/app/src/app/(app)/setup/route.test.ts @@ -0,0 +1,80 @@ +import { vi } from 'vitest'; + +vi.mock('@/utils/auth', async () => { + const { mockAuth } = await import('@/test-utils/mocks/auth'); + return { auth: mockAuth }; +}); + +const { mockServerApiGet, mockCreateSetupSession } = vi.hoisted(() => ({ + mockServerApiGet: vi.fn(), + mockCreateSetupSession: vi.fn(async () => ({ id: 'setup_1' })), +})); +vi.mock('@/lib/api-server', () => ({ serverApi: { get: mockServerApiGet } })); +vi.mock('./lib/setup-session', () => ({ createSetupSession: mockCreateSetupSession })); + +import { describe, it, expect, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { redirect } from 'next/navigation'; +import { GET } from './route'; +import { mockAuthApi, createMockSession, createMockUser } from '@/test-utils/mocks/auth'; + +const mockRedirect = vi.mocked(redirect); + +interface MeData { + organizations: unknown[]; + pendingInvitation: { id: string } | null; + hasInactiveMembership?: boolean; +} +const mockMe = (data: MeData) => mockServerApiGet.mockResolvedValue({ data, status: 200 }); +const call = (url: string) => GET(new NextRequest(url)); + +describe('/setup route — CS-569 offboard guard vs invite precedence', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSetupSession.mockResolvedValue({ id: 'setup_1' }); + mockRedirect.mockImplementation((url: string) => { + throw new Error(`REDIRECT:${url}`); + }); + mockAuthApi.getSession.mockResolvedValue({ + session: createMockSession(), + user: createMockUser(), + }); + }); + + it('redirects an offboarded user with no invite to access-removed', async () => { + mockMe({ organizations: [], pendingInvitation: null, hasInactiveMembership: true }); + + await expect(call('http://localhost/setup')).rejects.toThrow('REDIRECT:/auth/access-removed'); + expect(mockCreateSetupSession).not.toHaveBeenCalled(); + }); + + it('does NOT block an offboarded user arriving via ?inviteCode= (invite handled downstream)', async () => { + // Regression (cubic): the guard must not pre-empt the legacy invite entry. + mockMe({ organizations: [], pendingInvitation: null, hasInactiveMembership: true }); + + await expect(call('http://localhost/setup?inviteCode=inv_abc')).rejects.toThrow( + 'REDIRECT:/setup/setup_1?inviteCode=inv_abc', + ); + // Guard is skipped entirely when an invite code is present. + expect(mockServerApiGet).not.toHaveBeenCalled(); + expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed'); + }); + + it('routes an offboarded user with a pending invitation to the invite, not access-removed', async () => { + mockMe({ + organizations: [], + pendingInvitation: { id: 'inv_xyz' }, + hasInactiveMembership: true, + }); + + await expect(call('http://localhost/setup')).rejects.toThrow('REDIRECT:/invite/inv_xyz'); + expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed'); + }); + + it('lets a genuinely new user through to onboarding', async () => { + mockMe({ organizations: [], pendingInvitation: null, hasInactiveMembership: false }); + + await expect(call('http://localhost/setup')).rejects.toThrow('REDIRECT:/setup/setup_1'); + expect(mockCreateSetupSession).toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/app/(app)/setup/route.ts b/apps/app/src/app/(app)/setup/route.ts index 647f7886e4..290692bbfd 100644 --- a/apps/app/src/app/(app)/setup/route.ts +++ b/apps/app/src/app/(app)/setup/route.ts @@ -18,18 +18,30 @@ export async function GET(request: NextRequest) { redirect(`/sign-in${queryString}`); } - // CS-569: guard the onboarding loop at the setup entry too (direct nav or the - // create-additional intent). A user with 0 active memberships but >=1 - // deactivated one was offboarded — never let them create a (spurious) org. - // New users (no memberships) and users adding an additional org (have active - // memberships) fall through unchanged. - const meRes = await serverApi.get<{ - organizations: unknown[]; - hasInactiveMembership?: boolean; - }>('/v1/auth/me'); - const activeOrgCount = meRes.data?.organizations?.length ?? 0; - if (activeOrgCount === 0 && meRes.data?.hasInactiveMembership) { - redirect('/auth/access-removed'); + // Invite flows take precedence over the CS-569 offboard guard: a raw + // ?inviteCode= is turned into /invite/{code} downstream by /setup/[setupId], + // so let it flow through untouched rather than pre-empting it here. + const hasInviteCode = request.nextUrl.searchParams.has('inviteCode'); + if (!hasInviteCode) { + // CS-569: guard the onboarding loop at the setup entry too (direct nav or + // the create-additional intent). A user with 0 active memberships but >=1 + // deactivated one was offboarded — never let them create a (spurious) org. + // New users (no memberships) and users adding an additional org (have + // active memberships) fall through unchanged. + const meRes = await serverApi.get<{ + organizations: unknown[]; + pendingInvitation: { id: string } | null; + hasInactiveMembership?: boolean; + }>('/v1/auth/me'); + const activeOrgCount = meRes.data?.organizations?.length ?? 0; + if (activeOrgCount === 0 && meRes.data?.hasInactiveMembership) { + // A pending invitation is the offboarded user's legitimate way back in — + // honor it before the access-removed dead-end (mirrors the root page). + if (meRes.data?.pendingInvitation) { + redirect(`/invite/${meRes.data.pendingInvitation.id}`); + } + redirect('/auth/access-removed'); + } } const setupSession = await createSetupSession(session.user.id); From 514e0e5bf20f27826c2c6d3cc5ad08b1eced6298 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 10:46:35 -0400 Subject: [PATCH 5/8] fix(trust-portal): bound ISO cert regex so a number can't match a longer one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem (cubic review, PR #3318) The ISO badge regexes matched the standard number as a bare substring, so an "iso"-prefixed value with extra trailing digits — e.g. "ISO 90010" (normalizes to "iso90010") — still matched "iso9001" and earned the ISO 9001 badge. The same held for 27001/42001 (e.g. ISO/IEC 27017 shares the "2701" prefix with 27001). ## Fix Append "(?:\d{4})?(?!\d)" to each ISO regex: allow an optional 4-digit year (the only digits that legitimately follow a standard number, e.g. ":2015"/ ":2022") but forbid any other trailing digit. This keeps year suffixes working while rejecting longer numbers that merely start with the standard number. ## Tests - "ISO 90010" does NOT map to iso9001 (cubic's case), GDPR on the same vendor still maps - "ISO/IEC 27017:2015" does NOT map to iso27001 (real distinct standard) - All prior positives still pass: ISO 9001:2015, ISO/IEC 42001:2023, ISO/IEC 27001:2022 -> 6/6 in the suite Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BZotUzpY9RZt9JDsEJyrWS --- .../trust-portal/trust-portal.service.spec.ts | 23 +++++++++++++++++++ .../src/trust-portal/trust-portal.service.ts | 10 ++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/api/src/trust-portal/trust-portal.service.spec.ts b/apps/api/src/trust-portal/trust-portal.service.spec.ts index 3d24eb4158..b8dedc7e60 100644 --- a/apps/api/src/trust-portal/trust-portal.service.spec.ts +++ b/apps/api/src/trust-portal/trust-portal.service.spec.ts @@ -151,4 +151,27 @@ describe('TrustPortalService getAllVendorsWithSync compliance badges', () => { // Sanity check that the sync path still ran and produced real badges. expect(badgeTypes).toContain('gdpr'); }); + + // Regression: the ISO number must not match as a prefix of a longer number. + // "ISO 90010" normalizes to "iso90010" and would otherwise match iso9001; + // only an optional 4-digit year (e.g. ":2015") may follow the standard number. + it('does not read "ISO 90010" as the iso9001 badge', async () => { + const badgeTypes = await badgeTypesFor([ + { type: 'ISO 90010', status: 'verified' }, + { type: 'GDPR Compliance', status: 'verified' }, + ]); + + expect(badgeTypes).not.toContain('iso9001'); + expect(badgeTypes).toContain('gdpr'); + }); + + // Real-world boundary: ISO/IEC 27017 (cloud security) shares the "2701" + // prefix with 27001 but is a distinct standard and must not earn its badge. + it('does not read "ISO/IEC 27017:2015" as the iso27001 badge', async () => { + const badgeTypes = await badgeTypesFor([ + { type: 'ISO/IEC 27017:2015', status: 'verified' }, + ]); + + expect(badgeTypes).not.toContain('iso27001'); + }); }); diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index e5fbee516f..bfbf3094a3 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -1954,9 +1954,11 @@ export class TrustPortalService { // unrelated ids that merely contain the digits (e.g. a catalog id "19001") // aren't misclassified. The optional "iec" handles joint ISO/IEC standards, // whose "IEC" infix would otherwise break a naive includes('iso27001') check - // ("ISO/IEC 27001:2022" normalizes to "isoiec270012022"). - if (/iso(?:iec)?27001/.test(normalized)) return 'iso27001'; - if (/iso(?:iec)?42001/.test(normalized)) return 'iso42001'; + // ("ISO/IEC 27001:2022" normalizes to "isoiec270012022"). The trailing + // "(?:\d{4})?(?!\d)" allows an optional 4-digit year ("...:2022") but forbids + // any other trailing digit, so "ISO 90010" is not read as "ISO 9001". + if (/iso(?:iec)?27001(?:\d{4})?(?!\d)/.test(normalized)) return 'iso27001'; + if (/iso(?:iec)?42001(?:\d{4})?(?!\d)/.test(normalized)) return 'iso42001'; if (normalized.includes('gdpr')) return 'gdpr'; if (normalized.includes('hipaa')) return 'hipaa'; if ( @@ -1967,7 +1969,7 @@ export class TrustPortalService { return 'pci_dss'; if (normalized.includes('nen7510') || normalized.includes('nen 7510')) return 'nen7510'; - if (/iso(?:iec)?9001/.test(normalized)) return 'iso9001'; + if (/iso(?:iec)?9001(?:\d{4})?(?!\d)/.test(normalized)) return 'iso9001'; return null; } From 707e28e88979b41bb6ff60132fda962f6f5396f3 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 10:49:40 -0400 Subject: [PATCH 6/8] refactor(auth): centralize the CS-569 no-active-org routing decision The offboard-guard decision was hand-written in two places (app/page.tsx and the /setup route), which risked exactly the kind of drift between paths that caused CS-569. Extract it into a single shared helper, resolveNoActiveOrgRedirect (invite > offboarded > new user), and use it from both call sites so they can't diverge. Also harden the /setup invite short-circuit: read the ?inviteCode= value and treat an empty one as "no invite" (matching the downstream truthy check in /setup/[setupId]) instead of only checking key presence. Behavior is unchanged; adds a unit test for the shared decision. Refs: CS-569 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M6zcF9bvPP1UwZgzK21cw1 --- apps/app/src/app/(app)/setup/route.ts | 28 +++++++-------- apps/app/src/app/page.tsx | 19 ++++------- .../src/lib/no-active-org-redirect.test.ts | 31 +++++++++++++++++ apps/app/src/lib/no-active-org-redirect.ts | 34 +++++++++++++++++++ 4 files changed, 85 insertions(+), 27 deletions(-) create mode 100644 apps/app/src/lib/no-active-org-redirect.test.ts create mode 100644 apps/app/src/lib/no-active-org-redirect.ts diff --git a/apps/app/src/app/(app)/setup/route.ts b/apps/app/src/app/(app)/setup/route.ts index 290692bbfd..c8033d73d7 100644 --- a/apps/app/src/app/(app)/setup/route.ts +++ b/apps/app/src/app/(app)/setup/route.ts @@ -1,4 +1,5 @@ import { serverApi } from '@/lib/api-server'; +import { resolveNoActiveOrgRedirect } from '@/lib/no-active-org-redirect'; import { auth } from '@/utils/auth'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -20,27 +21,26 @@ export async function GET(request: NextRequest) { // Invite flows take precedence over the CS-569 offboard guard: a raw // ?inviteCode= is turned into /invite/{code} downstream by /setup/[setupId], - // so let it flow through untouched rather than pre-empting it here. - const hasInviteCode = request.nextUrl.searchParams.has('inviteCode'); - if (!hasInviteCode) { + // so let it flow through untouched rather than pre-empting it here. Matches + // the downstream truthy check (an empty ?inviteCode= is not an invite). + const inviteCode = request.nextUrl.searchParams.get('inviteCode'); + if (!inviteCode) { // CS-569: guard the onboarding loop at the setup entry too (direct nav or - // the create-additional intent). A user with 0 active memberships but >=1 - // deactivated one was offboarded — never let them create a (spurious) org. - // New users (no memberships) and users adding an additional org (have - // active memberships) fall through unchanged. + // the create-additional intent). Reuse the same decision the landing page + // uses so the two can't drift. A non-null target means the user is invited + // or offboarded; `null` means a new user (or one with active orgs) who + // should fall through to onboarding. const meRes = await serverApi.get<{ organizations: unknown[]; pendingInvitation: { id: string } | null; hasInactiveMembership?: boolean; }>('/v1/auth/me'); - const activeOrgCount = meRes.data?.organizations?.length ?? 0; - if (activeOrgCount === 0 && meRes.data?.hasInactiveMembership) { - // A pending invitation is the offboarded user's legitimate way back in — - // honor it before the access-removed dead-end (mirrors the root page). - if (meRes.data?.pendingInvitation) { - redirect(`/invite/${meRes.data.pendingInvitation.id}`); + const hasActiveOrg = (meRes.data?.organizations?.length ?? 0) > 0; + if (!hasActiveOrg) { + const target = resolveNoActiveOrgRedirect(meRes.data); + if (target) { + redirect(target); } - redirect('/auth/access-removed'); } } diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 56b293d28f..4d21b6309e 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -1,4 +1,5 @@ import { serverApi } from '@/lib/api-server'; +import { resolveNoActiveOrgRedirect } from '@/lib/no-active-org-redirect'; import { getDefaultRoute, mergePermissions, resolveBuiltInPermissions } from '@/lib/permissions'; import { auth } from '@/utils/auth'; import { db } from '@db/server'; @@ -56,21 +57,13 @@ export default async function RootPage({ const meRes = await serverApi.get('/v1/auth/me'); const memberships = meRes.data?.organizations ?? []; - const pendingInvitation = meRes.data?.pendingInvitation; if (memberships.length === 0) { - if (pendingInvitation) { - return redirect(await buildUrlWithParams(`/invite/${pendingInvitation.id}`)); - } - // CS-569: a user whose only memberships are deactivated (offboarded) has - // no active org. Do NOT drop them into onboarding — that silently spawns a - // spurious empty org and locks them into an onboarding loop. Tell them - // their access was removed instead. Genuinely new users (no memberships at - // all) still go to /setup. - if (meRes.data?.hasInactiveMembership) { - return redirect(await buildUrlWithParams('/auth/access-removed')); - } - return redirect(await buildUrlWithParams('/setup')); + // CS-569: route a user with no active org through the shared decision so + // this page and the /setup route can't diverge (invite > offboarded > new). + // `null` = genuinely new user → onboarding. + const target = resolveNoActiveOrgRedirect(meRes.data); + return redirect(await buildUrlWithParams(target ?? '/setup')); } // Always use the org the user last switched to (stored in session) diff --git a/apps/app/src/lib/no-active-org-redirect.test.ts b/apps/app/src/lib/no-active-org-redirect.test.ts new file mode 100644 index 0000000000..d61eb879e5 --- /dev/null +++ b/apps/app/src/lib/no-active-org-redirect.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { resolveNoActiveOrgRedirect } from './no-active-org-redirect'; + +describe('resolveNoActiveOrgRedirect (CS-569)', () => { + it('routes a pending invitation to the invite — highest precedence', () => { + expect( + resolveNoActiveOrgRedirect({ + pendingInvitation: { id: 'inv_1' }, + hasInactiveMembership: true, + }), + ).toBe('/invite/inv_1'); + }); + + it('routes an offboarded user (no invite) to access-removed', () => { + expect( + resolveNoActiveOrgRedirect({ pendingInvitation: null, hasInactiveMembership: true }), + ).toBe('/auth/access-removed'); + }); + + it('returns null for a genuinely new user so the caller onboards them', () => { + expect( + resolveNoActiveOrgRedirect({ pendingInvitation: null, hasInactiveMembership: false }), + ).toBeNull(); + }); + + it('is null-safe when the /v1/auth/me payload is missing (degrade to onboarding)', () => { + expect(resolveNoActiveOrgRedirect(undefined)).toBeNull(); + expect(resolveNoActiveOrgRedirect(null)).toBeNull(); + expect(resolveNoActiveOrgRedirect({})).toBeNull(); + }); +}); diff --git a/apps/app/src/lib/no-active-org-redirect.ts b/apps/app/src/lib/no-active-org-redirect.ts new file mode 100644 index 0000000000..edc972273d --- /dev/null +++ b/apps/app/src/lib/no-active-org-redirect.ts @@ -0,0 +1,34 @@ +/** + * Decides where to send a signed-in user who has **no active organization**. + * + * This centralizes the CS-569 offboarding-loop guard so the root landing page + * (`app/page.tsx`) and the `/setup` entry route can't drift apart — divergent + * per-path membership handling is exactly what caused CS-569. + * + * Precedence: + * 1. A pending invitation is always the way in → `/invite/{id}`. + * 2. Offboarded (memberships exist but are all deactivated) → `/auth/access-removed` + * (do NOT drop them into onboarding; that spawns a spurious org + loop). + * 3. Genuinely new user (no memberships at all) → `null` (caller decides the + * onboarding target). + * + * Null-safe: a missing `/v1/auth/me` payload is treated as "new user" (`null`), + * so a transient API failure degrades to onboarding rather than a dead-end. + */ +export function resolveNoActiveOrgRedirect( + me: + | { + pendingInvitation?: { id: string } | null; + hasInactiveMembership?: boolean; + } + | null + | undefined, +): string | null { + if (me?.pendingInvitation) { + return `/invite/${me.pendingInvitation.id}`; + } + if (me?.hasInactiveMembership) { + return '/auth/access-removed'; + } + return null; +} From bc630084d63661b624cc2108071f53da55bfd74b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 10:53:05 -0400 Subject: [PATCH 7/8] refactor(trust-portal): centralize ISO cert matching in one bounded helper Extract the three ISO badge checks into a single matchesIsoStandard(normalized, number) helper so the boundary rule lives in exactly one place and can't drift between the 27001/42001/9001 checks (the last cubic finding was a boundary that existed on one line but not conceptually shared). Behaviour is byte-identical to the previous inline regexes. Verified safe against the real data: - Certification `type` values are AI-extracted names (see the extraction schema: "SOC 2 Type II, ISO 27001, ISO 42001, ISO 27017, ISO 27018, ..."), which always carry the "ISO" prefix, so requiring the prefix drops no legitimate badge. - getAllVendorsWithSync re-derives and overwrites complianceBadges on read, so this matcher governs the displayed set and self-heals. - The emitted badge type strings are unchanged (soc2/iso27001/iso42001/gdpr/ hipaa/pci_dss/nen7510/iso9001), matching the frontend BADGE_ICONS/BADGE_LABELS keys in TrustPortalVendors.tsx, so rendering is unaffected. Tests (8 total) now cover the real cert-name matrix: positives (ISO 9001:2015, ISO/IEC 27001:2022, ISO/IEC 42001:2023, a full realistic mix) and negatives (ISO 90010, ISO/IEC 27017, ISO/IEC 27018, "Catalog 19001"). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BZotUzpY9RZt9JDsEJyrWS --- .../trust-portal/trust-portal.service.spec.ts | 26 +++++++++++++ .../src/trust-portal/trust-portal.service.ts | 37 ++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/apps/api/src/trust-portal/trust-portal.service.spec.ts b/apps/api/src/trust-portal/trust-portal.service.spec.ts index b8dedc7e60..7149f7cbb2 100644 --- a/apps/api/src/trust-portal/trust-portal.service.spec.ts +++ b/apps/api/src/trust-portal/trust-portal.service.spec.ts @@ -174,4 +174,30 @@ describe('TrustPortalService getAllVendorsWithSync compliance badges', () => { expect(badgeTypes).not.toContain('iso27001'); }); + + // ISO/IEC 27018 (PII in the cloud) is another distinct 2701x standard. + it('does not read "ISO/IEC 27018:2019" as the iso27001 badge', async () => { + const badgeTypes = await badgeTypesFor([ + { type: 'ISO/IEC 27018:2019', status: 'verified' }, + ]); + + expect(badgeTypes).not.toContain('iso27001'); + }); + + // Guardrail: a realistic mix of certification names (all carrying the "ISO" + // prefix, as produced by the extraction schema) must still map to every badge + // — the precision tightening must not drop any legitimate certification. + it('maps a realistic mix of certifications without dropping any', async () => { + const badgeTypes = await badgeTypesFor([ + { type: 'SOC 2 Type II', status: 'verified' }, + { type: 'ISO/IEC 27001:2013', status: 'verified' }, + { type: 'ISO 9001', status: 'verified' }, + { type: 'ISO/IEC 42001:2023', status: 'verified' }, + { type: 'GDPR Compliance', status: 'verified' }, + ]); + + expect(badgeTypes).toEqual( + expect.arrayContaining(['soc2', 'iso27001', 'iso9001', 'iso42001', 'gdpr']), + ); + }); }); diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index bfbf3094a3..00d9a05fe4 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -1950,15 +1950,8 @@ export class TrustPortalService { if (normalized.includes('soc2') || normalized.includes('soc 2')) return 'soc2'; - // Match ISO standards by their number, but require an "iso" prefix so that - // unrelated ids that merely contain the digits (e.g. a catalog id "19001") - // aren't misclassified. The optional "iec" handles joint ISO/IEC standards, - // whose "IEC" infix would otherwise break a naive includes('iso27001') check - // ("ISO/IEC 27001:2022" normalizes to "isoiec270012022"). The trailing - // "(?:\d{4})?(?!\d)" allows an optional 4-digit year ("...:2022") but forbids - // any other trailing digit, so "ISO 90010" is not read as "ISO 9001". - if (/iso(?:iec)?27001(?:\d{4})?(?!\d)/.test(normalized)) return 'iso27001'; - if (/iso(?:iec)?42001(?:\d{4})?(?!\d)/.test(normalized)) return 'iso42001'; + if (this.matchesIsoStandard(normalized, '27001')) return 'iso27001'; + if (this.matchesIsoStandard(normalized, '42001')) return 'iso42001'; if (normalized.includes('gdpr')) return 'gdpr'; if (normalized.includes('hipaa')) return 'hipaa'; if ( @@ -1969,11 +1962,35 @@ export class TrustPortalService { return 'pci_dss'; if (normalized.includes('nen7510') || normalized.includes('nen 7510')) return 'nen7510'; - if (/iso(?:iec)?9001(?:\d{4})?(?!\d)/.test(normalized)) return 'iso9001'; + if (this.matchesIsoStandard(normalized, '9001')) return 'iso9001'; return null; } + /** + * Whether a normalized cert string (lowercased, alphanumerics only) names the + * given ISO standard number. + * + * - Requires an "iso" / "iso iec" prefix, so unrelated ids that merely contain + * the digits ("19001", "127001") are not misclassified. + * - The optional "iec" handles joint ISO/IEC standards whose "IEC" infix would + * otherwise break the match ("ISO/IEC 27001:2022" -> "isoiec270012022"). + * - Allows an optional trailing 4-digit year ("ISO 9001:2015" -> "iso90012015") + * but forbids any other trailing digit, so a longer number is not read as a + * shorter standard ("ISO 90010" is not "ISO 9001", "ISO 27017" is not 27001). + * + * `standardNumber` is always a hard-coded digit literal — never user input — + * so building the RegExp from it carries no injection risk. + */ + private matchesIsoStandard( + normalized: string, + standardNumber: string, + ): boolean { + return new RegExp(`iso(?:iec)?${standardNumber}(?:\\d{4})?(?!\\d)`).test( + normalized, + ); + } + private generateLogoUrl(website: string | null): string | null { if (!website) return null; try { From ab7ba22ff8be46ba7937bd1c841c30e9058185a7 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 11:13:44 -0400 Subject: [PATCH 8/8] fix(auth): honor explicit ?inviteCode= before the offboard guard on the root page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic review (CS-569, P2): the centralization refactor made the root page short-circuit offboarded users to /auth/access-removed before the /setup passthrough, so an explicit ?inviteCode= on / was swallowed for offboarded users — a regression from the prior behavior where 0-org users fell through to /setup (which hands the code to /invite downstream). Restore invite-first precedence: when ?inviteCode= is present, redirect to /setup (preserving the code) so the downstream /invite handling runs, before applying the offboard guard. Mirrors the /setup route precedence. Adds a regression test: offboarded + ?inviteCode= -> /setup (not access-removed). Refs: CS-569 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M6zcF9bvPP1UwZgzK21cw1 --- apps/app/src/app/page.test.tsx | 12 ++++++++++++ apps/app/src/app/page.tsx | 13 ++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/page.test.tsx b/apps/app/src/app/page.test.tsx index ba887671fe..a2dc827269 100644 --- a/apps/app/src/app/page.test.tsx +++ b/apps/app/src/app/page.test.tsx @@ -79,6 +79,18 @@ describe('RootPage onboarding-loop guard (CS-569)', () => { expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed'); }); + it('lets an explicit ?inviteCode= win over the offboard guard (hands off to /setup for downstream invite handling)', async () => { + // Regression (cubic P2): an offboarded user landing on /?inviteCode=... must + // still be able to accept the invite, not be short-circuited to access-removed. + mockMe({ organizations: [], pendingInvitation: null, hasInactiveMembership: true }); + + await expect( + Page({ searchParams: Promise.resolve({ inviteCode: 'inv_abc' }) }), + ).rejects.toThrow('REDIRECT:/setup?inviteCode=inv_abc'); + + expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed'); + }); + it('prefers a pending invitation over the access-removed page', async () => { mockMe({ organizations: [], diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 4d21b6309e..ad486bb17c 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -59,9 +59,16 @@ export default async function RootPage({ const memberships = meRes.data?.organizations ?? []; if (memberships.length === 0) { - // CS-569: route a user with no active org through the shared decision so - // this page and the /setup route can't diverge (invite > offboarded > new). - // `null` = genuinely new user → onboarding. + // An explicit ?inviteCode= (the user is accepting a specific invitation) + // always wins over the offboard guard: hand off to /setup, which preserves + // the code and passes it through to /invite downstream. Mirrors the /setup + // route precedence so an offboarded user can still accept a valid invite. + const inviteCode = (await searchParams)?.inviteCode; + if (inviteCode) { + return redirect(await buildUrlWithParams('/setup')); + } + // Otherwise route via the shared decision (pending invite > offboarded > + // new user). `null` = genuinely new user → onboarding. const target = resolveNoActiveOrgRedirect(meRes.data); return redirect(await buildUrlWithParams(target ?? '/setup')); }