diff --git a/apps/api/src/integration-platform/controllers/sync-dynamic-employees.controller.spec.ts b/apps/api/src/integration-platform/controllers/sync-dynamic-employees.controller.spec.ts new file mode 100644 index 0000000000..359a2c0c89 --- /dev/null +++ b/apps/api/src/integration-platform/controllers/sync-dynamic-employees.controller.spec.ts @@ -0,0 +1,209 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SyncController } from './sync.controller'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import { CredentialVaultService } from '../services/credential-vault.service'; +import { OAuthCredentialsService } from '../services/oauth-credentials.service'; +import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; +import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service'; +import { GenericDeviceSyncService } from '../services/generic-device-sync.service'; +import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository'; +import { CheckRunRepository } from '../repositories/check-run.repository'; +import type { SyncEmployee } from '@trycompai/integration-platform'; + +const mockGetManifest = jest.fn(); +const mockInterpretDeclarativeSync = jest.fn(); +const mockCreateCheckContext = jest.fn(); + +jest.mock('@db', () => ({ db: {} })); + +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { integration: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +jest.mock('@trycompai/integration-platform', () => { + const actual = jest.requireActual< + typeof import('@trycompai/integration-platform') + >('@trycompai/integration-platform'); + return { + ...actual, + getManifest: (...args: unknown[]) => mockGetManifest(...args), + interpretDeclarativeSync: (...args: unknown[]) => + mockInterpretDeclarativeSync(...args), + createCheckContext: (...args: unknown[]) => mockCreateCheckContext(...args), + TASK_TEMPLATE_INFO: {}, + }; +}); + +describe('SyncController - dynamic provider employee sync filter', () => { + let controller: SyncController; + const mockProcessEmployees = jest.fn(); + const mockFindById = jest.fn(); + const mockFindBySlug = jest.fn(); + const mockGetDecryptedCredentials = jest.fn(); + const mockCheckRunCreate = jest.fn(); + const mockCheckRunComplete = jest.fn(); + + const orgId = 'org_test123'; + const connectionId = 'conn_test123'; + const providerSlug = 'entra-id'; + + const employees: SyncEmployee[] = [ + { email: 'keep@example.com', status: 'active' }, + { email: 'excluded@example.com', status: 'active' }, + ]; + + async function runSync(variables: Record) { + mockFindById.mockResolvedValue({ + id: connectionId, + organizationId: orgId, + variables, + metadata: {}, + }); + mockGetManifest.mockReturnValue({ + name: 'Microsoft Entra ID', + auth: { type: 'api_key' }, + capabilities: ['sync'], + }); + mockFindBySlug.mockResolvedValue({ + syncDefinition: { + steps: [], + employeesPath: 'employees', + isDirectorySource: true, + }, + }); + mockGetDecryptedCredentials.mockResolvedValue({ api_key: 'fake-key' }); + mockCheckRunCreate.mockResolvedValue({ id: 'run_1', startedAt: new Date() }); + mockCheckRunComplete.mockResolvedValue(undefined); + mockInterpretDeclarativeSync.mockReturnValue({ + run: jest.fn().mockResolvedValue(employees), + }); + mockCreateCheckContext.mockReturnValue({ + ctx: {}, + getResults: () => ({ logs: [] }), + }); + mockProcessEmployees.mockResolvedValue({ + success: true, + totalFound: employees.length, + imported: 1, + skipped: 0, + deactivated: 0, + reactivated: 0, + errors: 0, + details: [], + }); + + return controller.syncDynamicProviderEmployees( + orgId, + providerSlug, + connectionId, + ); + } + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SyncController], + providers: [ + { + provide: ConnectionRepository, + useValue: { findById: mockFindById, update: jest.fn() }, + }, + { + provide: CredentialVaultService, + useValue: { + getDecryptedCredentials: mockGetDecryptedCredentials, + refreshOAuthTokens: jest.fn(), + }, + }, + { + provide: OAuthCredentialsService, + useValue: { getCredentials: jest.fn() }, + }, + { + provide: IntegrationSyncLoggerService, + useValue: { logSync: jest.fn() }, + }, + { + provide: GenericEmployeeSyncService, + useValue: { processEmployees: mockProcessEmployees }, + }, + { provide: GenericDeviceSyncService, useValue: {} }, + { + provide: DynamicIntegrationRepository, + useValue: { findBySlug: mockFindBySlug }, + }, + { + provide: CheckRunRepository, + useValue: { create: mockCheckRunCreate, complete: mockCheckRunComplete }, + }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(SyncController); + }); + + it('passes the resolved exclude filter from connection.variables to processEmployees', async () => { + await runSync({ + sync_user_filter_mode: 'exclude', + sync_excluded_emails: 'excluded@example.com', + }); + + expect(mockProcessEmployees).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: orgId, + employees, + options: expect.objectContaining({ + syncFilter: { + mode: 'exclude', + excludedTerms: ['excluded@example.com'], + includedTerms: [], + }, + }), + }), + ); + }); + + it('passes the resolved include filter from connection.variables to processEmployees', async () => { + await runSync({ + sync_user_filter_mode: 'include', + sync_included_emails: 'keep@example.com', + }); + + expect(mockProcessEmployees).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + syncFilter: { + mode: 'include', + excludedTerms: [], + includedTerms: ['keep@example.com'], + }, + }), + }), + ); + }); + + it('defaults to mode "all" when no filter variables are configured', async () => { + await runSync({}); + + expect(mockProcessEmployees).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + syncFilter: { mode: 'all', excludedTerms: [], includedTerms: [] }, + }), + }), + ); + }); +}); diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 13a599d5d5..5386f0fd28 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -40,6 +40,7 @@ import { } from '@trycompai/integration-platform'; import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service'; +import { resolveSyncEmployeeFilter } from '../services/sync-employee-filter'; import { GenericDeviceSyncService } from '../services/generic-device-sync.service'; import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository'; import { CheckRunRepository } from '../repositories/check-run.repository'; @@ -1953,6 +1954,19 @@ export class SyncController { `[DynamicSync] Sync definition produced ${employees.length} employees for "${providerSlug}"`, ); + // Resolve the org's configured include/exclude email filter from the + // connection variables. This lets dynamic providers (e.g. Microsoft + // Entra ID) limit which users are synced via the same + // sync_user_filter_mode / sync_excluded_emails / sync_included_emails + // variables that Google Workspace and JumpCloud already honor. Absent + // those variables this resolves to 'all' (import everyone — unchanged). + const syncFilter = resolveSyncEmployeeFilter( + (connection.variables as Record) ?? {}, + ); + this.logger.log( + `[DynamicSync] Employee filter mode="${syncFilter.mode}" excluded=${syncFilter.excludedTerms.length} included=${syncFilter.includedTerms.length} for "${providerSlug}"`, + ); + // 8. Process employees via generic service const result = await this.genericSyncService.processEmployees({ organizationId, @@ -1966,6 +1980,7 @@ export class SyncController { isDirectorySource: (syncDefinition as { isDirectorySource?: boolean }) .isDirectorySource ?? false, + syncFilter, }, }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts index 27e1561e5b..e2455a87ab 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts @@ -237,7 +237,12 @@ describe('GenericEmployeeSyncService role validation', () => { expect(mockMemberUpdate).toHaveBeenCalledWith({ where: { id: 'mem_1' }, - data: { deactivated: false, isActive: true, role: 'employee' }, + data: { + deactivated: false, + isActive: true, + offboardDate: null, + role: 'employee', + }, }); }); }); @@ -328,4 +333,123 @@ describe('GenericEmployeeSyncService role validation', () => { expect(result.deactivated).toBe(0); }); }); + + describe('sync filter (include / exclude)', () => { + it('imports everyone when no filter is configured (backward compatible)', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'bob@example.com' }), + ], + options: { providerName: 'Microsoft Entra ID' }, + }); + + expect(result.imported).toBe(2); + expect(mockMemberCreate).toHaveBeenCalledTimes(2); + }); + + it('skips importing excluded users in exclude mode', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'excluded@example.com' }), + ], + options: { + providerName: 'Microsoft Entra ID', + syncFilter: { + mode: 'exclude', + excludedTerms: ['excluded@example.com'], + includedTerms: [], + }, + }, + }); + + expect(result.imported).toBe(1); + expect(mockMemberCreate).toHaveBeenCalledTimes(1); + const importedEmails = result.details + .filter((d) => d.status === 'imported') + .map((d) => d.email); + expect(importedEmails).toEqual(['alice@example.com']); + }); + + it('imports only the included subset in include mode', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'bob@example.com' }), + ], + options: { + providerName: 'Microsoft Entra ID', + syncFilter: { + mode: 'include', + excludedTerms: [], + includedTerms: ['alice@example.com'], + }, + }, + }); + + expect(result.imported).toBe(1); + expect(mockMemberCreate).toHaveBeenCalledTimes(1); + const importedEmails = result.details + .filter((d) => d.status === 'imported') + .map((d) => d.email); + expect(importedEmails).toEqual(['alice@example.com']); + }); + + it('in include mode, does not deactivate present members outside the include list, but still deactivates genuine removals', async () => { + // bob is active in the provider but outside the include list; carol is a + // member who no longer exists in the provider at all. + mockMemberFindMany.mockResolvedValue([ + { + id: 'mem_bob', + role: 'employee', + offboardDate: null, + user: { email: 'bob@example.com' }, + }, + { + id: 'mem_carol', + role: 'employee', + offboardDate: null, + user: { email: 'carol@example.com' }, + }, + ]); + + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'bob@example.com' }), + ], + options: { + providerName: 'Microsoft Entra ID', + isDirectorySource: true, + syncFilter: { + mode: 'include', + excludedTerms: [], + includedTerms: ['alice@example.com'], + }, + }, + }); + + // bob is still in the provider → must NOT be deactivated for being + // outside the include subset. + expect(mockMemberUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'mem_bob' } }), + ); + // carol is genuinely gone → still deactivated. + expect(mockMemberUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mem_carol' }, + data: expect.objectContaining({ + deactivated: true, + isActive: false, + }), + }), + ); + expect(result.deactivated).toBe(1); + }); + }); }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts index 13608616b7..ffee52540c 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; import type { SyncEmployee } from '@trycompai/integration-platform'; +import { matchesSyncFilterTerms } from '@trycompai/integration-platform'; import { BUILT_IN_ROLE_PERMISSIONS } from '@trycompai/auth'; +import type { ResolvedSyncEmployeeFilter } from './sync-employee-filter'; // ============================================================================ // Types @@ -44,6 +46,15 @@ export interface ProcessEmployeesOptions { * deactivating active employees when their API returns a partial member list. */ isDirectorySource?: boolean; + /** + * Optional include/exclude email filter (resolved from connection variables). + * + * When omitted (or mode 'all'), every active employee is imported — the + * historical behavior. When provided, this mirrors the Google Workspace / + * JumpCloud sync filter so dynamic providers (e.g. Microsoft Entra) can + * limit which users are synced. + */ + syncFilter?: ResolvedSyncEmployeeFilter; } const DEFAULT_PROTECTED_ROLES = ['owner', 'admin', 'auditor']; @@ -126,14 +137,40 @@ export class GenericEmployeeSyncService { `[GenericSync] Processing ${employees.length} employees for org="${organizationId}" provider="${providerName}"`, ); - // Separate employees by status - const activeEmployees = employees.filter((e) => e.status === 'active'); + // Resolve the configured include/exclude email filter. When unset (or + // mode 'all') this is a no-op and every active employee is imported. + const filterMode = options.syncFilter?.mode ?? 'all'; + const excludedTerms = options.syncFilter?.excludedTerms ?? []; + const includedTerms = options.syncFilter?.includedTerms ?? []; + const passesImportFilter = (email: string): boolean => { + if (filterMode === 'exclude') { + return ( + excludedTerms.length === 0 || + !matchesSyncFilterTerms(email, excludedTerms) + ); + } + if (filterMode === 'include') { + return matchesSyncFilterTerms(email, includedTerms); + } + return true; + }; + + // Separate employees by status. Phase 1 imports only the active employees + // that pass the filter; Phase 2 tracks presence against both the filtered + // set and the full provider list (see the deactivation note below). + const activeEmployeesAll = employees.filter((e) => e.status === 'active'); + const activeEmployees = activeEmployeesAll.filter((e) => + passesImportFilter(e.email.toLowerCase()), + ); const inactiveEmails = new Set( employees .filter((e) => e.status !== 'active') .map((e) => e.email.toLowerCase()), ); - const activeEmails = new Set( + const allActiveEmails = new Set( + activeEmployeesAll.map((e) => e.email.toLowerCase()), + ); + const filteredActiveEmails = new Set( activeEmployees.map((e) => e.email.toLowerCase()), ); @@ -307,6 +344,14 @@ export class GenericEmployeeSyncService { }, }); + // In 'include' mode, members who still exist in the provider must NOT be + // deactivated just for being outside the include subset — check presence + // against the full provider list (mirrors the Google Workspace path). In + // 'exclude'/'all' mode, presence is the filtered set, so excluded users and + // genuinely-removed users are both deactivated. + const presenceActiveEmails = + filterMode === 'include' ? allActiveEmails : filteredActiveEmails; + for (const member of allOrgMembers) { const memberEmail = member.user.email.toLowerCase(); const memberDomain = memberEmail.split('@')[1]; @@ -326,7 +371,7 @@ export class GenericEmployeeSyncService { // If member is not in active set AND not already accounted for, deactivate const isSuspended = inactiveEmails.has(memberEmail); - const isRemoved = !activeEmails.has(memberEmail) && !isSuspended; + const isRemoved = !presenceActiveEmails.has(memberEmail) && !isSuspended; if (isSuspended || isRemoved) { try { diff --git a/apps/api/src/integration-platform/services/sync-employee-filter.spec.ts b/apps/api/src/integration-platform/services/sync-employee-filter.spec.ts new file mode 100644 index 0000000000..75df309256 --- /dev/null +++ b/apps/api/src/integration-platform/services/sync-employee-filter.spec.ts @@ -0,0 +1,50 @@ +import { resolveSyncEmployeeFilter } from './sync-employee-filter'; + +describe('resolveSyncEmployeeFilter', () => { + it('defaults to mode "all" when no variables are set', () => { + expect(resolveSyncEmployeeFilter({})).toEqual({ + mode: 'all', + excludedTerms: [], + includedTerms: [], + }); + }); + + it('defaults to mode "all" when the mode is unknown', () => { + expect( + resolveSyncEmployeeFilter({ sync_user_filter_mode: 'bogus' }).mode, + ).toBe('all'); + }); + + it('parses exclude terms from a comma/newline separated string', () => { + const result = resolveSyncEmployeeFilter({ + sync_user_filter_mode: 'exclude', + sync_excluded_emails: 'a@example.com, b@example.com\nc@example.com', + }); + + expect(result.mode).toBe('exclude'); + expect(result.excludedTerms).toEqual([ + 'a@example.com', + 'b@example.com', + 'c@example.com', + ]); + }); + + it('honors include mode when an include list is provided', () => { + const result = resolveSyncEmployeeFilter({ + sync_user_filter_mode: 'include', + sync_included_emails: ['keep@example.com'], + }); + + expect(result.mode).toBe('include'); + expect(result.includedTerms).toEqual(['keep@example.com']); + }); + + it('falls back to "all" when include mode is selected with an empty list (never silently drops everyone)', () => { + expect( + resolveSyncEmployeeFilter({ + sync_user_filter_mode: 'include', + sync_included_emails: '', + }).mode, + ).toBe('all'); + }); +}); diff --git a/apps/api/src/integration-platform/services/sync-employee-filter.ts b/apps/api/src/integration-platform/services/sync-employee-filter.ts new file mode 100644 index 0000000000..6c4fdffb12 --- /dev/null +++ b/apps/api/src/integration-platform/services/sync-employee-filter.ts @@ -0,0 +1,50 @@ +import { parseSyncFilterTerms } from '@trycompai/integration-platform'; + +export type SyncUserFilterMode = 'all' | 'exclude' | 'include'; + +const SYNC_USER_FILTER_MODES = new Set([ + 'all', + 'exclude', + 'include', +]); + +export interface ResolvedSyncEmployeeFilter { + mode: SyncUserFilterMode; + excludedTerms: string[]; + includedTerms: string[]; +} + +/** + * Resolve the org's configured employee sync filter from a connection's + * `variables`. + * + * This mirrors the Google Workspace / JumpCloud sync filter so dynamic + * providers (e.g. Microsoft Entra ID) honor the same connection variables: + * - `sync_user_filter_mode`: 'all' | 'exclude' | 'include' + * - `sync_excluded_emails`: terms to exclude (full emails, @domain, substrings) + * - `sync_included_emails`: terms to include + * + * Falls back to 'all' (import everyone) when the mode is unset/unknown, or when + * 'include' is selected with an empty include list — so a half-configured + * filter can never silently drop every user. + */ +export function resolveSyncEmployeeFilter( + variables: Record, +): ResolvedSyncEmployeeFilter { + const rawMode = variables.sync_user_filter_mode; + const requestedMode: SyncUserFilterMode = + typeof rawMode === 'string' && + SYNC_USER_FILTER_MODES.has(rawMode as SyncUserFilterMode) + ? (rawMode as SyncUserFilterMode) + : 'all'; + + const excludedTerms = parseSyncFilterTerms(variables.sync_excluded_emails); + const includedTerms = parseSyncFilterTerms(variables.sync_included_emails); + + const mode: SyncUserFilterMode = + requestedMode === 'include' && includedTerms.length === 0 + ? 'all' + : requestedMode; + + return { mode, excludedTerms, includedTerms }; +} diff --git a/apps/app/src/app/(public)/auth/page.test.tsx b/apps/app/src/app/(public)/auth/page.test.tsx new file mode 100644 index 0000000000..f808dae6bd --- /dev/null +++ b/apps/app/src/app/(public)/auth/page.test.tsx @@ -0,0 +1,61 @@ +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 }; +}); + +// env.mjs validates required server env vars at import time. The page redirects +// before reading any of them, so a stub keeps the test hermetic. +vi.mock('@/env.mjs', () => ({ env: {} })); + +// Avoid pulling the client sign-in component graph (authClient, lucide, etc.) +// into the test; the redirect paths return before rendering the form. +vi.mock('@/components/login-form', () => ({ LoginForm: () => null })); + +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); + +describe('Auth page invite routing', () => { + 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}`); + }); + }); + + it('routes an authenticated invitee with an active org to the invite flow (not onboarding)', async () => { + // Regression: an auditor who already has a Comp AI session with an active + // org clicks an invite link. Previously this redirected to /setup and the + // invite code was discarded, dumping them into the onboarding wizard. + mockAuthApi.getSession.mockResolvedValue({ + session: createMockSession({ activeOrganizationId: 'org_existing' }), + user: createMockUser({ email: 'auditor@example.com' }), + }); + + await expect( + Page({ searchParams: Promise.resolve({ inviteCode: 'inv_abc123' }) }), + ).rejects.toThrow('REDIRECT:/invite/inv_abc123'); + + expect(mockRedirect).toHaveBeenCalledWith('/invite/inv_abc123'); + expect(mockRedirect).not.toHaveBeenCalledWith('/setup'); + }); + + it('still sends an authenticated user without an invite code to home', async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: createMockSession({ activeOrganizationId: 'org_existing' }), + user: createMockUser(), + }); + + await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow('REDIRECT:/'); + + expect(mockRedirect).toHaveBeenCalledWith('/'); + }); +}); diff --git a/apps/app/src/app/(public)/auth/page.tsx b/apps/app/src/app/(public)/auth/page.tsx index ff8130ec36..6f84bc185b 100644 --- a/apps/app/src/app/(public)/auth/page.tsx +++ b/apps/app/src/app/(public)/auth/page.tsx @@ -34,7 +34,10 @@ export default async function Page({ const orgId = session?.session?.activeOrganizationId; if (orgId && inviteCode) { - redirect('/setup'); + // An invite code always takes priority: send the user to the invitation + // acceptance flow (which validates the invite and applies the role), never + // to the new-org onboarding wizard. + redirect(`/invite/${inviteCode}`); } if (orgId && !inviteCode) {