Skip to content

[PM-20134] Fix overwriteExisting and largeImport causing users to be deleted #737

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 30, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 11 additions & 4 deletions src/abstractions/request-builder.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ import { OrganizationImportRequest } from "@/jslib/common/src/models/request/org
import { GroupEntry } from "@/src/models/groupEntry";
import { UserEntry } from "@/src/models/userEntry";

export class RequestBuilderOptions {
constructor(options: { removeDisabled: boolean; overwriteExisting: boolean }) {
this.removeDisabled = options.removeDisabled;
this.overwriteExisting = options.overwriteExisting;
}

removeDisabled: boolean = false;
overwriteExisting: boolean = false;
}

export abstract class RequestBuilder {
buildRequest: (
groups: GroupEntry[],
users: UserEntry[],
options: {
removeDisabled: boolean;
overwriteExisting: boolean;
},
options: RequestBuilderOptions,
) => OrganizationImportRequest[];
}
7 changes: 2 additions & 5 deletions src/services/batch-request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { OrganizationImportRequest } from "@/jslib/common/src/models/request/org
import { GroupEntry } from "@/src/models/groupEntry";
import { UserEntry } from "@/src/models/userEntry";

import { RequestBuilder } from "../abstractions/request-builder.service";
import { RequestBuilder, RequestBuilderOptions } from "../abstractions/request-builder.service";

import { batchSize } from "./sync.service";

