Skip to content
Open
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
31 changes: 30 additions & 1 deletion .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ on:
- "libs/services/sync.service.ts" # core sync service used by all directory services
- "libs/services/directory-services/ldap-directory.service*" # LDAP directory service
- "libs/services/directory-services/gsuite-directory.service*" # Google Workspace directory service
- "libs/services/directory-services/entra-id-directory.service*" # Microsoft Entra ID directory service
- "libs/services/directory-services/okta-directory.service*" # Okta directory service
# Add directory services here as we add test coverage
pull_request:
paths:
Expand All @@ -27,6 +29,8 @@ on:
- "libs/services/sync.service.ts" # core sync service used by all directory services
- "libs/services/directory-services/ldap-directory.service*" # LDAP directory service
- "libs/services/directory-services/gsuite-directory.service*" # Google Workspace directory service
- "libs/services/directory-services/entra-id-directory.service*" # Microsoft Entra ID directory service
- "libs/services/directory-services/okta-directory.service*" # Okta directory service
# Add directory services here as we add test coverage
permissions:
contents: read
Expand Down Expand Up @@ -74,7 +78,7 @@ jobs:
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-directory-connector
secrets: "GOOGLE-ADMIN-USER,GOOGLE-CLIENT-EMAIL,GOOGLE-DOMAIN,GOOGLE-PRIVATE-KEY"
secrets: "GOOGLE-ADMIN-USER,GOOGLE-CLIENT-EMAIL,GOOGLE-DOMAIN,GOOGLE-PRIVATE-KEY,ENTRA-TENANT-ID,ENTRA-APPLICATION-ID,ENTRA-KEY,OKTA-ORG-URL,OKTA-TOKEN"

- name: Azure Logout
uses: bitwarden/gh-actions/azure-logout@main
Expand All @@ -100,6 +104,10 @@ jobs:
- 'libs/services/directory-services/ldap-directory.service*'
google:
- 'libs/services/directory-services/gsuite-directory.service*'
entra:
- 'libs/services/directory-services/entra-id-directory.service*'
okta:
- 'libs/services/directory-services/okta-directory.service*'

# LDAP
- name: Setup LDAP integration tests
Expand Down Expand Up @@ -128,6 +136,27 @@ jobs:
run: |
node --experimental-vm-modules node_modules/.bin/jest gsuite-directory.service.integration.spec.ts --coverage --coverageDirectory=coverage-google

# Microsoft Entra ID
- name: Run Entra ID integration tests
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.entra == 'true'
env:
ENTRA_TENANT_ID: ${{ steps.get-kv-secrets.outputs.ENTRA-TENANT-ID }}
ENTRA_APPLICATION_ID: ${{ steps.get-kv-secrets.outputs.ENTRA-APPLICATION-ID }}
ENTRA_KEY: ${{ steps.get-kv-secrets.outputs.ENTRA-KEY }}
JEST_JUNIT_UNIQUE_OUTPUT_NAME: "true" # avoids junit outputs from clashing
run: |
node --experimental-vm-modules node_modules/.bin/jest entra-id-directory.service.integration.spec.ts --coverage --coverageDirectory=coverage-entra

# Okta
- name: Run Okta integration tests
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.okta == 'true'
env:
OKTA_ORG_URL: ${{ steps.get-kv-secrets.outputs.OKTA-ORG-URL }}
OKTA_TOKEN: ${{ steps.get-kv-secrets.outputs.OKTA-TOKEN }}
JEST_JUNIT_UNIQUE_OUTPUT_NAME: "true" # avoids junit outputs from clashing
run: |
node --experimental-vm-modules node_modules/.bin/jest okta-directory.service.integration.spec.ts --coverage --coverageDirectory=coverage-okta

- name: Report test results
id: report
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @jest-environment node
*/
import { config as dotenvConfig } from "dotenv";
import { mock, MockProxy } from "jest-mock-extended";

import { I18nService } from "@/libs/abstractions/i18n.service";
import { LogService } from "@/libs/abstractions/log.service";
import { StateService } from "@/libs/abstractions/state.service";

import {
getEntraIdConfiguration,
getSyncConfiguration,
} from "../../../utils/entra/config-fixtures";
import { allGroupFixtures, filteredGroupFixtures } from "../../../utils/entra/group-fixtures";
import { allUserFixtures, groupAUserFixtures } from "../../../utils/entra/user-fixtures";
import { DirectoryType } from "../../enums/directoryType";

import { EntraIdDirectoryService } from "./entra-id-directory.service";

// These tests integrate with a test Microsoft Entra ID tenant.
// Credentials are located in the shared Bitwarden collection for Directory Connector testing.
// Place the .env file attachment in the utils folder.

// Load .env variables
dotenvConfig({ path: "utils/.env" });

// This filter targets integration test data.
// It should return data that matches the fixtures exactly.
const INTEGRATION_GROUP_FILTER = "include: Integration Testing Group A";

// These tests are slow!
// Increase the default timeout from 5s to 30s
jest.setTimeout(30000);

describe("entraIdDirectoryService", () => {
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateService>;

let directoryService: EntraIdDirectoryService;

beforeEach(() => {
logService = mock();
i18nService = mock();
stateService = mock();

stateService.getDirectoryType.mockResolvedValue(DirectoryType.EntraID);
stateService.getDirectory
.calledWith(DirectoryType.EntraID)
.mockResolvedValue(getEntraIdConfiguration());
stateService.getUserDelta.mockResolvedValue(null); // do not use delta link, fetch from scratch
i18nService.t.mockImplementation((id) => id); // passthrough for error messages

directoryService = new EntraIdDirectoryService(logService, i18nService, stateService);
});

it("syncs without filters (includes integration test data)", async () => {
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: true, groups: true }));

const [groups, users] = await directoryService.getEntries(true, true);

expect(groups).toEqual(expect.arrayContaining(allGroupFixtures));
expect(users).toEqual(expect.arrayContaining(allUserFixtures));
});

it("syncs users only (no groups)", async () => {
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: true, groups: false }));

const [groups, users] = await directoryService.getEntries(true, true);

expect(groups).toBeUndefined();
expect(users).toEqual(expect.arrayContaining(allUserFixtures));
});

it("syncs groups only (no users)", async () => {
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: false, groups: true }));

const [groups, users] = await directoryService.getEntries(true, true);

expect(groups).toEqual(expect.arrayContaining(allGroupFixtures));
expect(users).toBeUndefined();
});

it("syncs using group filter (exact match for integration test data)", async () => {
stateService.getSync.mockResolvedValue(
getSyncConfiguration({ users: true, groups: true, groupFilter: INTEGRATION_GROUP_FILTER }),
);

const result = await directoryService.getEntries(true, true);

expect(result).toEqual([filteredGroupFixtures, groupAUserFixtures]);
});

it("throws when credentials are invalid", async () => {
stateService.getDirectory
.calledWith(DirectoryType.EntraID)
.mockResolvedValue(getEntraIdConfiguration({ key: "bad-secret" }));
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: true, groups: true }));

await expect(directoryService.getEntries(true, true)).rejects.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @jest-environment node
*/
import { config as dotenvConfig } from "dotenv";
import { mock, MockProxy } from "jest-mock-extended";

import { I18nService } from "@/libs/abstractions/i18n.service";
import { LogService } from "@/libs/abstractions/log.service";
import { StateService } from "@/libs/abstractions/state.service";

import { getOktaConfiguration, getSyncConfiguration } from "../../../utils/okta/config-fixtures";
import { allGroupFixtures, integrationGroupFixtures } from "../../../utils/okta/group-fixtures";
import { allUserFixtures, integrationGroupUserFixtures } from "../../../utils/okta/user-fixtures";
import { DirectoryType } from "../../enums/directoryType";

import { OktaDirectoryService } from "./okta-directory.service";

// These tests integrate with a test Okta organization.
// Credentials are located in the shared Bitwarden collection for Directory Connector testing.
// Place the .env file attachment in the utils folder.

// Load .env variables
dotenvConfig({ path: "utils/.env" });

// This filter targets the integration test groups exactly.
const INTEGRATION_GROUP_FILTER = "include: Integration Test Group A, Integration Test Group B";

// These tests are slow!
// Increase the default timeout from 5s to 30s
jest.setTimeout(30000);

describe("oktaDirectoryService", () => {
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateService>;

let directoryService: OktaDirectoryService;

beforeEach(() => {
logService = mock();
i18nService = mock();
stateService = mock();

stateService.getDirectoryType.mockResolvedValue(DirectoryType.Okta);
stateService.getDirectory
.calledWith(DirectoryType.Okta)
.mockResolvedValue(getOktaConfiguration());
stateService.getLastUserSync.mockResolvedValue(null); // do not filter results by last modified date
stateService.getLastGroupSync.mockResolvedValue(null); // do not filter results by last modified date
i18nService.t.mockImplementation((id) => id); // passthrough for error messages

directoryService = new OktaDirectoryService(logService, i18nService, stateService);
});

it("syncs without filters (includes integration test data)", async () => {
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: true, groups: true }));

const [groups, users] = await directoryService.getEntries(true, true);

expect(groups).toEqual(expect.arrayContaining(allGroupFixtures));
expect(users).toEqual(expect.arrayContaining(allUserFixtures));
});

it("syncs users only (no groups)", async () => {
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: true, groups: false }));

const [groups, users] = await directoryService.getEntries(true, true);

expect(groups).toBeUndefined();
expect(users).toEqual(expect.arrayContaining(allUserFixtures));
});

