Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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>(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: [] },
}),
}),
);
});
});
15 changes: 15 additions & 0 deletions apps/api/src/integration-platform/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>) ?? {},
);
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,
Expand All @@ -1966,6 +1980,7 @@ export class SyncController {
isDirectorySource:
(syncDefinition as { isDirectorySource?: boolean })
.isDirectorySource ?? false,
syncFilter,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
});
});
});
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading