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 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
16 changes: 6 additions & 10 deletions jslib/common/src/models/request/organizationImportRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,12 @@ export class OrganizationImportRequest {
overwriteExisting = false;
largeImport = false;

constructor(
model:
| {
groups: Required<OrganizationImportGroupRequest>[];
users: Required<OrganizationImportMemberRequest>[];
overwriteExisting: boolean;
largeImport: boolean;
}
| ImportDirectoryRequest,
) {
constructor(model: {
groups: Required<OrganizationImportGroupRequest>[];
users: Required<OrganizationImportMemberRequest>[];
overwriteExisting: boolean;
largeImport: boolean;
}) {
if (model instanceof ImportDirectoryRequest) {
this.groups = model.groups.map((g) => new OrganizationImportGroupRequest(g));
this.members = model.users.map((u) => new OrganizationImportMemberRequest(u));
Expand Down
8 changes: 6 additions & 2 deletions src/abstractions/request-builder.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { OrganizationImportRequest } from "@/jslib/common/src/models/request/org
import { GroupEntry } from "@/src/models/groupEntry";
import { UserEntry } from "@/src/models/userEntry";

export interface RequestBuilderOptions {
removeDisabled: boolean;
overwriteExisting: boolean;
}

export abstract class RequestBuilder {
buildRequest: (
groups: GroupEntry[],
users: UserEntry[],
removeDisabled: boolean,
overwriteExisting: boolean,
options: RequestBuilderOptions,
) => OrganizationImportRequest[];
}
17 changes: 11 additions & 6 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,17 +16,22 @@ export class BatchRequestBuilder implements RequestBuilder {
buildRequest(
groups: GroupEntry[],
users: UserEntry[],
removeDisabled: boolean,
overwriteExisting: boolean,
options: RequestBuilderOptions,
): OrganizationImportRequest[] {
if (options.overwriteExisting) {
throw new Error(
"You cannot use the 'Remove and re-add organization users during the next sync' option with large imports.",
);
}

const requests: OrganizationImportRequest[] = [];

if (users.length > 0) {
const usersRequest = users.map((u) => {
return {
email: u.email,
externalId: u.externalId,
deleted: u.deleted || (removeDisabled && u.disabled),
deleted: u.deleted || (options.removeDisabled && u.disabled),
};
});

Expand All @@ -37,7 +42,7 @@ export class BatchRequestBuilder implements RequestBuilder {
groups: [],
users: u,
largeImport: true,
overwriteExisting,
overwriteExisting: false,
});
requests.push(req);
}
Expand All @@ -59,7 +64,7 @@ export class BatchRequestBuilder implements RequestBuilder {
groups: g,
users: [],
largeImport: true,
overwriteExisting,
overwriteExisting: false,
});
requests.push(req);
}
Expand Down
68 changes: 48 additions & 20 deletions src/services/batch-requests-builder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,74 @@
import { GroupEntry } from "@/src/models/groupEntry";
import { GetUniqueString } from "@/jslib/common/spec/utils";

import { UserEntry } from "@/src/models/userEntry";

import { RequestBuilderOptions } from "../abstractions/request-builder.service";
import { groupSimulator, userSimulator } from "../utils/request-builder-helper";

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());
}

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

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

const defaultOptions: RequestBuilderOptions = Object.freeze({
overwriteExisting: false,
removeDisabled: false,
});

it("BatchRequestBuilder batches requests for > 2000 users", () => {
const mockGroups = groupSimulator(11000);
const mockUsers = userSimulator(11000);

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

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

it("SingleRequestBuilder returns single request for 200 users", () => {
const mockGroups = groupSimulator(200);
const mockUsers = userSimulator(200);
it("BatchRequestBuilder throws error when overwriteExisting is true", () => {
const mockGroups = groupSimulator(11000);
const mockUsers = userSimulator(11000);
const options = { ...defaultOptions, overwriteExisting: true };

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

expect(r).toThrow(
"You cannot use the 'Remove and re-add organization users during the next sync' option with large imports.",
);
});

it("BatchRequestBuilder returns requests with deleted users when removeDisabled is true", () => {
const mockGroups = groupSimulator(11000);
const mockUsers = userSimulator(11000);

const disabledUser1 = new UserEntry();
const disabledUserEmail1 = GetUniqueString() + "@email.com";

const disabledUser2 = new UserEntry();
const disabledUserEmail2 = GetUniqueString() + "@email.com";

disabledUser1.disabled = true;
disabledUser1.email = disabledUserEmail1;
disabledUser2.disabled = true;
disabledUser2.email = disabledUserEmail2;

mockUsers[0] = disabledUser1;
mockUsers.push(disabledUser2);

const requests = singleRequestBuilder.buildRequest(mockGroups, mockUsers, true, true);
const options = { ...defaultOptions, removeDisabled: true };
const requests = batchRequestBuilder.buildRequest(mockGroups, mockUsers, options);

expect(requests.length).toEqual(1);
expect(requests[0].members).toContainEqual({ email: disabledUserEmail1, deleted: true });
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).toContainEqual({ email: disabledUserEmail2, deleted: true });
});

it("BatchRequestBuilder retuns an empty array when there are no users or groups", () => {
const requests = batchRequestBuilder.buildRequest([], [], true, true);
const requests = batchRequestBuilder.buildRequest([], [], defaultOptions);

expect(requests).toEqual([]);
});
Expand Down
79 changes: 79 additions & 0 deletions src/services/single-request-builder.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { GetUniqueString } from "@/jslib/common/spec/utils";

import { UserEntry } from "@/src/models/userEntry";

import { RequestBuilderOptions } from "../abstractions/request-builder.service";
import { groupSimulator, userSimulator } from "../utils/request-builder-helper";

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

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

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

const defaultOptions: RequestBuilderOptions = Object.freeze({
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 = { ...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 disabledUserEmail = GetUniqueString() + "@example.com";
disabledUser.disabled = true;
disabledUser.email = disabledUserEmail;
mockUsers.push(disabledUser);

const options = { ...defaultOptions, removeDisabled: true };
const request = singleRequestBuilder.buildRequest(mockGroups, mockUsers, options)[0];

expect(request.members.length).toEqual(201);
expect(request.members.pop()).toEqual(
expect.objectContaining({ email: disabledUserEmail, deleted: true }),
);
expect(request.overwriteExisting).toBe(false);
});

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 disabledUserEmail = GetUniqueString() + "@example.com";
disabledUser.disabled = true;
disabledUser.email = disabledUserEmail;
mockUsers.push(disabledUser);

const options = { overwriteExisting: true, removeDisabled: true };
const request = singleRequestBuilder.buildRequest(mockGroups, mockUsers, options)[0];

expect(request.members.pop()).toEqual(
expect.objectContaining({ email: disabledUserEmail, deleted: true }),
);
expect(request.overwriteExisting).toBe(true);
});
});
9 changes: 4 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,8 +15,7 @@ export class SingleRequestBuilder implements RequestBuilder {
buildRequest(
groups: GroupEntry[],
users: UserEntry[],
removeDisabled: boolean,
overwriteExisting: boolean,
options: RequestBuilderOptions,
): OrganizationImportRequest[] {
return [
new OrganizationImportRequest({
Expand All @@ -31,10 +30,10 @@ export class SingleRequestBuilder implements RequestBuilder {
return {
email: u.email,
externalId: u.externalId,
deleted: u.deleted || (removeDisabled && u.disabled),
deleted: u.deleted || (options.removeDisabled && u.disabled),
};
}),
overwriteExisting: overwriteExisting,
overwriteExisting: options.overwriteExisting,
largeImport: false,
}),
];
Expand Down
Loading
Loading