mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 11:13:44 +00:00
PM-20112 adding making changes to include a new bug for the Member Access Report
This commit is contained in:
@@ -4558,6 +4558,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportGenerationComplete": {
|
||||
"message": "Report generation complete"
|
||||
},
|
||||
"riskInsightsRunReport": {
|
||||
"message": "Run report"
|
||||
},
|
||||
|
||||
@@ -55,12 +55,13 @@
|
||||
></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<button type="button" bitLink (click)="edit(row)">
|
||||
{{ row.name }}
|
||||
{{ row.name || row.email }}
|
||||
</button>
|
||||
|
||||
<div class="tw-text-sm tw-mt-1 tw-text-muted">
|
||||
{{ row.email }}
|
||||
</div>
|
||||
@if (row.name) {
|
||||
<div class="tw-text-sm tw-mt-1 tw-text-muted">
|
||||
{{ row.email }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Unit tests for MemberAccessReportComponent
|
||||
*
|
||||
* These tests focus on the member column display logic to ensure:
|
||||
* - Users with names show name as primary text and email as secondary
|
||||
* - Users without names show email as primary text with no secondary text
|
||||
*/
|
||||
|
||||
describe("MemberAccessReportComponent - Member Column Display Logic", () => {
|
||||
describe("Template logic for member name display", () => {
|
||||
it("should use name when available (name || email pattern)", () => {
|
||||
const user = { name: "John Doe", email: "john@example.com" };
|
||||
const displayText = user.name || user.email;
|
||||
expect(displayText).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("should fallback to email when name is empty string", () => {
|
||||
const user = { name: "", email: "noname@example.com" };
|
||||
const displayText = user.name || user.email;
|
||||
expect(displayText).toBe("noname@example.com");
|
||||
});
|
||||
|
||||
it("should fallback to email when name is null", () => {
|
||||
const user = { name: null as any, email: "null@example.com" };
|
||||
const displayText = user.name || user.email;
|
||||
expect(displayText).toBe("null@example.com");
|
||||
});
|
||||
|
||||
it("should fallback to email when name is undefined", () => {
|
||||
const user = { name: undefined as any, email: "undefined@example.com" };
|
||||
const displayText = user.name || user.email;
|
||||
expect(displayText).toBe("undefined@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Template logic for showing secondary email", () => {
|
||||
it("should show secondary email when user has a name", () => {
|
||||
const user = { name: "John Doe", email: "john@example.com" };
|
||||
const showSecondary = !!user.name;
|
||||
expect(showSecondary).toBe(true);
|
||||
});
|
||||
|
||||
it("should not show secondary email when name is empty string", () => {
|
||||
const user = { name: "", email: "noname@example.com" };
|
||||
const showSecondary = !!user.name;
|
||||
expect(showSecondary).toBe(false);
|
||||
});
|
||||
|
||||
it("should not show secondary email when name is null", () => {
|
||||
const user = { name: null as any, email: "null@example.com" };
|
||||
const showSecondary = !!user.name;
|
||||
expect(showSecondary).toBe(false);
|
||||
});
|
||||
|
||||
it("should not show secondary email when name is undefined", () => {
|
||||
const user = { name: undefined as any, email: "undefined@example.com" };
|
||||
const showSecondary = !!user.name;
|
||||
expect(showSecondary).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combined scenarios matching template", () => {
|
||||
interface TestUser {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const getDisplayInfo = (user: TestUser) => {
|
||||
return {
|
||||
primaryText: user.name || user.email,
|
||||
showSecondaryEmail: !!user.name,
|
||||
secondaryText: user.name ? user.email : null,
|
||||
};
|
||||
};
|
||||
|
||||
it("should correctly display user with name", () => {
|
||||
const user = { name: "Alice Smith", email: "alice@example.com" };
|
||||
const display = getDisplayInfo(user);
|
||||
|
||||
expect(display.primaryText).toBe("Alice Smith");
|
||||
expect(display.showSecondaryEmail).toBe(true);
|
||||
expect(display.secondaryText).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("should correctly display user without name", () => {
|
||||
const user = { name: "", email: "bob@example.com" };
|
||||
const display = getDisplayInfo(user);
|
||||
|
||||
expect(display.primaryText).toBe("bob@example.com");
|
||||
expect(display.showSecondaryEmail).toBe(false);
|
||||
expect(display.secondaryText).toBe(null);
|
||||
});
|
||||
|
||||
it("should handle multiple users with mixed scenarios", () => {
|
||||
const users = [
|
||||
{ name: "User With Name", email: "with@example.com" },
|
||||
{ name: "", email: "without@example.com" },
|
||||
{ name: "Another Name", email: "another@example.com" },
|
||||
];
|
||||
|
||||
const displays = users.map(getDisplayInfo);
|
||||
|
||||
// First user (with name)
|
||||
expect(displays[0].primaryText).toBe("User With Name");
|
||||
expect(displays[0].showSecondaryEmail).toBe(true);
|
||||
expect(displays[0].secondaryText).toBe("with@example.com");
|
||||
|
||||
// Second user (without name)
|
||||
expect(displays[1].primaryText).toBe("without@example.com");
|
||||
expect(displays[1].showSecondaryEmail).toBe(false);
|
||||
expect(displays[1].secondaryText).toBe(null);
|
||||
|
||||
// Third user (with name)
|
||||
expect(displays[2].primaryText).toBe("Another Name");
|
||||
expect(displays[2].showSecondaryEmail).toBe(true);
|
||||
expect(displays[2].secondaryText).toBe("another@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Template pattern verification", () => {
|
||||
it("should match the pattern used in Members page", () => {
|
||||
// This test verifies that our logic matches the Members page pattern:
|
||||
// Primary: {{ row.name || row.email }}
|
||||
// Secondary: @if (row.name) { {{ row.email }} }
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
user: { name: "Test User", email: "test@example.com" },
|
||||
expectedPrimary: "Test User",
|
||||
expectedShowSecondary: true,
|
||||
},
|
||||
{
|
||||
user: { name: "", email: "noname@example.com" },
|
||||
expectedPrimary: "noname@example.com",
|
||||
expectedShowSecondary: false,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ user, expectedPrimary, expectedShowSecondary }) => {
|
||||
const primary = user.name || user.email;
|
||||
const showSecondary = !!user.name;
|
||||
|
||||
expect(primary).toBe(expectedPrimary);
|
||||
expect(showSecondary).toBe(expectedShowSecondary);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
MemberAccessProgress,
|
||||
MemberAccessProgressConfig,
|
||||
MemberAccessProgressState,
|
||||
calculateProgressPercentage,
|
||||
} from "./member-access-progress";
|
||||
|
||||
describe("MemberAccessProgress", () => {
|
||||
describe("Progress Configuration", () => {
|
||||
it("should have valid configuration for all progress steps", () => {
|
||||
// Verify all steps have a config entry
|
||||
Object.values(MemberAccessProgress).forEach((step) => {
|
||||
const config = MemberAccessProgressConfig[step];
|
||||
expect(config).toBeDefined();
|
||||
expect(config.messageKey).toBeDefined();
|
||||
expect(config.progress).toBeDefined();
|
||||
expect(typeof config.messageKey).toBe("string");
|
||||
expect(typeof config.progress).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have 'reportGenerationComplete' as the completion message key", () => {
|
||||
const completeConfig = MemberAccessProgressConfig[MemberAccessProgress.Complete];
|
||||
expect(completeConfig.messageKey).toBe("reportGenerationComplete");
|
||||
});
|
||||
|
||||
it("should have 100% progress for Complete step", () => {
|
||||
const completeConfig = MemberAccessProgressConfig[MemberAccessProgress.Complete];
|
||||
expect(completeConfig.progress).toBe(100);
|
||||
});
|
||||
|
||||
it("should have sequential progress values", () => {
|
||||
const progressValues = [
|
||||
MemberAccessProgressConfig[MemberAccessProgress.FetchingMembers].progress,
|
||||
MemberAccessProgressConfig[MemberAccessProgress.FetchingCollections].progress,
|
||||
MemberAccessProgressConfig[MemberAccessProgress.FetchingGroups].progress,
|
||||
MemberAccessProgressConfig[MemberAccessProgress.FetchingCipherCounts].progress,
|
||||
MemberAccessProgressConfig[MemberAccessProgress.BuildingMaps].progress,
|
||||
MemberAccessProgressConfig[MemberAccessProgress.ProcessingMembers].progress,
|
||||
MemberAccessProgressConfig[MemberAccessProgress.Complete].progress,
|
||||
];
|
||||
|
||||
// Verify values are in ascending order
|
||||
for (let i = 0; i < progressValues.length - 1; i++) {
|
||||
expect(progressValues[i]).toBeLessThanOrEqual(progressValues[i + 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have unique message keys for all steps", () => {
|
||||
const messageKeys = Object.values(MemberAccessProgressConfig).map((c) => c.messageKey);
|
||||
const uniqueKeys = new Set(messageKeys);
|
||||
expect(uniqueKeys.size).toBe(messageKeys.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateProgressPercentage", () => {
|
||||
it("should return static progress for non-ProcessingMembers steps", () => {
|
||||
const state: MemberAccessProgressState = {
|
||||
step: MemberAccessProgress.FetchingMembers,
|
||||
processedMembers: 0,
|
||||
totalMembers: 100,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const result = calculateProgressPercentage(state);
|
||||
expect(result).toBe(
|
||||
MemberAccessProgressConfig[MemberAccessProgress.FetchingMembers].progress,
|
||||
);
|
||||
});
|
||||
|
||||
it("should calculate dynamic progress for ProcessingMembers step", () => {
|
||||
const state: MemberAccessProgressState = {
|
||||
step: MemberAccessProgress.ProcessingMembers,
|
||||
processedMembers: 50,
|
||||
totalMembers: 100,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const result = calculateProgressPercentage(state);
|
||||
// 35% base + (50/100 * 60%) = 35% + 30% = 65%
|
||||
expect(result).toBe(65);
|
||||
});
|
||||
|
||||
it("should return 35% for ProcessingMembers with 0 processed", () => {
|
||||
const state: MemberAccessProgressState = {
|
||||
step: MemberAccessProgress.ProcessingMembers,
|
||||
processedMembers: 0,
|
||||
totalMembers: 100,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const result = calculateProgressPercentage(state);
|
||||
expect(result).toBe(35);
|
||||
});
|
||||
|
||||
it("should cap at 95% for ProcessingMembers with all processed", () => {
|
||||
const state: MemberAccessProgressState = {
|
||||
step: MemberAccessProgress.ProcessingMembers,
|
||||
processedMembers: 100,
|
||||
totalMembers: 100,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const result = calculateProgressPercentage(state);
|
||||
// 35% + (100/100 * 60%) = 35% + 60% = 95%
|
||||
expect(result).toBe(95);
|
||||
});
|
||||
|
||||
it("should handle ProcessingMembers with 0 total members", () => {
|
||||
const state: MemberAccessProgressState = {
|
||||
step: MemberAccessProgress.ProcessingMembers,
|
||||
processedMembers: 0,
|
||||
totalMembers: 0,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const result = calculateProgressPercentage(state);
|
||||
// Should use static progress when totalMembers is 0
|
||||
expect(result).toBe(
|
||||
MemberAccessProgressConfig[MemberAccessProgress.ProcessingMembers].progress,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 100% for Complete step", () => {
|
||||
const state: MemberAccessProgressState = {
|
||||
step: MemberAccessProgress.Complete,
|
||||
processedMembers: 100,
|
||||
totalMembers: 100,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const result = calculateProgressPercentage(state);
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
|
||||
it("should calculate correct progress for partial processing", () => {
|
||||
const testCases = [
|
||||
{ processed: 10, total: 100, expected: 41 }, // 35 + (10/100 * 60)
|
||||
{ processed: 25, total: 100, expected: 50 }, // 35 + (25/100 * 60)
|
||||
{ processed: 75, total: 100, expected: 80 }, // 35 + (75/100 * 60)
|
||||
];
|
||||
|
||||
testCases.forEach(({ processed, total, expected }) => {
|
||||
const state: MemberAccessProgressState = {
|
||||
step: MemberAccessProgress.ProcessingMembers,
|
||||
processedMembers: processed,
|
||||
totalMembers: total,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const result = calculateProgressPercentage(state);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ export const MemberAccessProgressConfig = Object.freeze({
|
||||
progress: 35,
|
||||
},
|
||||
[MemberAccessProgress.Complete]: {
|
||||
messageKey: "complete",
|
||||
messageKey: "reportGenerationComplete",
|
||||
progress: 100,
|
||||
},
|
||||
} as const);
|
||||
|
||||
@@ -1,168 +1,520 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { GroupApiService } from "@bitwarden/web-vault/app/admin-console/organizations/core/services/group/group-api.service";
|
||||
import { GroupDetailsView } from "@bitwarden/web-vault/app/admin-console/organizations/core/views/group-details.view";
|
||||
|
||||
import { MemberAccessReportApiService } from "./member-access-report-api.service";
|
||||
import {
|
||||
memberAccessReportsMock,
|
||||
memberAccessWithoutAccessDetailsReportsMock,
|
||||
} from "./member-access-report.mock";
|
||||
import { MemberAccessReportService } from "./member-access-report.service";
|
||||
|
||||
describe("ImportService", () => {
|
||||
describe("MemberAccessReportService", () => {
|
||||
const mockOrganizationId = "mockOrgId" as OrganizationId;
|
||||
const reportApiService = mock<MemberAccessReportApiService>();
|
||||
const mockEncryptService = mock<EncryptService>();
|
||||
const userId = newGuid() as UserId;
|
||||
|
||||
const mockOrganizationUserApiService = mock<OrganizationUserApiService>();
|
||||
const mockCollectionAdminService = mock<CollectionAdminService>();
|
||||
const mockGroupApiService = mock<GroupApiService>();
|
||||
const mockApiService = mock<ApiService>();
|
||||
const mockEncryptService = mock<EncryptService>();
|
||||
const mockAccountService = mockAccountServiceWith(userId);
|
||||
const mockKeyService = mock<KeyService>();
|
||||
let memberAccessReportService: MemberAccessReportService;
|
||||
const i18nMock = mock<I18nService>({
|
||||
const mockI18nService = mock<I18nService>({
|
||||
t(key) {
|
||||
return key;
|
||||
},
|
||||
});
|
||||
|
||||
let service: MemberAccessReportService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockKeyService.orgKeys$.mockReturnValue(
|
||||
of({ mockOrgId: new SymmetricCryptoKey(new Uint8Array(64)) }),
|
||||
);
|
||||
reportApiService.getMemberAccessData.mockImplementation(() =>
|
||||
Promise.resolve(memberAccessReportsMock),
|
||||
);
|
||||
memberAccessReportService = new MemberAccessReportService(
|
||||
reportApiService,
|
||||
i18nMock,
|
||||
|
||||
// Mock account service
|
||||
mockAccountService.activeAccount$ = of({ id: userId } as any);
|
||||
|
||||
service = new MemberAccessReportService(
|
||||
mockI18nService,
|
||||
mockEncryptService,
|
||||
mockKeyService,
|
||||
mockAccountService,
|
||||
mockOrganizationUserApiService,
|
||||
mockCollectionAdminService,
|
||||
mockGroupApiService,
|
||||
mockApiService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("generateMemberAccessReportView", () => {
|
||||
it("should generate member access report view", async () => {
|
||||
const result =
|
||||
await memberAccessReportService.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "Sarah Johnson",
|
||||
email: "sjohnson@email.com",
|
||||
collectionsCount: 3,
|
||||
groupsCount: 1,
|
||||
itemsCount: 0,
|
||||
userGuid: expect.any(String),
|
||||
usesKeyConnector: expect.any(Boolean),
|
||||
},
|
||||
{
|
||||
name: "James Lull",
|
||||
email: "jlull@email.com",
|
||||
collectionsCount: 2,
|
||||
groupsCount: 1,
|
||||
itemsCount: 0,
|
||||
userGuid: expect.any(String),
|
||||
usesKeyConnector: expect.any(Boolean),
|
||||
},
|
||||
{
|
||||
name: "Beth Williams",
|
||||
email: "bwilliams@email.com",
|
||||
collectionsCount: 2,
|
||||
groupsCount: 1,
|
||||
itemsCount: 0,
|
||||
userGuid: expect.any(String),
|
||||
usesKeyConnector: expect.any(Boolean),
|
||||
},
|
||||
{
|
||||
name: "Ray Williams",
|
||||
email: "rwilliams@email.com",
|
||||
collectionsCount: 3,
|
||||
groupsCount: 3,
|
||||
itemsCount: 0,
|
||||
userGuid: expect.any(String),
|
||||
usesKeyConnector: expect.any(Boolean),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateUserReportExportItems", () => {
|
||||
it("should generate user report export items", async () => {
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItems(mockOrganizationId);
|
||||
describe("(No Name) fallback", () => {
|
||||
it("should use '(No Name)' when user has empty name string", async () => {
|
||||
// Setup mock data with empty name
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "", // Empty name
|
||||
email: "user@example.com",
|
||||
twoFactorEnabled: false,
|
||||
resetPasswordEnrolled: false,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group1"],
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
const filteredReportItems = result
|
||||
.filter(
|
||||
(item) =>
|
||||
(item.name === "Sarah Johnson" &&
|
||||
item.group === "Group 1" &&
|
||||
item.totalItems === "0") ||
|
||||
(item.name === "James Lull" && item.group === "Group 4" && item.totalItems === "0"),
|
||||
)
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
email: item.email,
|
||||
group: item.group,
|
||||
totalItems: item.totalItems,
|
||||
accountRecovery: item.accountRecovery,
|
||||
twoStepLogin: item.twoStepLogin,
|
||||
}));
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "col1",
|
||||
name: "Test Collection",
|
||||
groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }],
|
||||
users: [],
|
||||
} as CollectionAdminView,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(filteredReportItems).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: "sjohnson@email.com",
|
||||
name: "Sarah Johnson",
|
||||
twoStepLogin: "memberAccessReportTwoFactorEnabledTrue",
|
||||
accountRecovery: "memberAccessReportAuthenticationEnabledTrue",
|
||||
group: "Group 1",
|
||||
totalItems: "0",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "jlull@email.com",
|
||||
name: "James Lull",
|
||||
twoStepLogin: "memberAccessReportTwoFactorEnabledFalse",
|
||||
accountRecovery: "memberAccessReportAuthenticationEnabledFalse",
|
||||
group: "Group 4",
|
||||
totalItems: "0",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([
|
||||
{
|
||||
id: "group1",
|
||||
name: "Test Group",
|
||||
collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }],
|
||||
} as GroupDetailsView,
|
||||
]);
|
||||
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first (required before export)
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
// Verify all export items have "(No Name)" instead of empty string
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
result.forEach((item) => {
|
||||
expect(item.name).toBe("(No Name)");
|
||||
});
|
||||
});
|
||||
|
||||
it("should use actual name when user has non-empty name", async () => {
|
||||
// Setup mock data with actual name
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
twoFactorEnabled: false,
|
||||
resetPasswordEnrolled: false,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group1"],
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "col1",
|
||||
name: "Test Collection",
|
||||
groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }],
|
||||
users: [],
|
||||
} as CollectionAdminView,
|
||||
]),
|
||||
);
|
||||
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([
|
||||
{
|
||||
id: "group1",
|
||||
name: "Test Group",
|
||||
collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }],
|
||||
} as GroupDetailsView,
|
||||
]);
|
||||
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
// Verify actual name is used
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
result.forEach((item) => {
|
||||
expect(item.name).toBe("John Doe");
|
||||
});
|
||||
});
|
||||
|
||||
it("should use '(No Name)' for users with no collection or group access", async () => {
|
||||
// Setup mock data with empty name and no access
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "", // Empty name
|
||||
email: "user@example.com",
|
||||
twoFactorEnabled: false,
|
||||
resetPasswordEnrolled: false,
|
||||
usesKeyConnector: false,
|
||||
groups: [], // No groups
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([]));
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([]);
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
// Should have one row for user with no access
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("(No Name)");
|
||||
expect(result[0].email).toBe("user@example.com");
|
||||
expect(result[0].group).toBe("memberAccessReportNoGroup");
|
||||
expect(result[0].collection).toBe("memberAccessReportNoCollection");
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate user report export items and include users with no access", async () => {
|
||||
reportApiService.getMemberAccessData.mockImplementation(() =>
|
||||
Promise.resolve(memberAccessWithoutAccessDetailsReportsMock),
|
||||
);
|
||||
const result =
|
||||
await memberAccessReportService.generateUserReportExportItems(mockOrganizationId);
|
||||
describe("Groups with no collections", () => {
|
||||
it("should include group membership even when group has no collections", async () => {
|
||||
// Setup: user in a group, but group has no collections
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
twoFactorEnabled: true,
|
||||
resetPasswordEnrolled: true,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group1"], // User is in group1
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: "asmith@email.com",
|
||||
name: "Alice Smith",
|
||||
twoStepLogin: "memberAccessReportTwoFactorEnabledTrue",
|
||||
accountRecovery: "memberAccessReportAuthenticationEnabledTrue",
|
||||
group: "Alice Group 1",
|
||||
totalItems: "0",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "rbrown@email.com",
|
||||
name: "Robert Brown",
|
||||
twoStepLogin: "memberAccessReportTwoFactorEnabledFalse",
|
||||
accountRecovery: "memberAccessReportAuthenticationEnabledFalse",
|
||||
group: "memberAccessReportNoGroup",
|
||||
totalItems: "0",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
// No collections at all
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([]));
|
||||
|
||||
// Group exists but has no collections
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([
|
||||
{
|
||||
id: "group1",
|
||||
name: "Empty Group",
|
||||
collections: [], // No collections
|
||||
} as GroupDetailsView,
|
||||
]);
|
||||
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
// Should have one row showing the group membership
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("Jane Doe");
|
||||
expect(result[0].email).toBe("jane@example.com");
|
||||
expect(result[0].group).toBe("Empty Group");
|
||||
expect(result[0].collection).toBe("memberAccessReportNoCollection");
|
||||
expect(result[0].collectionPermission).toBe("memberAccessReportNoCollectionPermission");
|
||||
expect(result[0].totalItems).toBe("0");
|
||||
});
|
||||
|
||||
it("should create separate rows for groups with collections and groups without", async () => {
|
||||
// Setup: user in two groups, one with collections and one without
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "Multi Group User",
|
||||
email: "multi@example.com",
|
||||
twoFactorEnabled: false,
|
||||
resetPasswordEnrolled: false,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group1", "group2"], // In both groups
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
// One collection assigned to group1
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "col1",
|
||||
name: "Collection 1",
|
||||
groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: true }],
|
||||
users: [],
|
||||
} as CollectionAdminView,
|
||||
]),
|
||||
);
|
||||
|
||||
// group1 has collection, group2 does not
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([
|
||||
{
|
||||
id: "group1",
|
||||
name: "Group With Collection",
|
||||
collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: true }],
|
||||
} as GroupDetailsView,
|
||||
{
|
||||
id: "group2",
|
||||
name: "Group Without Collection",
|
||||
collections: [],
|
||||
} as GroupDetailsView,
|
||||
]);
|
||||
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
// Should have 2 rows: one for group1 with collection, one for group2 without
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// Find rows by group name
|
||||
const group1Row = result.find((r) => r.group === "Group With Collection");
|
||||
const group2Row = result.find((r) => r.group === "Group Without Collection");
|
||||
|
||||
expect(group1Row).toBeDefined();
|
||||
expect(group1Row?.collection).toBe("Collection 1");
|
||||
expect(group1Row?.collectionPermission).not.toBe(
|
||||
"memberAccessReportNoCollectionPermission",
|
||||
);
|
||||
|
||||
expect(group2Row).toBeDefined();
|
||||
expect(group2Row?.collection).toBe("memberAccessReportNoCollection");
|
||||
expect(group2Row?.collectionPermission).toBe("memberAccessReportNoCollectionPermission");
|
||||
expect(group2Row?.totalItems).toBe("0");
|
||||
});
|
||||
|
||||
it("should show multiple collections for group with collections", async () => {
|
||||
// Setup: user in group with multiple collections
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "User Name",
|
||||
email: "user@example.com",
|
||||
twoFactorEnabled: false,
|
||||
resetPasswordEnrolled: false,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group1"],
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
// Two collections both assigned to group1
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "col1",
|
||||
name: "Collection 1",
|
||||
groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }],
|
||||
users: [],
|
||||
} as CollectionAdminView,
|
||||
{
|
||||
id: "col2",
|
||||
name: "Collection 2",
|
||||
groups: [{ id: "group1", readOnly: true, hidePasswords: false, manage: false }],
|
||||
users: [],
|
||||
} as CollectionAdminView,
|
||||
]),
|
||||
);
|
||||
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([
|
||||
{
|
||||
id: "group1",
|
||||
name: "Multi Collection Group",
|
||||
collections: [
|
||||
{ id: "col1", readOnly: false, hidePasswords: false, manage: false },
|
||||
{ id: "col2", readOnly: true, hidePasswords: false, manage: false },
|
||||
],
|
||||
} as GroupDetailsView,
|
||||
]);
|
||||
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
// Should have 2 rows, one for each collection
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].group).toBe("Multi Collection Group");
|
||||
expect(result[1].group).toBe("Multi Collection Group");
|
||||
|
||||
const collectionNames = result.map((r) => r.collection).sort();
|
||||
expect(collectionNames).toEqual(["Collection 1", "Collection 2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combined scenarios", () => {
|
||||
it("should handle user with empty name in group with no collections", async () => {
|
||||
// Combines both fixes: empty name + group without collections
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "",
|
||||
email: "noname@example.com",
|
||||
twoFactorEnabled: true,
|
||||
resetPasswordEnrolled: false,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group1"],
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(of([]));
|
||||
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([
|
||||
{
|
||||
id: "group1",
|
||||
name: "No Collection Group",
|
||||
collections: [],
|
||||
} as GroupDetailsView,
|
||||
]);
|
||||
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("(No Name)");
|
||||
expect(result[0].group).toBe("No Collection Group");
|
||||
expect(result[0].collection).toBe("memberAccessReportNoCollection");
|
||||
});
|
||||
|
||||
it("should handle multiple users with mixed name and group scenarios", async () => {
|
||||
// Complex scenario with multiple users
|
||||
mockOrganizationUserApiService.getAllUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
twoFactorEnabled: true,
|
||||
resetPasswordEnrolled: true,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group1"],
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
{
|
||||
id: "user2",
|
||||
name: "",
|
||||
email: "bob@example.com",
|
||||
twoFactorEnabled: false,
|
||||
resetPasswordEnrolled: false,
|
||||
usesKeyConnector: false,
|
||||
groups: ["group2"],
|
||||
avatarColor: null,
|
||||
} as any,
|
||||
],
|
||||
} as ListResponse<any>);
|
||||
|
||||
mockCollectionAdminService.collectionAdminViews$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "col1",
|
||||
name: "Collection A",
|
||||
groups: [{ id: "group1", readOnly: false, hidePasswords: false, manage: false }],
|
||||
users: [],
|
||||
} as CollectionAdminView,
|
||||
]),
|
||||
);
|
||||
|
||||
mockGroupApiService.getAllDetails.mockResolvedValue([
|
||||
{
|
||||
id: "group1",
|
||||
name: "Group A",
|
||||
collections: [{ id: "col1", readOnly: false, hidePasswords: false, manage: false }],
|
||||
} as GroupDetailsView,
|
||||
{
|
||||
id: "group2",
|
||||
name: "Group B",
|
||||
collections: [], // No collections
|
||||
} as GroupDetailsView,
|
||||
]);
|
||||
|
||||
mockApiService.getCiphersOrganization.mockResolvedValue({
|
||||
data: [] as CipherResponse[],
|
||||
});
|
||||
|
||||
// Generate report view first
|
||||
await service.generateMemberAccessReportView(mockOrganizationId);
|
||||
|
||||
// Generate export
|
||||
const result = await service.generateUserReportExportItems(mockOrganizationId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// Alice should have actual name with collection
|
||||
const aliceRow = result.find((r) => r.email === "alice@example.com");
|
||||
expect(aliceRow?.name).toBe("Alice");
|
||||
expect(aliceRow?.group).toBe("Group A");
|
||||
expect(aliceRow?.collection).toBe("Collection A");
|
||||
|
||||
// Bob should have "(No Name)" and group without collection
|
||||
const bobRow = result.find((r) => r.email === "bob@example.com");
|
||||
expect(bobRow?.name).toBe("(No Name)");
|
||||
expect(bobRow?.group).toBe("Group B");
|
||||
expect(bobRow?.collection).toBe("memberAccessReportNoCollection");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,7 +401,7 @@ export class MemberAccessReportService {
|
||||
for (const access of directAccess) {
|
||||
exportItems.push({
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
name: member.name || "(No Name)",
|
||||
twoStepLogin: member.twoFactorEnabled
|
||||
? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
|
||||
: this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
|
||||
@@ -421,10 +421,30 @@ export class MemberAccessReportService {
|
||||
const groupCollections = groupCollectionMap.get(groupId) || [];
|
||||
const groupName = groupNameMap.get(groupId) || "Unknown Group";
|
||||
|
||||
for (const access of groupCollections) {
|
||||
if (groupCollections.length > 0) {
|
||||
// Group has collection access - create a row for each collection
|
||||
for (const access of groupCollections) {
|
||||
exportItems.push({
|
||||
email: member.email,
|
||||
name: member.name || "(No Name)",
|
||||
twoStepLogin: member.twoFactorEnabled
|
||||
? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
|
||||
: this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
|
||||
accountRecovery: member.resetPasswordEnrolled
|
||||
? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue")
|
||||
: this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"),
|
||||
group: groupName,
|
||||
collection:
|
||||
access.collectionName || this.i18nService.t("memberAccessReportNoCollection"),
|
||||
collectionPermission: this.getPermissionText(access),
|
||||
totalItems: String(collectionCipherCountMap.get(access.collectionId) || 0),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Group has no collection access - still show the group membership
|
||||
exportItems.push({
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
name: member.name || "(No Name)",
|
||||
twoStepLogin: member.twoFactorEnabled
|
||||
? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
|
||||
: this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
|
||||
@@ -432,10 +452,9 @@ export class MemberAccessReportService {
|
||||
? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue")
|
||||
: this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"),
|
||||
group: groupName,
|
||||
collection:
|
||||
access.collectionName || this.i18nService.t("memberAccessReportNoCollection"),
|
||||
collectionPermission: this.getPermissionText(access),
|
||||
totalItems: String(collectionCipherCountMap.get(access.collectionId) || 0),
|
||||
collection: this.i18nService.t("memberAccessReportNoCollection"),
|
||||
collectionPermission: this.i18nService.t("memberAccessReportNoCollectionPermission"),
|
||||
totalItems: "0",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -444,7 +463,7 @@ export class MemberAccessReportService {
|
||||
if (directAccess.length === 0 && memberGroups.length === 0) {
|
||||
exportItems.push({
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
name: member.name || "(No Name)",
|
||||
twoStepLogin: member.twoFactorEnabled
|
||||
? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
|
||||
: this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
|
||||
|
||||
Reference in New Issue
Block a user