it("syncs groups only (no users)", async () => {
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: false, groups: true }));

const [groups, users] = await directoryService.getEntries(true, true);

expect(groups).toEqual(expect.arrayContaining(allGroupFixtures));
expect(users).toBeUndefined();
});

it("syncs using group filter (exact match for integration test data)", async () => {
stateService.getSync.mockResolvedValue(
getSyncConfiguration({ users: true, groups: true, groupFilter: INTEGRATION_GROUP_FILTER }),
);

const [groups, users] = await directoryService.getEntries(true, true);
Comment thread
BTreston marked this conversation as resolved.

expect(groups).toEqual(expect.arrayContaining(integrationGroupFixtures));
expect(groups).toHaveLength(integrationGroupFixtures.length);
expect(users).toEqual(expect.arrayContaining(integrationGroupUserFixtures));
expect(users).toHaveLength(integrationGroupUserFixtures.length);
});

it("throws when credentials are invalid", async () => {
stateService.getDirectory
.calledWith(DirectoryType.Okta)
.mockResolvedValue(getOktaConfiguration({ token: "bad-token-00000" }));
stateService.getSync.mockResolvedValue(getSyncConfiguration({ users: true, groups: true }));

await expect(directoryService.getEntries(true, true)).rejects.toThrow();
});
});
53 changes: 53 additions & 0 deletions utils/entra/config-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { EntraIdConfiguration } from "@/libs/models/entraIdConfiguration";
import { SyncConfiguration } from "@/libs/models/syncConfiguration";

/**
* @returns a basic Entra ID configuration. Can be overridden by passing in a partial configuration.
*/
export const getEntraIdConfiguration = (
config?: Partial<EntraIdConfiguration>,
): EntraIdConfiguration => {
const tenant = process.env.ENTRA_TENANT_ID;
const applicationId = process.env.ENTRA_APPLICATION_ID;
const key = process.env.ENTRA_KEY;

if (!tenant || !applicationId || !key) {
throw new Error("Entra ID integration test credentials not configured.");
}

return {
identityAuthority: "login.microsoftonline.com",
tenant,
applicationId,
key,
...(config ?? {}),
};
};

/**
* @returns a basic sync configuration. Can be overridden by passing in a partial configuration.
*/
export const getSyncConfiguration = (config?: Partial<SyncConfiguration>): SyncConfiguration => ({
users: false,
groups: false,
interval: 5,
userFilter: "",
groupFilter: "",
removeDisabled: false,
overwriteExisting: false,
largeImport: false,
// Ldap properties - not optional for some reason
groupObjectClass: "",
userObjectClass: "",
groupPath: null,
userPath: null,
groupNameAttribute: "",
userEmailAttribute: "",
memberAttribute: "",
useEmailPrefixSuffix: false,
emailPrefixAttribute: "",
emailSuffix: null,
creationDateAttribute: "",
revisionDateAttribute: "",
...(config ?? {}),
});
46 changes: 46 additions & 0 deletions utils/entra/group-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Jsonify } from "type-fest";

import { GroupEntry } from "@/libs/models/groupEntry";

// These must match the Entra ID test tenant seed data

const allGroupsData: Jsonify<GroupEntry>[] = [
{
externalId: "573783bf-d086-4e1e-9084-4b7b0d2a5969",
groupMemberReferenceIds: [],
name: "Integration Testing Group A",
referenceId: "573783bf-d086-4e1e-9084-4b7b0d2a5969",
userMemberExternalIds: [
"34e12500-13ff-4700-afa0-285757ab7695",
"051c89c3-1788-44ce-9ee5-35dc1e6cb794",
"50fff556-010d-46e2-826a-c86ddf5816dc",
],
users: [],
},
{
externalId: "66578c29-73af-448b-b352-301f9588772b",
groupMemberReferenceIds: [],
name: "Integration Testing Group B",
referenceId: "66578c29-73af-448b-b352-301f9588772b",
userMemberExternalIds: [],
users: [],
},
];

const filteredGroupsData: Jsonify<GroupEntry>[] = [
{
externalId: "573783bf-d086-4e1e-9084-4b7b0d2a5969",
groupMemberReferenceIds: [],
name: "Integration Testing Group A",
referenceId: "573783bf-d086-4e1e-9084-4b7b0d2a5969",
userMemberExternalIds: [
"34e12500-13ff-4700-afa0-285757ab7695",
"051c89c3-1788-44ce-9ee5-35dc1e6cb794",
"50fff556-010d-46e2-826a-c86ddf5816dc",
],
users: [],
},
];

export const allGroupFixtures = allGroupsData.map((g) => GroupEntry.fromJSON(g));
export const filteredGroupFixtures = filteredGroupsData.map((g) => GroupEntry.fromJSON(g));
Loading
Loading