mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 09:43:23 +00:00
[PM-27662] Introduce vault item transfer service (#17876)
* [PM-27662] Add revision date to policy response * [PM-27662] Introduce vault item transfer service * [PM-27662] Add feature flag check * [PM-27662] Add tests * [PM-27662] Add basic implementation to Web vault * [PM-27662] Remove redundant for loop * [PM-27662] Remove unnecessary distinctUntilChanged * [PM-27662] Avoid subscribing to userMigrationInfo$ if feature flag disabled * [PM-27662] Make UserMigrationInfo type more strict * [PM-27662] Typo * [PM-27662] Fix missing i18n * [PM-27662] Fix tests * [PM-27662] Fix tests/types related to policy changes * [PM-27662] Use getById operator
This commit is contained in:
@@ -1475,6 +1475,9 @@
|
||||
"selectFile": {
|
||||
"message": "Select a file"
|
||||
},
|
||||
"itemsTransferred": {
|
||||
"message": "Items transferred"
|
||||
},
|
||||
"maxFileSize": {
|
||||
"message": "Maximum file size is 500 MB."
|
||||
},
|
||||
|
||||
@@ -708,6 +708,9 @@
|
||||
"addAttachment": {
|
||||
"message": "Add attachment"
|
||||
},
|
||||
"itemsTransferred": {
|
||||
"message": "Items transferred"
|
||||
},
|
||||
"fixEncryption": {
|
||||
"message": "Fix encryption"
|
||||
},
|
||||
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components";
|
||||
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { CipherListView } from "@bitwarden/sdk-internal";
|
||||
import {
|
||||
AddEditFolderDialogComponent,
|
||||
@@ -97,6 +97,8 @@ import {
|
||||
DecryptionFailureDialogComponent,
|
||||
DefaultCipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
VaultItemsTransferService,
|
||||
DefaultVaultItemsTransferService,
|
||||
} from "@bitwarden/vault";
|
||||
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
|
||||
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
|
||||
@@ -177,12 +179,12 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
|
||||
VaultItemsModule,
|
||||
SharedModule,
|
||||
OrganizationWarningsModule,
|
||||
BannerComponent,
|
||||
],
|
||||
providers: [
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
DefaultCipherFormConfigService,
|
||||
{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
|
||||
],
|
||||
})
|
||||
export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
|
||||
@@ -349,6 +351,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
private premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
private autoConfirmService: AutomaticUserConfirmationService,
|
||||
private configService: ConfigService,
|
||||
private vaultItemTransferService: VaultItemsTransferService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -644,6 +647,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
|
||||
|
||||
this.setupAutoConfirm();
|
||||
|
||||
void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -5185,6 +5185,9 @@
|
||||
"oldAttachmentsNeedFixDesc": {
|
||||
"message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key."
|
||||
},
|
||||
"itemsTransferred": {
|
||||
"message": "Items transferred"
|
||||
},
|
||||
"yourAccountsFingerprint": {
|
||||
"message": "Your account's fingerprint phrase",
|
||||
"description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing."
|
||||
|
||||
@@ -11,6 +11,7 @@ export class PolicyData {
|
||||
type: PolicyType;
|
||||
data: Record<string, string | number | boolean>;
|
||||
enabled: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response?: PolicyResponse) {
|
||||
if (response == null) {
|
||||
@@ -22,6 +23,7 @@ export class PolicyData {
|
||||
this.type = response.type;
|
||||
this.data = response.data;
|
||||
this.enabled = response.enabled;
|
||||
this.revisionDate = response.revisionDate;
|
||||
}
|
||||
|
||||
static fromPolicy(policy: Policy): PolicyData {
|
||||
|
||||
@@ -19,6 +19,8 @@ export class Policy extends Domain {
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(obj?: PolicyData) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
@@ -30,6 +32,7 @@ export class Policy extends Domain {
|
||||
this.type = obj.type;
|
||||
this.data = obj.data;
|
||||
this.enabled = obj.enabled;
|
||||
this.revisionDate = new Date(obj.revisionDate);
|
||||
}
|
||||
|
||||
static fromResponse(response: PolicyResponse): Policy {
|
||||
|
||||
@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
|
||||
data: any;
|
||||
enabled: boolean;
|
||||
canToggleState: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
|
||||
this.data = this.getResponseProperty("Data");
|
||||
this.enabled = this.getResponseProperty("Enabled");
|
||||
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,15 @@ describe("PolicyService", () => {
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -113,6 +116,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -242,6 +247,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,24 +338,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -371,24 +386,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: false,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -411,24 +434,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org2",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -451,24 +482,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org3",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -788,6 +827,7 @@ describe("PolicyService", () => {
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
policyData.revisionDate = new Date().toISOString();
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export enum FeatureFlag {
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -123,6 +124,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: override,
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
@@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: override,
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy, policy]);
|
||||
@@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: "password",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
const passphrase = new Policy({
|
||||
id: "" as PolicyId,
|
||||
@@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: "passphrase",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([password, passphrase]);
|
||||
@@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
some: "policy",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
@@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
some: "policy",
|
||||
},
|
||||
enabled: false,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
@@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
some: "policy",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
|
||||
@@ -17,6 +17,7 @@ function createPolicy(
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ function createPolicy(
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ const somePolicy = new Policy({
|
||||
id: "" as PolicyId,
|
||||
organizationId: "" as OrganizationId,
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
@@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => {
|
||||
enabled: true,
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: { overridePasswordType: "password" },
|
||||
revisionDate: new Date().toISOString(),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ function createPolicy(
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
59
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal file
59
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export type UserMigrationInfo =
|
||||
| {
|
||||
/**
|
||||
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
|
||||
* organizational policy change. (Enforce organization data ownership policy enabled)
|
||||
*/
|
||||
requiresMigration: false;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
|
||||
* organizational policy change. (Enforce organization data ownership policy enabled)
|
||||
*/
|
||||
requiresMigration: true;
|
||||
|
||||
/**
|
||||
* The organization that is enforcing data ownership policies for the given user.
|
||||
*/
|
||||
enforcingOrganization: Organization;
|
||||
|
||||
/**
|
||||
* The default collection ID for the user in the enforcing organization, if available.
|
||||
*/
|
||||
defaultCollectionId?: CollectionId;
|
||||
};
|
||||
|
||||
export abstract class VaultItemsTransferService {
|
||||
/**
|
||||
* Gets information about whether the given user requires migration of their vault items
|
||||
* from My Vault to a My Items collection, and whether they are capable of performing that migration.
|
||||
* @param userId
|
||||
*/
|
||||
abstract userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo>;
|
||||
|
||||
/**
|
||||
* Enforces organization data ownership for the given user by transferring vault items.
|
||||
* Checks if any organization policies require the transfer, and if so, prompts the user to confirm before proceeding.
|
||||
*
|
||||
* Rejecting the transfer will result in the user being revoked from the organization.
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
abstract enforceOrganizationDataOwnership(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Begins transfer of vault items from My Vault to the specified default collection for the given user.
|
||||
*/
|
||||
abstract transferPersonalItems(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
defaultCollectionId: CollectionId,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -35,5 +35,7 @@ export { DefaultSshImportPromptService } from "./services/default-ssh-import-pro
|
||||
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./abstractions/vault-items-transfer.service";
|
||||
export * from "./services/default-vault-items-transfer.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
export * from "./services/archive-cipher-utilities.service";
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service";
|
||||
|
||||
describe("DefaultVaultItemsTransferService", () => {
|
||||
let service: DefaultVaultItemsTransferService;
|
||||
|
||||
let mockCipherService: MockProxy<CipherService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockCollectionService: MockProxy<CollectionService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
|
||||
const userId = "user-id" as UserId;
|
||||
const organizationId = "org-id" as OrganizationId;
|
||||
const collectionId = "collection-id" as CollectionId;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCipherService = mock<CipherService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockCollectionService = mock<CollectionService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
|
||||
service = new DefaultVaultItemsTransferService(
|
||||
mockCipherService,
|
||||
mockPolicyService,
|
||||
mockOrganizationService,
|
||||
mockCollectionService,
|
||||
mockLogService,
|
||||
mockI18nService,
|
||||
mockDialogService,
|
||||
mockToastService,
|
||||
mockConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("userMigrationInfo$", () => {
|
||||
// Helper to setup common mock scenario
|
||||
function setupMocksForMigrationScenario(options: {
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
collections?: CollectionView[];
|
||||
}): void {
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
|
||||
}
|
||||
|
||||
it("calls policiesByType$ with correct PolicyType", async () => {
|
||||
setupMocksForMigrationScenario({ policies: [] });
|
||||
|
||||
await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(mockPolicyService.policiesByType$).toHaveBeenCalledWith(
|
||||
PolicyType.OrganizationDataOwnership,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
describe("when no policy exists", () => {
|
||||
beforeEach(() => {
|
||||
setupMocksForMigrationScenario({ policies: [] });
|
||||
});
|
||||
|
||||
it("returns requiresMigration: false", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when policy exists", () => {
|
||||
const policy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const organization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
} as Organization;
|
||||
|
||||
beforeEach(() => {
|
||||
setupMocksForMigrationScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
});
|
||||
});
|
||||
|
||||
describe("and user has no personal ciphers", () => {
|
||||
beforeEach(() => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([]));
|
||||
});
|
||||
|
||||
it("returns requiresMigration: false", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: false,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("and user has personal ciphers", () => {
|
||||
beforeEach(() => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-1" } as CipherView]));
|
||||
});
|
||||
|
||||
it("returns requiresMigration: true", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes defaultCollectionId when a default collection exists", async () => {
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: collectionId,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns default collection only for the enforcing organization", async () => {
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "wrong-collection-id" as CollectionId,
|
||||
organizationId: "wrong-org-id" as OrganizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: collectionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out organization ciphers when checking for personal ciphers", async () => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "cipher-1",
|
||||
organizationId: organizationId as string,
|
||||
} as CipherView,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: false,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when multiple policies exist", () => {
|
||||
const olderPolicy = {
|
||||
organizationId: "older-org-id" as OrganizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const newerPolicy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-06-01"),
|
||||
} as Policy;
|
||||
const olderOrganization = {
|
||||
id: "older-org-id" as OrganizationId,
|
||||
name: "Older Org",
|
||||
} as Organization;
|
||||
const newerOrganization = {
|
||||
id: organizationId,
|
||||
name: "Newer Org",
|
||||
} as Organization;
|
||||
|
||||
beforeEach(() => {
|
||||
setupMocksForMigrationScenario({
|
||||
policies: [newerPolicy, olderPolicy],
|
||||
organizations: [olderOrganization, newerOrganization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the oldest policy when selecting enforcing organization", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: olderOrganization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferPersonalItems", () => {
|
||||
it("does nothing when user has no personal ciphers", async () => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([]));
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
expect(mockLogService.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls shareManyWithServer with correct parameters", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" }, { id: "cipher-2" }] as CipherView[];
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
personalCiphers,
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("transfers only personal ciphers, not organization ciphers", async () => {
|
||||
const allCiphers = [
|
||||
{ id: "cipher-1" },
|
||||
{ id: "cipher-2", organizationId: "other-org-id" },
|
||||
{ id: "cipher-3" },
|
||||
] as CipherView[];
|
||||
|
||||
const expectedPersonalCiphers = [allCiphers[0], allCiphers[2]];
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(allCiphers));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
expectedPersonalCiphers,
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates errors from shareManyWithServer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" }] as CipherView[];
|
||||
|
||||
const error = new Error("Transfer failed");
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
|
||||
mockCipherService.shareManyWithServer.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow("Transfer failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upgradeOldAttachments", () => {
|
||||
it("upgrades old attachments before transferring", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: false,
|
||||
attachments: [{ key: "new-key" }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment]))
|
||||
.mockReturnValueOnce(of([upgradedCipher]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(
|
||||
cipherWithOldAttachment,
|
||||
userId,
|
||||
);
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
[upgradedCipher],
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("upgrades multiple ciphers with old attachments", async () => {
|
||||
const cipher1 = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const cipher2 = {
|
||||
id: "cipher-2",
|
||||
name: "Cipher 2",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher1 = { ...cipher1, hasOldAttachments: false } as CipherView;
|
||||
const upgradedCipher2 = { ...cipher2, hasOldAttachments: false } as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipher1, cipher2]))
|
||||
.mockReturnValueOnce(of([upgradedCipher1, upgradedCipher2]));
|
||||
mockCipherService.upgradeOldCipherAttachments
|
||||
.mockResolvedValueOnce(upgradedCipher1)
|
||||
.mockResolvedValueOnce(upgradedCipher2);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(2);
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher1, userId);
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher2, userId);
|
||||
});
|
||||
|
||||
it("skips attachments that already have keys", async () => {
|
||||
const cipherWithMixedAttachments = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: "existing-key" }, { key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
...cipherWithMixedAttachments,
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithMixedAttachments]))
|
||||
.mockReturnValueOnce(of([upgradedCipher]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
// Should only be called once for the attachment without a key
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws error when upgradeOldCipherAttachments fails", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockRejectedValue(new Error("Upgrade failed"));
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when upgrade returns cipher still having old attachments", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
// Upgrade returns but cipher still has old attachments
|
||||
const stillOldCipher = {
|
||||
...cipherWithOldAttachment,
|
||||
hasOldAttachments: true,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(stillOldCipher);
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when sanity check finds remaining old attachments after upgrade", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
...cipherWithOldAttachment,
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
// First call returns cipher with old attachment, second call (after upgrade) still returns old attachment
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment]))
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment])); // Still has old attachments after re-fetch
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow(
|
||||
"Failed to upgrade all old attachments. 1 ciphers still have old attachments.",
|
||||
);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs info when upgrading old attachments", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
...cipherWithOldAttachment,
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment]))
|
||||
.mockReturnValueOnce(of([upgradedCipher]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Found 1 ciphers with old attachments needing upgrade"),
|
||||
);
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Successfully upgraded 1 ciphers with old attachments"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not upgrade when ciphers have no old attachments", async () => {
|
||||
const cipherWithoutOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithoutOldAttachment]));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforceOrganizationDataOwnership", () => {
|
||||
const policy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const organization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
} as Organization;
|
||||
|
||||
function setupMocksForEnforcementScenario(options: {
|
||||
featureEnabled?: boolean;
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
collections?: CollectionView[];
|
||||
}): void {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
|
||||
}
|
||||
|
||||
it("does nothing when feature flag is disabled", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
featureEnabled: false,
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.MigrateMyVaultToMyItems,
|
||||
);
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when no migration is required", async () => {
|
||||
setupMocksForEnforcementScenario({ policies: [] });
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when user has no personal ciphers", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [],
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs warning and returns when default collection is missing", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [],
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith(
|
||||
"Default collection is missing for user during organization data ownership enforcement",
|
||||
);
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows confirmation dialog when migration is required", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: "Requires migration",
|
||||
content: "Your vault requires migration of personal items to your organization.",
|
||||
type: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not transfer items when user declines confirmation", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("transfers items and shows success toast when user confirms", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
personalCiphers,
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "itemsTransferred",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when transfer fails", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"Error transferring personal items to organization",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "errorOccurred",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
231
libs/vault/src/services/default-vault-items-transfer.service.ts
Normal file
231
libs/vault/src/services/default-vault-items-transfer.service.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import {
|
||||
VaultItemsTransferService,
|
||||
UserMigrationInfo,
|
||||
} from "../abstractions/vault-items-transfer.service";
|
||||
|
||||
@Injectable()
|
||||
export class DefaultVaultItemsTransferService implements VaultItemsTransferService {
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private policyService: PolicyService,
|
||||
private organizationService: OrganizationService,
|
||||
private collectionService: CollectionService,
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> {
|
||||
return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
|
||||
map(
|
||||
(policies) =>
|
||||
policies.sort((a, b) => a.revisionDate.getTime() - b.revisionDate.getTime())?.[0],
|
||||
),
|
||||
switchMap((policy) => {
|
||||
if (policy == null) {
|
||||
return of(undefined);
|
||||
}
|
||||
return this.organizationService.organizations$(userId).pipe(getById(policy.organizationId));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private personalCiphers$(userId: UserId): Observable<CipherView[]> {
|
||||
return this.cipherService.cipherViews$(userId).pipe(
|
||||
filterOutNullish(),
|
||||
map((ciphers) => ciphers.filter((c) => c.organizationId == null)),
|
||||
);
|
||||
}
|
||||
|
||||
private defaultUserCollection$(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Observable<CollectionId | undefined> {
|
||||
return this.collectionService.decryptedCollections$(userId).pipe(
|
||||
map((collections) => {
|
||||
return collections.find((c) => c.isDefaultCollection && c.organizationId === organizationId)
|
||||
?.id;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo> {
|
||||
return this.enforcingOrganization$(userId).pipe(
|
||||
switchMap((enforcingOrganization) => {
|
||||
if (enforcingOrganization == null) {
|
||||
return of<UserMigrationInfo>({
|
||||
requiresMigration: false,
|
||||
});
|
||||
}
|
||||
return combineLatest([
|
||||
this.personalCiphers$(userId),
|
||||
this.defaultUserCollection$(userId, enforcingOrganization.id),
|
||||
]).pipe(
|
||||
map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => {
|
||||
return {
|
||||
requiresMigration: personalCiphers.length > 0,
|
||||
enforcingOrganization,
|
||||
defaultCollectionId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async enforceOrganizationDataOwnership(userId: UserId): Promise<void> {
|
||||
const featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.MigrateMyVaultToMyItems,
|
||||
);
|
||||
|
||||
if (!featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationInfo = await firstValueFrom(this.userMigrationInfo$(userId));
|
||||
|
||||
if (!migrationInfo.requiresMigration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (migrationInfo.defaultCollectionId == null) {
|
||||
// TODO: Handle creating the default collection if missing (to be handled by AC in future work)
|
||||
this.logService.warning(
|
||||
"Default collection is missing for user during organization data ownership enforcement",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporary confirmation dialog. Full implementation in PM-27663
|
||||
const confirmMigration = await this.dialogService.openSimpleDialog({
|
||||
title: "Requires migration",
|
||||
content: "Your vault requires migration of personal items to your organization.",
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmMigration) {
|
||||
// TODO: Show secondary confirmation dialog in PM-27663, for now we just exit
|
||||
// TODO: Revoke user from organization if they decline migration PM-29465
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transferPersonalItems(
|
||||
userId,
|
||||
migrationInfo.enforcingOrganization.id,
|
||||
migrationInfo.defaultCollectionId,
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemsTransferred"),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logService.error("Error transferring personal items to organization", error);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async transferPersonalItems(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
defaultCollectionId: CollectionId,
|
||||
): Promise<void> {
|
||||
let personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
|
||||
|
||||
if (personalCiphers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldAttachmentCiphers = personalCiphers.filter((c) => c.hasOldAttachments);
|
||||
|
||||
if (oldAttachmentCiphers.length > 0) {
|
||||
await this.upgradeOldAttachments(oldAttachmentCiphers, userId, organizationId);
|
||||
personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
|
||||
|
||||
// Sanity check to ensure all old attachments were upgraded, though upgradeOldAttachments should throw if any fail
|
||||
const remainingOldAttachments = personalCiphers.filter((c) => c.hasOldAttachments);
|
||||
if (remainingOldAttachments.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to upgrade all old attachments. ${remainingOldAttachments.length} ciphers still have old attachments.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`Starting transfer of ${personalCiphers.length} personal ciphers to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
|
||||
await this.cipherService.shareManyWithServer(
|
||||
personalCiphers,
|
||||
organizationId,
|
||||
[defaultCollectionId],
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades old attachments that don't have attachment keys.
|
||||
* Throws an error if any attachment fails to upgrade as it is not possible to share with an organization without a key.
|
||||
*/
|
||||
private async upgradeOldAttachments(
|
||||
ciphers: CipherView[],
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<void> {
|
||||
this.logService.info(
|
||||
`Found ${ciphers.length} ciphers with old attachments needing upgrade during transfer to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
|
||||
for (const cipher of ciphers) {
|
||||
try {
|
||||
if (!cipher.hasOldAttachments) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgraded = await this.cipherService.upgradeOldCipherAttachments(cipher, userId);
|
||||
|
||||
if (upgraded.hasOldAttachments) {
|
||||
this.logService.error(
|
||||
`Attachment upgrade did not complete successfully for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(
|
||||
`Failed to upgrade old attachments for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}: ${e}`,
|
||||
);
|
||||
throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`Successfully upgraded ${ciphers.length} ciphers with old attachments during transfer to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user