Expand All @@ -16,10 +16,7 @@ export class BatchRequestBuilder implements RequestBuilder {
buildRequest(
groups: GroupEntry[],
users: UserEntry[],
options: {
removeDisabled: boolean;
overwriteExisting: boolean;
},
options: RequestBuilderOptions,
): OrganizationImportRequest[] {
if (options.overwriteExisting) {
throw new Error(
Expand Down
35 changes: 23 additions & 12 deletions src/services/batch-requests-builder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { GroupEntry } from "@/src/models/groupEntry";
import { UserEntry } from "@/src/models/userEntry";

import { RequestBuilderOptions } from "../abstractions/request-builder.service";

import { BatchRequestBuilder } from "./batch-request-builder";
import { SingleRequestBuilder } from "./single-request-builder";

describe("BatchRequestBuilder", () => {
let batchRequestBuilder: BatchRequestBuilder;
let singleRequestBuilder: SingleRequestBuilder;

function userSimulator(userCount: number) {
return Array(userCount).fill(new UserEntry());
Expand All @@ -18,10 +18,12 @@ describe("BatchRequestBuilder", () => {

beforeEach(async () => {
batchRequestBuilder = new BatchRequestBuilder();
singleRequestBuilder = new SingleRequestBuilder();
});

const defaultOptions = { overwriteExisting: false, removeDisabled: false };
const defaultOptions = new RequestBuilderOptions({
overwriteExisting: false,
removeDisabled: false,
});

it("BatchRequestBuilder batches requests for > 2000 users", () => {
const mockGroups = groupSimulator(11000);
Expand All @@ -35,7 +37,7 @@ describe("BatchRequestBuilder", () => {
it("BatchRequestBuilder throws error when overwriteExisting is true", () => {
const mockGroups = groupSimulator(11000);
const mockUsers = userSimulator(11000);
const options = { ...defaultOptions, overwriteExisting: true };
const options = new RequestBuilderOptions({ ...defaultOptions, overwriteExisting: true });

const r = () => batchRequestBuilder.buildRequest(mockGroups, mockUsers, options);

Expand All @@ -44,13 +46,22 @@ describe("BatchRequestBuilder", () => {
);
});

it("SingleRequestBuilder returns single request for 200 users", () => {
const mockGroups = groupSimulator(200);
const mockUsers = userSimulator(200);

const requests = singleRequestBuilder.buildRequest(mockGroups, mockUsers, defaultOptions);

expect(requests.length).toEqual(1);
it("BatchRequestBuilder returns requests with deleted users when removeDisabled is true", () => {
const mockGroups = groupSimulator(11000);
const disabledUser = new UserEntry();
const mockUsers = userSimulator(11000);
const options = new RequestBuilderOptions({ ...defaultOptions, removeDisabled: true });

disabledUser.disabled = true;
mockUsers[0] = disabledUser;
mockUsers.push(disabledUser);

const requests = batchRequestBuilder.buildRequest(mockGroups, mockUsers, options);
expect(requests[0].members.find((m) => m.deleted)).toBeTruthy();
expect(requests[1].members.find((m) => m.deleted)).toBeUndefined();
expect(requests[3].members.find((m) => m.deleted)).toBeUndefined();
expect(requests[4].members.find((m) => m.deleted)).toBeUndefined();
expect(requests[5].members.find((m) => m.deleted)).toBeTruthy();
});

it("BatchRequestBuilder retuns an empty array when there are no users or groups", () => {
Expand Down
96 changes: 95 additions & 1 deletion src/services/ldap-directory.service.integration.spec.ts
Copy link
Member

@eliykat eliykat Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot these tests are testing the directory service specifically, whereas our new tests are testing syncService.

After thinking about it some more I recommend:

  • moving the new tests to a new spec file (sync.service.integration.spec.ts)
  • only test the SyncService result/side effects
  • still integrate against the openldap container

Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { mock, MockProxy } from "jest-mock-extended";

import { ApiService } from "@/jslib/common/src/abstractions/api.service";
import { CryptoFunctionService } from "@/jslib/common/src/abstractions/cryptoFunction.service";
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
import { EnvironmentService } from "@/jslib/common/src/services/environment.service";

import { I18nService } from "../../jslib/common/src/abstractions/i18n.service";
import { LogService } from "../../jslib/common/src/abstractions/log.service";
import { groupFixtures } from "../../openldap/group-fixtures";
import { userFixtures } from "../../openldap/user-fixtures";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
import { DirectoryType } from "../enums/directoryType";
import { getLdapConfiguration, getSyncConfiguration } from "../utils/test-fixtures";

import { BatchRequestBuilder } from "./batch-request-builder";

Check warning on line 16 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L16

Added line #L16 was not covered by tests
import { LdapDirectoryService } from "./ldap-directory.service";
import { SingleRequestBuilder } from "./single-request-builder";

Check warning on line 18 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L18

Added line #L18 was not covered by tests
import { StateService } from "./state.service";
import { SyncService } from "./sync.service";
import * as constants from "./sync.service";

Check warning on line 21 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L20-L21

Added lines #L20 - L21 were not covered by tests

// These tests integrate with the OpenLDAP docker image and seed data located in the openldap folder.
// To run theses tests:
Expand All @@ -20,19 +30,49 @@
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateService>;

let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let apiService: MockProxy<ApiService>;
let messagingService: MockProxy<MessagingService>;
let environmentService: MockProxy<EnvironmentService>;
let directoryFactory: MockProxy<DirectoryFactoryService>;

let batchRequestBuilder: BatchRequestBuilder;
let singleRequestBuilder: SingleRequestBuilder;
let syncService: SyncService;
let directoryService: LdapDirectoryService;

beforeEach(() => {
logService = mock();
i18nService = mock();
stateService = mock();
cryptoFunctionService = mock();
apiService = mock();
messagingService = mock();
environmentService = mock();
directoryFactory = mock();

Check warning on line 52 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L48-L52

Added lines #L48 - L52 were not covered by tests

stateService.getDirectoryType.mockResolvedValue(DirectoryType.Ldap);
stateService.getOrganizationId.mockResolvedValue("fakeId");

Check warning on line 55 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L55

Added line #L55 was not covered by tests
stateService.getLastUserSync.mockResolvedValue(null); // do not filter results by last modified date
i18nService.t.mockImplementation((id) => id); // passthrough implementation for any error messages

directoryService = new LdapDirectoryService(logService, i18nService, stateService);
directoryFactory.createService.mockReturnValue(directoryService);

Check warning on line 60 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L60

Added line #L60 was not covered by tests

batchRequestBuilder = new BatchRequestBuilder();
singleRequestBuilder = new SingleRequestBuilder();

Check warning on line 63 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L62-L63

Added lines #L62 - L63 were not covered by tests

syncService = new SyncService(

Check warning on line 65 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L65

Added line #L65 was not covered by tests
cryptoFunctionService,
apiService,
messagingService,
i18nService,
environmentService,
stateService,
batchRequestBuilder,
singleRequestBuilder,
directoryFactory,
);
});

describe("basic sync fetching users and groups", () => {
Expand Down Expand Up @@ -75,6 +115,60 @@
const result = await directoryService.getEntries(true, true);
expect(result).toEqual([groupFixtures, userFixtures]);
});

it("with largeImport disabled matches directory fixture data", async () => {
stateService.getDirectory

Check warning on line 120 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L119-L120

Added lines #L119 - L120 were not covered by tests
.calledWith(DirectoryType.Ldap)
.mockResolvedValue(getLdapConfiguration());
stateService.getSync.mockResolvedValue(

Check warning on line 123 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L123

Added line #L123 was not covered by tests
getSyncConfiguration({
users: true,
groups: true,
}),
);

cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));

Check warning on line 130 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L130

Added line #L130 was not covered by tests
// This arranges the last hash to be differet from the ArrayBuffer after it is converted to b64
stateService.getLastSyncHash.mockResolvedValue("unique hash");

Check warning on line 132 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L132

Added line #L132 was not covered by tests

const syncResult = await syncService.sync(false, false);
const result = await directoryService.getEntries(true, true);

Check warning on line 135 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L134-L135

Added lines #L134 - L135 were not covered by tests

expect(syncResult).toEqual([groupFixtures, userFixtures]);
expect(result).toEqual([groupFixtures, userFixtures]);
expect(result).toEqual(syncResult);

Check warning on line 139 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L137-L139

Added lines #L137 - L139 were not covered by tests

expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(1);

Check warning on line 141 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L141

Added line #L141 was not covered by tests
});

it("with largeImport enabled matches directory fixture data", async () => {
stateService.getDirectory

Check warning on line 145 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L144-L145

Added lines #L144 - L145 were not covered by tests
.calledWith(DirectoryType.Ldap)
.mockResolvedValue(getLdapConfiguration());
stateService.getSync.mockResolvedValue(

Check warning on line 148 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L148

Added line #L148 was not covered by tests
getSyncConfiguration({
users: true,
groups: true,
largeImport: true,
overwriteExisting: false,
}),
);

cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));

Check warning on line 157 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L157

Added line #L157 was not covered by tests
// This arranges the last hash to be differet from the ArrayBuffer after it is converted to b64
stateService.getLastSyncHash.mockResolvedValue("unique hash");

Check warning on line 159 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L159

Added line #L159 was not covered by tests

// @ts-expect-error This is a workaround to make the batchsize smaller to trigger the batching logic since its a const.
constants.batchSize = 4;

Check warning on line 162 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L162

Added line #L162 was not covered by tests

const result = await directoryService.getEntries(true, true);
const syncResult = await syncService.sync(false, false);

Check warning on line 165 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L164-L165

Added lines #L164 - L165 were not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also assert against the call received by the mock api service. That's an important side-effect here, and it lets you check the other request properties that aren't included in the return value, particularly overwriteExisting.


expect(syncResult).toEqual([groupFixtures, userFixtures]);
expect(result).toEqual([groupFixtures, userFixtures]);
expect(result).toEqual(syncResult);
expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(6);

Check warning on line 170 in src/services/ldap-directory.service.integration.spec.ts

View check run for this annotation

Codecov / codecov/patch

src/services/ldap-directory.service.integration.spec.ts#L167-L170

Added lines #L167 - L170 were not covered by tests
});
});

describe("users", () => {
Expand Down
73 changes: 73 additions & 0 deletions src/services/single-request-builder.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { GroupEntry } from "@/src/models/groupEntry";
import { UserEntry } from "@/src/models/userEntry";

import { RequestBuilderOptions } from "../abstractions/request-builder.service";

import { SingleRequestBuilder } from "./single-request-builder";

describe("SingleRequestBuilder", () => {
let singleRequestBuilder: SingleRequestBuilder;

function userSimulator(userCount: number) {
return Array(userCount).fill(new UserEntry());
}

function groupSimulator(groupCount: number) {
return Array(groupCount).fill(new GroupEntry());
}

beforeEach(async () => {
singleRequestBuilder = new SingleRequestBuilder();
});

const defaultOptions = new RequestBuilderOptions({
overwriteExisting: false,
removeDisabled: false,
});

it("SingleRequestBuilder returns single request for 200 users", () => {
const mockGroups = groupSimulator(200);
const mockUsers = userSimulator(200);

const requests = singleRequestBuilder.buildRequest(mockGroups, mockUsers, defaultOptions);

expect(requests.length).toEqual(1);
});

it("SingleRequestBuilder returns request with overwriteExisting enabled", () => {
const mockGroups = groupSimulator(200);
const mockUsers = userSimulator(200);
const options = new RequestBuilderOptions({ ...defaultOptions, overwriteExisting: true });

const request = singleRequestBuilder.buildRequest(mockGroups, mockUsers, options)[0];
expect(request.overwriteExisting).toBe(true);
});

it("SingleRequestBuilder returns request with deleted user when removeDisabled is true", () => {
const mockGroups = groupSimulator(200);
const mockUsers = userSimulator(200);
const disabledUser = new UserEntry();
const options = new RequestBuilderOptions({ ...defaultOptions, removeDisabled: true });

disabledUser.disabled = true;
mockUsers.push(disabledUser);

const request = singleRequestBuilder.buildRequest(mockGroups, mockUsers, options)[0];
expect(request.members.length).toEqual(201);
expect(request.members.pop().deleted).toBe(true);
});

it("SingleRequestBuilder returns request with deleted user and overwriteExisting enabled when overwriteExisting and removeDisabled are true", () => {
const mockGroups = groupSimulator(200);
const mockUsers = userSimulator(200);
const disabledUser = new UserEntry();
const options = new RequestBuilderOptions({ overwriteExisting: true, removeDisabled: true });

disabledUser.disabled = true;
mockUsers.push(disabledUser);

const request = singleRequestBuilder.buildRequest(mockGroups, mockUsers, options)[0];
expect(request.members.pop().deleted).toBe(true);
expect(request.overwriteExisting).toBe(true);
});
});
7 changes: 2 additions & 5 deletions src/services/single-request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { OrganizationImportRequest } from "@/jslib/common/src/models/request/org
import { GroupEntry } from "@/src/models/groupEntry";
import { UserEntry } from "@/src/models/userEntry";

import { RequestBuilder } from "../abstractions/request-builder.service";
import { RequestBuilder, RequestBuilderOptions } from "../abstractions/request-builder.service";

/**
* This class is responsible for building small (<2k users) syncs as a single
Expand All @@ -15,10 +15,7 @@ export class SingleRequestBuilder implements RequestBuilder {
buildRequest(
groups: GroupEntry[],
users: UserEntry[],
options: {
removeDisabled: boolean;
overwriteExisting: boolean;
},
options: RequestBuilderOptions,
): OrganizationImportRequest[] {
return [
new OrganizationImportRequest({
Expand Down
Loading