1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

Merge branch 'main' into vault/pm-5273

# Conflicts:
#	libs/common/src/state-migrations/migrate.ts
This commit is contained in:
Carlos Gonçalves
2024-03-12 12:02:49 +00:00
183 changed files with 3499 additions and 2091 deletions

View File

@@ -681,7 +681,7 @@ import { ModalService } from "./modal.service";
{
provide: PolicyServiceAbstraction,
useClass: PolicyService,
deps: [StateServiceAbstraction, StateProvider, OrganizationServiceAbstraction],
deps: [StateProvider, OrganizationServiceAbstraction],
},
{
provide: InternalPolicyService,

View File

@@ -1,7 +1,7 @@
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { BehaviorSubject, Subject, concatMap, firstValueFrom, map, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -151,8 +151,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
});
this.policyService
.policyAppliesToActiveUser$(PolicyType.SendOptions, (p) => p.data.disableHideEmail)
.pipe(takeUntil(this.destroy$))
.getAll$(PolicyType.SendOptions)
.pipe(
map((policies) => policies?.some((p) => p.data.disableHideEmail)),
takeUntil(this.destroy$),
)
.subscribe((policyAppliesToActiveUser) => {
if (
(this.disableHideEmail = policyAppliesToActiveUser) &&
@@ -164,7 +167,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
});
this.formGroup.controls.type.valueChanges.subscribe((val) => {
this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => {
this.type = val;
this.typeChanged();
});
@@ -207,28 +210,46 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;
if (this.send == null) {
if (this.editMode) {
const send = this.loadSend();
this.send = await send.decrypt();
this.type = this.send.type;
this.updateFormValues();
} else {
this.send = new SendView();
this.send.type = this.type;
this.send.file = new SendFileView();
this.send.text = new SendTextView();
this.send.deletionDate = new Date();
this.send.deletionDate.setDate(this.send.deletionDate.getDate() + 7);
this.formGroup.controls.type.patchValue(this.send.type);
const send = new BehaviorSubject<SendView>(this.send);
send.subscribe({
next: (decryptedSend: SendView | undefined) => {
if (!(decryptedSend instanceof SendView)) {
return;
}
this.formGroup.patchValue({
selectedDeletionDatePreset: DatePreset.SevenDays,
selectedExpirationDatePreset: DatePreset.Never,
});
this.send = decryptedSend;
decryptedSend.type = decryptedSend.type ?? this.type;
this.type = this.send.type;
this.updateFormValues();
this.hasPassword = this.send.password != null && this.send.password.trim() !== "";
},
error: (error: unknown) => {
const errorMessage = (error as Error).message ?? "An unknown error occurred";
this.logService.error("Failed to decrypt send: " + errorMessage);
},
});
if (this.editMode) {
this.sendService
.get$(this.sendId)
.pipe(
//Promise.reject will complete the BehaviourSubject, if desktop starts relying only on BehaviourSubject, this should be changed.
concatMap((s) =>
s instanceof Send ? s.decrypt() : Promise.reject(new Error("Failed to load send.")),
),
takeUntil(this.destroy$),
)
.subscribe(send);
} else {
const sendView = new SendView();
sendView.type = this.type;
sendView.file = new SendFileView();
sendView.text = new SendTextView();
sendView.deletionDate = new Date();
sendView.deletionDate.setDate(sendView.deletionDate.getDate() + 7);
send.next(sendView);
}
}
this.hasPassword = this.send.password != null && this.send.password.trim() !== "";
}
async submit(): Promise<boolean> {
@@ -373,8 +394,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.showOptions = !this.showOptions;
}
protected loadSend(): Send {
return this.sendService.get(this.sendId);
protected loadSend(): Promise<Send> {
return firstValueFrom(this.sendService.get$(this.sendId));
}
protected async encryptSend(file: File): Promise<[Send, EncArrayBuffer]> {

View File

@@ -1,6 +1,7 @@
import { Observable } from "rxjs";
import { ListResponse } from "../../../models/response/list.response";
import { UserId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
@@ -10,9 +11,9 @@ import { PolicyResponse } from "../../models/response/policy.response";
export abstract class PolicyService {
/**
* All {@link Policy} objects for the active user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* @see {@link get$} or {@link policyAppliesToActiveUser$} if you want to know when a policy applies to a user.
* All policies for the active user from sync data.
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
* Consider using {@link get$} or {@link getAll$} instead, which will only return policies that should be enforced against the user.
*/
policies$: Observable<Policy[]>;
@@ -20,37 +21,33 @@ export abstract class PolicyService {
* @returns the first {@link Policy} found that applies to the active user.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
* @param policyFilter Optional predicate to apply when filtering policies
* @see {@link getAll$} if you need all policies of a given type
*/
get$: (policyType: PolicyType, policyFilter?: (policy: Policy) => boolean) => Observable<Policy>;
get$: (policyType: PolicyType) => Observable<Policy>;
/**
* @returns all {@link Policy} objects of a given type that apply to the specified user (or the active user if not specified).
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
*/
getAll$: (policyType: PolicyType, userId?: UserId) => Observable<Policy[]>;
/**
* All {@link Policy} objects for the specified user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* @see {@link policyAppliesToUser} if you want to know when a policy applies to the user.
* @deprecated Use {@link policies$} instead
* Consider using {@link getAll$} instead, which will only return policies that should be enforced against the user.
*/
getAll: (type?: PolicyType, userId?: string) => Promise<Policy[]>;
getAll: (policyType: PolicyType) => Promise<Policy[]>;
/**
* @returns true if the {@link PolicyType} applies to the current user, otherwise false.
* @remarks A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @returns true if a policy of the specified type applies to the active user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* This does not take into account the policy's configuration - if that is important, use {@link getAll$} to get the
* {@link Policy} objects and then filter by Policy.data.
*/
policyAppliesToActiveUser$: (
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean,
) => Observable<boolean>;
policyAppliesToActiveUser$: (policyType: PolicyType) => Observable<boolean>;
/**
* @returns true if the {@link PolicyType} applies to the specified user, otherwise false.
* @remarks A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @see {@link policyAppliesToActiveUser$} if you only want to know about the current user.
*/
policyAppliesToUser: (
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean,
userId?: string,
) => Promise<boolean>;
policyAppliesToUser: (policyType: PolicyType) => Promise<boolean>;
// Policy specific interfaces
@@ -93,7 +90,7 @@ export abstract class PolicyService {
}
export abstract class InternalPolicyService extends PolicyService {
upsert: (policy: PolicyData) => Promise<any>;
upsert: (policy: PolicyData) => Promise<void>;
replace: (policies: { [id: string]: PolicyData }) => Promise<void>;
clear: (userId?: string) => Promise<any>;
clear: (userId?: string) => Promise<void>;
}

View File

@@ -1,8 +1,9 @@
import { PolicyId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { PolicyResponse } from "../response/policy.response";
export class PolicyData {
id: string;
id: PolicyId;
organizationId: string;
type: PolicyType;
data: Record<string, string | number | boolean>;

View File

@@ -1,9 +1,10 @@
import Domain from "../../../platform/models/domain/domain-base";
import { PolicyId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { PolicyData } from "../data/policy.data";
export class Policy extends Domain {
id: string;
id: PolicyId;
organizationId: string;
type: PolicyType;
data: any;

View File

@@ -1,8 +1,9 @@
import { BaseResponse } from "../../../models/response/base.response";
import { PolicyId } from "../../../types/guid";
import { PolicyType } from "../../enums";
export class PolicyResponse extends BaseResponse {
id: string;
id: PolicyId;
organizationId: string;
type: PolicyType;
data: any;

View File

@@ -1,5 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
@@ -19,71 +19,51 @@ import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain
import { PolicyResponse } from "../../../admin-console/models/response/policy.response";
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
import { ListResponse } from "../../../models/response/list.response";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { ContainerService } from "../../../platform/services/container.service";
import { StateService } from "../../../platform/services/state.service";
import { PolicyId, UserId } from "../../../types/guid";
describe("PolicyService", () => {
let policyService: PolicyService;
let cryptoService: MockProxy<CryptoService>;
let stateService: MockProxy<StateService>;
let stateProvider: FakeStateProvider;
let organizationService: MockProxy<OrganizationService>;
let encryptService: MockProxy<EncryptService>;
let activeAccount: BehaviorSubject<string>;
let activeAccountUnlocked: BehaviorSubject<boolean>;
let activeUserState: FakeActiveUserState<Record<PolicyId, PolicyData>>;
let policyService: PolicyService;
beforeEach(() => {
stateService = mock<StateService>();
const accountService = mockAccountServiceWith("userId" as UserId);
stateProvider = new FakeStateProvider(accountService);
organizationService = mock<OrganizationService>();
organizationService.getAll
.calledWith("user")
.mockResolvedValue([
new Organization(
organizationData(
"test-organization",
true,
true,
OrganizationUserStatusType.Accepted,
false,
),
),
]);
organizationService.getAll.calledWith(undefined).mockResolvedValue([]);
organizationService.getAll.calledWith(null).mockResolvedValue([]);
activeAccount = new BehaviorSubject("123");
activeAccountUnlocked = new BehaviorSubject(true);
stateService.getDecryptedPolicies.calledWith({ userId: "user" }).mockResolvedValue(null);
stateService.getEncryptedPolicies.calledWith({ userId: "user" }).mockResolvedValue({
"1": policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, {
minutes: 14,
}),
});
stateService.getEncryptedPolicies.mockResolvedValue({
"1": policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, {
minutes: 14,
}),
});
stateService.activeAccount$ = activeAccount;
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
stateService.getUserId.mockResolvedValue("user");
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
policyService = new PolicyService(stateService, stateProvider, organizationService);
});
activeUserState = stateProvider.activeUser.getFake(POLICIES);
organizationService.organizations$ = of([
// User
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
// Owner
organization(
"org2",
true,
true,
OrganizationUserStatusType.Confirmed,
false,
OrganizationUserType.Owner,
),
// Does not use policies
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
]);
afterEach(() => {
activeAccount.complete();
activeAccountUnlocked.complete();
policyService = new PolicyService(stateProvider, organizationService);
});
it("upsert", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
]),
);
await policyService.upsert(policyData("99", "test-organization", PolicyType.DisableSend, true));
expect(await firstValueFrom(policyService.policies$)).toEqual([
@@ -104,6 +84,12 @@ describe("PolicyService", () => {
});
it("replace", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
]),
);
await policyService.replace({
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
});
@@ -118,37 +104,63 @@ describe("PolicyService", () => {
]);
});
it("locking should clear", async () => {
activeAccountUnlocked.next(false);
// Sleep for 100ms to avoid timing issues
await new Promise((r) => setTimeout(r, 100));
expect((await firstValueFrom(policyService.policies$)).length).toBe(0);
});
describe("clear", () => {
it("null userId", async () => {
beforeEach(() => {
activeUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, {
minutes: 14,
}),
]),
);
});
it("clears state for the active user", async () => {
await policyService.clear();
expect(stateService.setEncryptedPolicies).toBeCalledTimes(1);
expect((await firstValueFrom(policyService.policies$)).length).toBe(0);
expect(await firstValueFrom(policyService.policies$)).toEqual([]);
expect(await firstValueFrom(activeUserState.state$)).toEqual(null);
expect(stateProvider.activeUser.getFake(POLICIES).nextMock).toHaveBeenCalledWith([
"userId",
null,
]);
});
it("matching userId", async () => {
await policyService.clear("user");
it("clears state for an inactive user", async () => {
const inactiveUserId = "someOtherUserId" as UserId;
const inactiveUserState = stateProvider.singleUser.getFake(inactiveUserId, POLICIES);
inactiveUserState.nextState(
arrayToRecord([
policyData("10", "another-test-organization", PolicyType.PersonalOwnership, true),
]),
);
expect(stateService.setEncryptedPolicies).toBeCalledTimes(1);
await policyService.clear(inactiveUserId);
expect((await firstValueFrom(policyService.policies$)).length).toBe(0);
});
// Active user is not affected
const expectedActiveUserPolicy: Partial<Policy> = {
id: "1" as PolicyId,
organizationId: "test-organization",
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
};
expect(await firstValueFrom(policyService.policies$)).toEqual([expectedActiveUserPolicy]);
expect(await firstValueFrom(activeUserState.state$)).toEqual({
"1": expectedActiveUserPolicy,
});
expect(stateProvider.activeUser.getFake(POLICIES).nextMock).not.toHaveBeenCalled();
it("mismatching userId", async () => {
await policyService.clear("12");
expect(stateService.setEncryptedPolicies).toBeCalledTimes(1);
expect((await firstValueFrom(policyService.policies$)).length).toBe(1);
// Non-active user is cleared
expect(
await firstValueFrom(
policyService.getAll$(PolicyType.PersonalOwnership, "someOtherUserId" as UserId),
),
).toEqual([]);
expect(await firstValueFrom(inactiveUserState.state$)).toEqual(null);
expect(
stateProvider.singleUser.getFake("someOtherUserId" as UserId, POLICIES).nextMock,
).toHaveBeenCalledWith(null);
});
});
@@ -313,300 +325,260 @@ describe("PolicyService", () => {
});
});
describe("get$", () => {
it("returns the specified PolicyType", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual({
id: "policy2",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
});
});
it("does not return disabled policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies for organizations that do not use policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(policyService.get$(PolicyType.ActivateAutofill));
expect(result).toBeNull();
});
});
describe("getAll$", () => {
it("returns the specified PolicyTypes", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return disabled policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies for organizations that do not use policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
});
describe("policyAppliesToActiveUser$", () => {
it("MasterPassword does not apply", async () => {
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.MasterPassword),
it("returns true when the policyType applies to the user", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
expect(result).toEqual(false);
});
it("MaximumVaultTimeout applies", async () => {
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.MaximumVaultTimeout),
);
expect(result).toEqual(true);
});
it("PolicyFilter filters result", async () => {
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.MaximumVaultTimeout, (p) => false),
);
expect(result).toEqual(false);
});
it("DisablePersonalVaultExport does not apply", async () => {
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual(false);
expect(result).toBe(true);
});
});
describe("policyAppliesToUser", () => {
it("MasterPassword does not apply", async () => {
const result = await policyService.policyAppliesToUser(
PolicyType.MasterPassword,
null,
"user",
it("returns false when policyType is disabled", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
]),
);
expect(result).toEqual(false);
});
it("MaximumVaultTimeout applies", async () => {
const result = await policyService.policyAppliesToUser(
PolicyType.MaximumVaultTimeout,
null,
"user",
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual(true);
expect(result).toBe(false);
});
it("PolicyFilter filters result", async () => {
const result = await policyService.policyAppliesToUser(
PolicyType.MaximumVaultTimeout,
(p) => false,
"user",
it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
expect(result).toEqual(false);
});
it("DisablePersonalVaultExport does not apply", async () => {
const result = await policyService.policyAppliesToUser(
PolicyType.DisablePersonalVaultExport,
null,
"user",
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual(false);
});
});
// TODO: remove this nesting once fully migrated to StateProvider
describe("stateProvider methods", () => {
let policyState$: FakeActiveUserState<Record<PolicyId, PolicyData>>;
beforeEach(() => {
policyState$ = stateProvider.activeUser.getFake(POLICIES);
organizationService.organizations$ = new BehaviorSubject([
// User
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
// Owner
organization(
"org2",
true,
true,
OrganizationUserStatusType.Confirmed,
false,
OrganizationUserType.Owner,
),
// Does not use policies
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
]);
expect(result).toBe(false);
});
describe("get_vNext$", () => {
it("returns the specified PolicyType", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
it("returns false for organizations that do not use policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
]),
);
const result = await firstValueFrom(
policyService.get_vNext$(PolicyType.DisablePersonalVaultExport),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual({
id: "policy2",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
});
});
it("does not return disabled policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies for organizations that do not use policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(policyService.get_vNext$(PolicyType.ActivateAutofill));
expect(result).toBeNull();
});
});
describe("getAll_vNext$", () => {
it("returns the specified PolicyTypes", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return disabled policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies for organizations that do not use policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
expect(result).toBe(false);
});
});
@@ -618,7 +590,7 @@ describe("PolicyService", () => {
data?: any,
) {
const policyData = new PolicyData({} as any);
policyData.id = id;
policyData.id = id as PolicyId;
policyData.organizationId = organizationId;
policyData.type = type;
policyData.enabled = enabled;

View File

@@ -1,8 +1,6 @@
import { BehaviorSubject, combineLatest, concatMap, map, Observable, of } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable, of } from "rxjs";
import { ListResponse } from "../../../models/response/list.response";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { KeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
@@ -23,42 +21,19 @@ export const POLICIES = KeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK
});
export class PolicyService implements InternalPolicyServiceAbstraction {
protected _policies: BehaviorSubject<Policy[]> = new BehaviorSubject([]);
policies$ = this._policies.asObservable();
private activeUserPolicyState = this.stateProvider.getActive(POLICIES);
activeUserPolicies$ = this.activeUserPolicyState.state$.pipe(
private activeUserPolicies$ = this.activeUserPolicyState.state$.pipe(
map((policyData) => policyRecordToArray(policyData)),
);
policies$ = this.activeUserPolicies$;
constructor(
protected stateService: StateService,
private stateProvider: StateProvider,
private organizationService: OrganizationService,
) {
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (Utils.global.bitwardenContainerService == null) {
return;
}
) {}
if (!unlocked) {
this._policies.next([]);
return;
}
const data = await this.stateService.getEncryptedPolicies();
await this.updateObservables(data);
}),
)
.subscribe();
}
// --- StateProvider methods - not yet wired up
get_vNext$(policyType: PolicyType) {
get$(policyType: PolicyType) {
const filteredPolicies$ = this.activeUserPolicies$.pipe(
map((policies) => policies.filter((p) => p.type === policyType)),
);
@@ -71,7 +46,7 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
);
}
getAll_vNext$(policyType: PolicyType, userId?: UserId) {
getAll$(policyType: PolicyType, userId?: UserId) {
const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe(
map((policyData) => policyRecordToArray(policyData)),
map((policies) => policies.filter((p) => p.type === policyType)),
@@ -82,8 +57,18 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
);
}
policyAppliesToActiveUser_vNext$(policyType: PolicyType) {
return this.get_vNext$(policyType).pipe(map((policy) => policy != null));
async getAll(policyType: PolicyType) {
return await firstValueFrom(
this.policies$.pipe(map((policies) => policies.filter((p) => p.type === policyType))),
);
}
policyAppliesToActiveUser$(policyType: PolicyType) {
return this.get$(policyType).pipe(map((policy) => policy != null));
}
async policyAppliesToUser(policyType: PolicyType) {
return await firstValueFrom(this.policyAppliesToActiveUser$(policyType));
}
private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) {
@@ -105,45 +90,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
);
});
}
// --- End StateProvider methods
get$(policyType: PolicyType, policyFilter?: (policy: Policy) => boolean): Observable<Policy> {
return this.policies$.pipe(
concatMap(async (policies) => {
const userId = await this.stateService.getUserId();
const appliesToCurrentUser = await this.checkPoliciesThatApplyToUser(
policies,
policyType,
policyFilter,
userId,
);
if (appliesToCurrentUser) {
return policies.find((policy) => policy.type === policyType && policy.enabled);
}
}),
);
}
async getAll(type?: PolicyType, userId?: string): Promise<Policy[]> {
let response: Policy[] = [];
const decryptedPolicies = await this.stateService.getDecryptedPolicies({ userId: userId });
if (decryptedPolicies != null) {
response = decryptedPolicies;
} else {
const diskPolicies = await this.stateService.getEncryptedPolicies({ userId: userId });
for (const id in diskPolicies) {
if (Object.prototype.hasOwnProperty.call(diskPolicies, id)) {
response.push(new Policy(diskPolicies[id]));
}
}
await this.stateService.setDecryptedPolicies(response, { userId: userId });
}
if (type != null) {
return response.filter((policy) => policy.type === type);
} else {
return response;
}
}
masterPasswordPolicyOptions$(policies?: Policy[]): Observable<MasterPasswordPolicyOptions> {
const observable = policies ? of(policies) : this.policies$;
@@ -205,15 +151,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
);
}
policyAppliesToActiveUser$(policyType: PolicyType, policyFilter?: (policy: Policy) => boolean) {
return this.policies$.pipe(
concatMap(async (policies) => {
const userId = await this.stateService.getUserId();
return await this.checkPoliciesThatApplyToUser(policies, policyType, policyFilter, userId);
}),
);
}
evaluateMasterPassword(
passwordStrength: number,
newPassword: string,
@@ -288,68 +225,20 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
return policiesResponse.data.map((response) => this.mapPolicyFromResponse(response));
}
async policyAppliesToUser(
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean,
userId?: string,
) {
const policies = await this.getAll(policyType, userId);
return this.checkPoliciesThatApplyToUser(policies, policyType, policyFilter, userId);
}
async upsert(policy: PolicyData): Promise<any> {
let policies = await this.stateService.getEncryptedPolicies();
if (policies == null) {
policies = {};
}
policies[policy.id] = policy;
await this.updateObservables(policies);
await this.stateService.setDecryptedPolicies(null);
await this.stateService.setEncryptedPolicies(policies);
async upsert(policy: PolicyData): Promise<void> {
await this.activeUserPolicyState.update((policies) => {
policies ??= {};
policies[policy.id] = policy;
return policies;
});
}
async replace(policies: { [id: string]: PolicyData }): Promise<void> {
await this.updateObservables(policies);
await this.stateService.setDecryptedPolicies(null);
await this.stateService.setEncryptedPolicies(policies);
await this.activeUserPolicyState.update(() => policies);
}
async clear(userId?: string): Promise<void> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._policies.next([]);
}
await this.stateService.setDecryptedPolicies(null, { userId: userId });
await this.stateService.setEncryptedPolicies(null, { userId: userId });
}
private async updateObservables(policiesMap: { [id: string]: PolicyData }) {
const policies = Object.values(policiesMap || {}).map((f) => new Policy(f));
this._policies.next(policies);
}
private async checkPoliciesThatApplyToUser(
policies: Policy[],
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean,
userId?: string,
) {
const organizations = await this.organizationService.getAll(userId);
const filteredPolicies = policies.filter(
(p) => p.type === policyType && p.enabled && (policyFilter == null || policyFilter(p)),
);
const policySet = new Set(filteredPolicies.map((p) => p.organizationId));
return organizations.some(
(o) =>
o.status >= OrganizationUserStatusType.Accepted &&
o.usePolicies &&
policySet.has(o.id) &&
!this.isExemptFromPolicy(policyType, o),
);
async clear(userId?: UserId): Promise<void> {
await this.stateProvider.setUserState(POLICIES, null, userId);
}
/**

View File

@@ -53,6 +53,10 @@ const INLINE_MENU_VISIBILITY = new KeyDefinition(
},
);
const ENABLE_CONTEXT_MENU = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "enableContextMenu", {
deserializer: (value: boolean) => value ?? true,
});
const CLEAR_CLIPBOARD_DELAY = new KeyDefinition(
AUTOFILL_SETTINGS_DISK_LOCAL,
"clearClipboardDelay",
@@ -75,6 +79,8 @@ export abstract class AutofillSettingsServiceAbstraction {
setAutoCopyTotp: (newValue: boolean) => Promise<void>;
inlineMenuVisibility$: Observable<InlineMenuVisibilitySetting>;
setInlineMenuVisibility: (newValue: InlineMenuVisibilitySetting) => Promise<void>;
enableContextMenu$: Observable<boolean>;
setEnableContextMenu: (newValue: boolean) => Promise<void>;
clearClipboardDelay$: Observable<ClearClipboardDelaySetting>;
setClearClipboardDelay: (newValue: ClearClipboardDelaySetting) => Promise<void>;
}
@@ -100,6 +106,9 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
private inlineMenuVisibilityState: GlobalState<InlineMenuVisibilitySetting>;
readonly inlineMenuVisibility$: Observable<InlineMenuVisibilitySetting>;
private enableContextMenuState: GlobalState<boolean>;
readonly enableContextMenu$: Observable<boolean>;
private clearClipboardDelayState: ActiveUserState<ClearClipboardDelaySetting>;
readonly clearClipboardDelay$: Observable<ClearClipboardDelaySetting>;
@@ -142,6 +151,9 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
map((x) => x ?? AutofillOverlayVisibility.Off),
);
this.enableContextMenuState = this.stateProvider.getGlobal(ENABLE_CONTEXT_MENU);
this.enableContextMenu$ = this.enableContextMenuState.state$.pipe(map((x) => x ?? true));
this.clearClipboardDelayState = this.stateProvider.getActive(CLEAR_CLIPBOARD_DELAY);
this.clearClipboardDelay$ = this.clearClipboardDelayState.state$.pipe(
map((x) => x ?? ClearClipboardDelay.Never),
@@ -172,6 +184,10 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
await this.inlineMenuVisibilityState.update(() => newValue);
}
async setEnableContextMenu(newValue: boolean): Promise<void> {
await this.enableContextMenuState.update(() => newValue);
}
async setClearClipboardDelay(newValue: ClearClipboardDelaySetting): Promise<void> {
await this.clearClipboardDelayState.update(() => newValue);
}

View File

@@ -4,4 +4,5 @@ import { TranslationService } from "./translation.service";
export abstract class I18nService extends TranslationService {
locale$: Observable<string>;
abstract setLocale(locale: string): Promise<void>;
}

View File

@@ -1,8 +1,6 @@
import { Observable } from "rxjs";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
import { Policy } from "../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
@@ -178,14 +176,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated For migration purposes only, use setDecryptedUserKeyPin instead
*/
setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this, use PolicyService
*/
getDecryptedPolicies: (options?: StorageOptions) => Promise<Policy[]>;
/**
* @deprecated Do not call this, use PolicyService
*/
setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use SendService
*/
@@ -196,8 +186,6 @@ export abstract class StateService<T extends Account = Account> {
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this, use SettingsService
*/
@@ -276,17 +264,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated For migration purposes only, use setEncryptedUserKeyPin instead
*/
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use PolicyService
*/
getEncryptedPolicies: (options?: StorageOptions) => Promise<{ [id: string]: PolicyData }>;
/**
* @deprecated Do not call this directly, use PolicyService
*/
setEncryptedPolicies: (
value: { [id: string]: PolicyData },
options?: StorageOptions,
) => Promise<void>;
/**
* @deprecated Do not call this directly, use SendService
*/

View File

@@ -12,6 +12,7 @@ import {
BIOMETRIC_UNLOCK_ENABLED,
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
ENCRYPTED_CLIENT_KEY_HALF,
FINGERPRINT_VALIDATED,
PROMPT_AUTOMATICALLY,
PROMPT_CANCELLED,
REQUIRE_PASSWORD_ON_START,
@@ -67,6 +68,19 @@ describe("BiometricStateService", () => {
});
});
describe("fingerprintValidated$", () => {
it("emits when the fingerprint validated state changes", async () => {
const state = stateProvider.global.getFake(FINGERPRINT_VALIDATED);
state.stateSubject.next(undefined);
expect(await firstValueFrom(sut.fingerprintValidated$)).toBe(false);
state.stateSubject.next(true);
expect(await firstValueFrom(sut.fingerprintValidated$)).toEqual(true);
});
});
describe("setEncryptedClientKeyHalf", () => {
it("updates encryptedClientKeyHalf$", async () => {
await sut.setEncryptedClientKeyHalf(encClientKeyHalf);
@@ -207,4 +221,20 @@ describe("BiometricStateService", () => {
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false);
});
});
describe("setFingerprintValidated", () => {
it("updates fingerprintValidated$", async () => {
await sut.setFingerprintValidated(true);
expect(await firstValueFrom(sut.fingerprintValidated$)).toBe(true);
});
it("updates state", async () => {
await sut.setFingerprintValidated(true);
expect(stateProvider.global.getFake(FINGERPRINT_VALIDATED).nextMock).toHaveBeenCalledWith(
true,
);
});
});
});

View File

@@ -2,7 +2,7 @@ import { Observable, firstValueFrom, map } from "rxjs";
import { UserId } from "../../types/guid";
import { EncryptedString, EncString } from "../models/domain/enc-string";
import { ActiveUserState, StateProvider } from "../state";
import { ActiveUserState, GlobalState, StateProvider } from "../state";
import {
BIOMETRIC_UNLOCK_ENABLED,
@@ -11,6 +11,7 @@ import {
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
PROMPT_AUTOMATICALLY,
PROMPT_CANCELLED,
FINGERPRINT_VALIDATED,
} from "./biometric.state";
export abstract class BiometricStateService {
@@ -49,6 +50,10 @@ export abstract class BiometricStateService {
* tracks the currently active user
*/
promptAutomatically$: Observable<boolean>;
/**
* Whether or not IPC fingerprint has been validated by the user this session.
*/
fingerprintValidated$: Observable<boolean>;
/**
* Updates the require password on start state for the currently active user.
@@ -88,6 +93,11 @@ export abstract class BiometricStateService {
* @param prompt Whether or not to prompt for biometrics on application start.
*/
abstract setPromptAutomatically(prompt: boolean): Promise<void>;
/**
* Updates whether or not IPC has been validated by the user this session
* @param validated the value to save
*/
abstract setFingerprintValidated(validated: boolean): Promise<void>;
abstract logout(userId: UserId): Promise<void>;
}
@@ -99,12 +109,14 @@ export class DefaultBiometricStateService implements BiometricStateService {
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
private promptCancelledState: ActiveUserState<boolean>;
private promptAutomaticallyState: ActiveUserState<boolean>;
private fingerprintValidatedState: GlobalState<boolean>;
biometricUnlockEnabled$: Observable<boolean>;
encryptedClientKeyHalf$: Observable<EncString | undefined>;
requirePasswordOnStart$: Observable<boolean>;
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
promptCancelled$: Observable<boolean>;
promptAutomatically$: Observable<boolean>;
fingerprintValidated$: Observable<boolean>;
constructor(private stateProvider: StateProvider) {
this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED);
@@ -130,6 +142,9 @@ export class DefaultBiometricStateService implements BiometricStateService {
this.promptCancelled$ = this.promptCancelledState.state$.pipe(map(Boolean));
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean));
this.fingerprintValidatedState = this.stateProvider.getGlobal(FINGERPRINT_VALIDATED);
this.fingerprintValidated$ = this.fingerprintValidatedState.state$.pipe(map(Boolean));
}
async setBiometricUnlockEnabled(enabled: boolean): Promise<void> {
@@ -207,6 +222,10 @@ export class DefaultBiometricStateService implements BiometricStateService {
async setPromptAutomatically(prompt: boolean): Promise<void> {
await this.promptAutomaticallyState.update(() => prompt);
}
async setFingerprintValidated(validated: boolean): Promise<void> {
await this.fingerprintValidatedState.update(() => validated);
}
}
function encryptedClientKeyHalfToEncString(

View File

@@ -5,6 +5,7 @@ import {
BIOMETRIC_UNLOCK_ENABLED,
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
ENCRYPTED_CLIENT_KEY_HALF,
FINGERPRINT_VALIDATED,
PROMPT_AUTOMATICALLY,
PROMPT_CANCELLED,
REQUIRE_PASSWORD_ON_START,
@@ -16,7 +17,8 @@ describe.each([
[PROMPT_CANCELLED, true],
[PROMPT_AUTOMATICALLY, true],
[REQUIRE_PASSWORD_ON_START, true],
[BIOMETRIC_UNLOCK_ENABLED, "test"],
[BIOMETRIC_UNLOCK_ENABLED, true],
[FINGERPRINT_VALIDATED, true],
])(
"deserializes state %s",
(

View File

@@ -74,3 +74,14 @@ export const PROMPT_AUTOMATICALLY = new KeyDefinition<boolean>(
deserializer: (obj) => obj,
},
);
/**
* Stores whether or not IPC handshake has been validated this session.
*/
export const FINGERPRINT_VALIDATED = new KeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"fingerprintValidated",
{
deserializer: (obj) => obj,
},
);

View File

@@ -1,8 +1,6 @@
import { Jsonify } from "type-fest";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { PolicyData } from "../../../admin-console/models/data/policy.data";
import { Policy } from "../../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
@@ -87,7 +85,6 @@ export class AccountData {
>();
localData?: any;
sends?: DataEncryptionPair<SendData, SendView> = new DataEncryptionPair<SendData, SendView>();
policies?: DataEncryptionPair<PolicyData, Policy> = new DataEncryptionPair<PolicyData, Policy>();
passwordGenerationHistory?: EncryptionPair<
GeneratedPasswordHistory[],
GeneratedPasswordHistory[]

View File

@@ -26,6 +26,5 @@ export class GlobalState {
enableBrowserIntegrationFingerprint?: boolean;
enableDuckDuckGoBrowserIntegration?: boolean;
neverDomains?: { [id: string]: unknown };
disableContextMenuItem?: boolean;
deepLinkRedirectUrl?: string;
}

View File

@@ -1,28 +1,36 @@
import { Observable, ReplaySubject } from "rxjs";
import { Observable, firstValueFrom, map } from "rxjs";
import { I18nService as I18nServiceAbstraction } from "../abstractions/i18n.service";
import { GlobalState, GlobalStateProvider, KeyDefinition, TRANSLATION_DISK } from "../state";
import { TranslationService } from "./translation.service";
const LOCALE_KEY = new KeyDefinition<string>(TRANSLATION_DISK, "locale", {
deserializer: (value) => value,
});
export class I18nService extends TranslationService implements I18nServiceAbstraction {
protected _locale = new ReplaySubject<string>(1);
private _translationLocale: string;
locale$: Observable<string> = this._locale.asObservable();
translationLocale: string;
protected translationLocaleState: GlobalState<string>;
locale$: Observable<string>;
constructor(
protected systemLanguage: string,
protected localesDirectory: string,
protected getLocalesJson: (formattedLocale: string) => Promise<any>,
globalStateProvider: GlobalStateProvider,
) {
super(systemLanguage, localesDirectory, getLocalesJson);
this.translationLocaleState = globalStateProvider.get(LOCALE_KEY);
this.locale$ = this.translationLocaleState.state$.pipe(map((locale) => locale ?? null));
}
get translationLocale(): string {
return this._translationLocale;
async setLocale(locale: string): Promise<void> {
await this.translationLocaleState.update(() => locale);
}
set translationLocale(locale: string) {
this._translationLocale = locale;
this._locale.next(locale);
override async init() {
const storedLocale = await firstValueFrom(this.translationLocaleState.state$);
await super.init(storedLocale);
}
}

View File

@@ -2,8 +2,6 @@ import { BehaviorSubject, Observable, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
import { Policy } from "../../admin-console/models/domain/policy";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
@@ -770,24 +768,6 @@ export class StateService<
);
}
@withPrototypeForArrayMembers(Policy)
async getDecryptedPolicies(options?: StorageOptions): Promise<Policy[]> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.data?.policies?.decrypted;
}
async setDecryptedPolicies(value: Policy[], options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
account.data.policies.decrypted = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
}
@withPrototypeForArrayMembers(SendView)
async getDecryptedSends(options?: StorageOptions): Promise<SendView[]> {
return (
@@ -823,24 +803,6 @@ export class StateService<
);
}
async getDisableContextMenuItem(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableContextMenuItem ?? false
);
}
async setDisableContextMenuItem(value: boolean, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.disableContextMenuItem = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDisableFavicon(options?: StorageOptions): Promise<boolean> {
return (
(
@@ -1321,27 +1283,6 @@ export class StateService<
);
}
@withPrototypeForObjectValues(PolicyData)
async getEncryptedPolicies(options?: StorageOptions): Promise<{ [id: string]: PolicyData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.policies?.encrypted;
}
async setEncryptedPolicies(
value: { [id: string]: PolicyData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.data.policies.encrypted = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
@withPrototypeForObjectValues(SendData)
async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> {
return (

View File

@@ -9,6 +9,7 @@ import { MessagingService } from "../abstractions/messaging.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { StateService } from "../abstractions/state.service";
import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service";
import { BiometricStateService } from "../biometrics/biometric-state.service";
import { Utils } from "../misc/utils";
export class SystemService implements SystemServiceAbstraction {
@@ -23,6 +24,7 @@ export class SystemService implements SystemServiceAbstraction {
private stateService: StateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private biometricStateService: BiometricStateService,
) {}
async startProcessReload(authService: AuthService): Promise<void> {
@@ -54,8 +56,9 @@ export class SystemService implements SystemServiceAbstraction {
}
private async executeProcessReload() {
const biometricLockedFingerprintValidated =
await this.stateService.getBiometricFingerprintValidated();
const biometricLockedFingerprintValidated = await firstValueFrom(
this.biometricStateService.fingerprintValidated$,
);
if (!biometricLockedFingerprintValidated) {
clearInterval(this.reloadInterval);
this.reloadInterval = null;

View File

@@ -57,6 +57,7 @@ export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const TRANSLATION_DISK = new StateDefinition("translation", "disk");
// Secrets Manager

View File

@@ -110,9 +110,8 @@ describe("VaultTimeoutSettingsService", () => {
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: true }),
);
policyService.policyAppliesToUser.mockResolvedValue(policy === null ? false : true);
policyService.getAll.mockResolvedValue(
policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[]),
policyService.getAll$.mockReturnValue(
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
);
stateService.getVaultTimeoutAction.mockResolvedValue(userPreference);
@@ -140,9 +139,8 @@ describe("VaultTimeoutSettingsService", () => {
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: false }),
);
policyService.policyAppliesToUser.mockResolvedValue(policy === null ? false : true);
policyService.getAll.mockResolvedValue(
policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[]),
policyService.getAll$.mockReturnValue(
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
);
stateService.getVaultTimeoutAction.mockResolvedValue(userPreference);

View File

@@ -84,18 +84,18 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return await biometricUnlockPromise;
}
async getVaultTimeout(userId?: string): Promise<number> {
async getVaultTimeout(userId?: UserId): Promise<number> {
const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
const policies = await firstValueFrom(
this.policyService.getAll$(PolicyType.MaximumVaultTimeout, userId),
);
if (
await this.policyService.policyAppliesToUser(PolicyType.MaximumVaultTimeout, null, userId)
) {
const policy = await this.policyService.getAll(PolicyType.MaximumVaultTimeout, userId);
if (policies?.length) {
// Remove negative values, and ensure it's smaller than maximum allowed value according to policy
let timeout = Math.min(vaultTimeout, policy[0].data.minutes);
let timeout = Math.min(vaultTimeout, policies[0].data.minutes);
if (vaultTimeout == null || timeout < 0) {
timeout = policy[0].data.minutes;
timeout = policies[0].data.minutes;
}
// TODO @jlf0dev: Can we move this somwhere else? Maybe add it to the initialization process?
@@ -111,23 +111,23 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return vaultTimeout;
}
vaultTimeoutAction$(userId?: string) {
vaultTimeoutAction$(userId?: UserId) {
return defer(() => this.getVaultTimeoutAction(userId));
}
async getVaultTimeoutAction(userId?: string): Promise<VaultTimeoutAction> {
async getVaultTimeoutAction(userId?: UserId): Promise<VaultTimeoutAction> {
const availableActions = await this.getAvailableVaultTimeoutActions();
if (availableActions.length === 1) {
return availableActions[0];
}
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId: userId });
const policies = await firstValueFrom(
this.policyService.getAll$(PolicyType.MaximumVaultTimeout, userId),
);
if (
await this.policyService.policyAppliesToUser(PolicyType.MaximumVaultTimeout, null, userId)
) {
const policy = await this.policyService.getAll(PolicyType.MaximumVaultTimeout, userId);
const action = policy[0].data.action;
if (policies?.length) {
const action = policies[0].data.action;
// We really shouldn't need to set the value here, but multiple services relies on this value being correct.
if (action && vaultTimeoutAction !== action) {
await this.stateService.setVaultTimeoutAction(action, { userId: userId });

View File

@@ -25,7 +25,10 @@ import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-st
import { MoveBiometricUnlockToStateProviders } from "./migrations/28-move-biometric-unlock-to-state-providers";
import { UserNotificationSettingsKeyMigrator } from "./migrations/29-move-user-notification-settings-to-state-provider";
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
import { LocalDataMigrator } from "./migrations/30-move-local-data-to-state-provider";
import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider";
import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider";
import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language";
import { LocalDataMigrator } from "./migrations/33-move-local-data-to-state-provider";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
@@ -35,7 +38,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 30;
export const CURRENT_VERSION = 33;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -68,7 +71,10 @@ export function createMigrationBuilder() {
.with(BadgeSettingsMigrator, 26, 27)
.with(MoveBiometricUnlockToStateProviders, 27, 28)
.with(UserNotificationSettingsKeyMigrator, 28, 29)
.with(LocalDataMigrator, 29, CURRENT_VERSION);
.with(PolicyMigrator, 29, 30)
.with(EnableContextMenuMigrator, 30, 31)
.with(PreferredLanguageMigrator, 31, 32)
.with(LocalDataMigrator, 32, CURRENT_VERSION);
}
export async function currentVersion(
@@ -101,8 +107,12 @@ export async function waitForMigrations(
const isReady = async () => {
const version = await currentVersion(storageService, logService);
// The saved version is what we consider the latest
// migrations should be complete
return version === CURRENT_VERSION;
// migrations should be complete, the state version
// shouldn't become larger than `CURRENT_VERSION` in
// any normal usage of the application but it is common
// enough in dev scenarios where we want to consider that
// ready as well and return true in that scenario.
return version >= CURRENT_VERSION;
};
const wait = async (time: number) => {

View File

@@ -0,0 +1,192 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { PolicyMigrator } from "./30-move-policy-state-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
policies: {
encrypted: {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9, // max vault timeout
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3, // single org
enabled: true,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_policies_policies": {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9,
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3,
enabled: true,
},
},
"user_user-2_policies_policies": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("PoliciesMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: PolicyMigrator;
const keyDefinitionLike = {
key: "policies",
stateDefinition: {
name: "policies",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 22);
sut = new PolicyMigrator(29, 30);
});
it("should remove policies from all old accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set policies value in StateProvider framework for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9,
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3,
enabled: true,
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 23);
sut = new PolicyMigrator(29, 30);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add policy values back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalled();
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
policies: {
encrypted: {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9,
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3,
enabled: true,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,76 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
enum PolicyType {
TwoFactorAuthentication = 0, // Requires users to have 2fa enabled
MasterPassword = 1, // Sets minimum requirements for master password complexity
PasswordGenerator = 2, // Sets minimum requirements/default type for generated passwords/passphrases
SingleOrg = 3, // Allows users to only be apart of one organization
RequireSso = 4, // Requires users to authenticate with SSO
PersonalOwnership = 5, // Disables personal vault ownership for adding/cloning items
DisableSend = 6, // Disables the ability to create and edit Bitwarden Sends
SendOptions = 7, // Sets restrictions or defaults for Bitwarden Sends
ResetPassword = 8, // Allows orgs to use reset password : also can enable auto-enrollment during invite flow
MaximumVaultTimeout = 9, // Sets the maximum allowed vault timeout
DisablePersonalVaultExport = 10, // Disable personal vault export
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
}
type PolicyDataType = {
id: string;
organizationId: string;
type: PolicyType;
data: Record<string, string | number | boolean>;
enabled: boolean;
};
type ExpectedAccountType = {
data?: {
policies?: {
encrypted?: Record<string, PolicyDataType>;
};
};
};
const POLICIES_KEY: KeyDefinitionLike = {
key: "policies",
stateDefinition: {
name: "policies",
},
};
export class PolicyMigrator extends Migrator<29, 30> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.policies?.encrypted;
if (value != null) {
await helper.setToUser(userId, POLICIES_KEY, value);
delete account.data.policies;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, POLICIES_KEY);
if (account) {
account.data = Object.assign(account.data ?? {}, {
policies: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, POLICIES_KEY, null);
}
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
}
}

View File

@@ -0,0 +1,91 @@
import { any, MockProxy } from "jest-mock-extended";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { EnableContextMenuMigrator } from "./31-move-enable-context-menu-to-autofill-settings-state-provider";
function exampleJSON() {
return {
global: {
disableContextMenuItem: true,
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
function rollbackJSON() {
return {
global_autofillSettings_enableContextMenu: false,
global: {
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
const enableContextMenuKeyDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "autofillSettings",
},
key: "enableContextMenu",
};
describe("EnableContextMenuMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: EnableContextMenuMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 30);
sut = new EnableContextMenuMigrator(30, 31);
});
it("should remove global disableContextMenuItem setting", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
it("should set enableContextMenu globally", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(enableContextMenuKeyDefinition, false);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 31);
sut = new EnableContextMenuMigrator(30, 31);
});
it("should null out new enableContextMenu global value", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(enableContextMenuKeyDefinition, null);
});
it("should add disableContextMenuItem global value back", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
disableContextMenuItem: true,
otherStuff: "otherStuff1",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,46 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobalState = {
disableContextMenuItem?: boolean;
};
const enableContextMenuKeyDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "autofillSettings",
},
key: "enableContextMenu",
};
export class EnableContextMenuMigrator extends Migrator<30, 31> {
async migrate(helper: MigrationHelper): Promise<void> {
const globalState = await helper.get<ExpectedGlobalState>("global");
// disableContextMenuItem -> enableContextMenu
if (globalState?.disableContextMenuItem != null) {
await helper.setToGlobal(enableContextMenuKeyDefinition, !globalState.disableContextMenuItem);
// delete `disableContextMenuItem` from state global
delete globalState.disableContextMenuItem;
await helper.set<ExpectedGlobalState>("global", globalState);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
const enableContextMenu: boolean = await helper.getFromGlobal(enableContextMenuKeyDefinition);
// enableContextMenu -> disableContextMenuItem
if (enableContextMenu != null) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
disableContextMenuItem: !enableContextMenu,
});
// remove the global state provider framework key for `enableContextMenu`
await helper.setToGlobal(enableContextMenuKeyDefinition, null);
}
}
}

View File

@@ -0,0 +1,77 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { LOCALE_KEY, PreferredLanguageMigrator } from "./32-move-preferred-language";
function exampleJSON() {
return {
global: {
locale: "en",
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
function rollbackJSON() {
return {
global_translation_locale: "en",
global: {
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
describe("PreferredLanguageMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: PreferredLanguageMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 31);
sut = new PreferredLanguageMigrator(31, 32);
});
it("should remove locale setting from global", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
it("should set locale for global state provider", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(LOCALE_KEY, "en");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 32);
sut = new PreferredLanguageMigrator(31, 32);
});
it("should null out new values for global", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(LOCALE_KEY, null);
});
it("should add locale back to the old global object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
locale: "en",
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,39 @@
import { MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobal = {
locale?: string;
};
export const LOCALE_KEY = {
key: "locale",
stateDefinition: {
name: "translation",
},
};
export class PreferredLanguageMigrator extends Migrator<31, 32> {
async migrate(helper: MigrationHelper): Promise<void> {
// global state
const global = await helper.get<ExpectedGlobal>("global");
if (!global?.locale) {
return;
}
await helper.setToGlobal(LOCALE_KEY, global.locale);
delete global.locale;
await helper.set("global", global);
}
async rollback(helper: MigrationHelper): Promise<void> {
const locale = await helper.getFromGlobal<string>(LOCALE_KEY);
if (!locale) {
return;
}
const global = (await helper.get<ExpectedGlobal>("global")) ?? {};
global.locale = locale;
await helper.set("global", global);
await helper.setToGlobal(LOCALE_KEY, null);
}
}

View File

@@ -3,7 +3,7 @@ import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { LocalDataMigrator } from "./30-move-local-data-to-state-provider";
import { LocalDataMigrator } from "./33-move-local-data-to-state-provider";
function exampleJSON() {
return {

View File

@@ -17,7 +17,7 @@ const CIPHERS_DISK: KeyDefinitionLike = {
},
};
export class LocalDataMigrator extends Migrator<29, 30> {
export class LocalDataMigrator extends Migrator<32, 33> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {

View File

@@ -5,6 +5,12 @@ import {
SUBADDRESS_SETTINGS,
PASSPHRASE_SETTINGS,
PASSWORD_SETTINGS,
SIMPLE_LOGIN_FORWARDER,
FORWARD_EMAIL_FORWARDER,
FIREFOX_RELAY_FORWARDER,
FASTMAIL_FORWARDER,
DUCK_DUCK_GO_FORWARDER,
ADDY_IO_FORWARDER,
} from "./key-definitions";
describe("Key definitions", () => {
@@ -48,6 +54,54 @@ describe("Key definitions", () => {
});
});
describe("ADDY_IO_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = ADDY_IO_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("DUCK_DUCK_GO_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = DUCK_DUCK_GO_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("FASTMAIL_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FASTMAIL_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("FIREFOX_RELAY_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FIREFOX_RELAY_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("FORWARD_EMAIL_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = FORWARD_EMAIL_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("SIMPLE_LOGIN_FORWARDER", () => {
it("should pass through deserialization", () => {
const value: any = {};
const result = SIMPLE_LOGIN_FORWARDER.deserializer(value);
expect(result).toBe(value);
});
});
describe("ENCRYPTED_HISTORY", () => {
it("should pass through deserialization", () => {
const value = {};

View File

@@ -5,6 +5,12 @@ import { GeneratedPasswordHistory } from "./password/generated-password-history"
import { PasswordGenerationOptions } from "./password/password-generation-options";
import { CatchallGenerationOptions } from "./username/catchall-generator-options";
import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options";
import {
ApiOptions,
EmailDomainOptions,
EmailPrefixOptions,
SelfHostedApiOptions,
} from "./username/options/forwarder-options";
import { SubaddressGenerationOptions } from "./username/subaddress-generator-options";
/** plaintext password generation options */
@@ -52,6 +58,54 @@ export const SUBADDRESS_SETTINGS = new KeyDefinition<SubaddressGenerationOptions
},
);
export const ADDY_IO_FORWARDER = new KeyDefinition<SelfHostedApiOptions & EmailDomainOptions>(
GENERATOR_DISK,
"addyIoForwarder",
{
deserializer: (value) => value,
},
);
export const DUCK_DUCK_GO_FORWARDER = new KeyDefinition<ApiOptions>(
GENERATOR_DISK,
"duckDuckGoForwarder",
{
deserializer: (value) => value,
},
);
export const FASTMAIL_FORWARDER = new KeyDefinition<ApiOptions & EmailPrefixOptions>(
GENERATOR_DISK,
"fastmailForwarder",
{
deserializer: (value) => value,
},
);
export const FIREFOX_RELAY_FORWARDER = new KeyDefinition<ApiOptions>(
GENERATOR_DISK,
"firefoxRelayForwarder",
{
deserializer: (value) => value,
},
);
export const FORWARD_EMAIL_FORWARDER = new KeyDefinition<ApiOptions & EmailDomainOptions>(
GENERATOR_DISK,
"forwardEmailForwarder",
{
deserializer: (value) => value,
},
);
export const SIMPLE_LOGIN_FORWARDER = new KeyDefinition<SelfHostedApiOptions>(
GENERATOR_DISK,
"simpleLoginForwarder",
{
deserializer: (value) => value,
},
);
/** encrypted password generation history */
export const ENCRYPTED_HISTORY = new KeyDefinition<GeneratedPasswordHistory>(
GENERATOR_DISK,

View File

@@ -0,0 +1,73 @@
import { mock } from "jest-mock-extended";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { DUCK_DUCK_GO_FORWARDER } from "../key-definitions";
import { SecretState } from "../state/secret-state";
import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
import { ApiOptions } from "./options/forwarder-options";
class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
constructor(
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider);
}
get key() {
// arbitrary.
return DUCK_DUCK_GO_FORWARDER;
}
}
const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId;
describe("ForwarderGeneratorStrategy", () => {
const encryptService = mock<EncryptService>();
const keyService = mock<CryptoService>();
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
describe("durableState", () => {
it("constructs a secret state", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const result = strategy.durableState(SomeUser);
expect(result).toBeInstanceOf(SecretState);
});
it("returns the same secret state for a single user", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const firstResult = strategy.durableState(SomeUser);
const secondResult = strategy.durableState(SomeUser);
expect(firstResult).toBe(secondResult);
});
it("returns a different secret state for a different user", () => {
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
const firstResult = strategy.durableState(SomeUser);
const secondResult = strategy.durableState(AnotherUser);
expect(firstResult).not.toBe(secondResult);
});
});
it("evaluator returns the default policy evaluator", () => {
const strategy = new TestForwarder(null, null, null);
const result = strategy.evaluator(null);
expect(result).toBeInstanceOf(DefaultPolicyEvaluator);
});
});

View File

@@ -0,0 +1,73 @@
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { KeyDefinition, StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { NoPolicy } from "../no-policy";
import { PaddedDataPacker } from "../state/padded-data-packer";
import { SecretClassifier } from "../state/secret-classifier";
import { SecretState } from "../state/secret-state";
import { UserKeyEncryptor } from "../state/user-key-encryptor";
import { ApiOptions } from "./options/forwarder-options";
const ONE_MINUTE = 60 * 1000;
const OPTIONS_FRAME_SIZE = 512;
/** An email forwarding service configurable through an API. */
export abstract class ForwarderGeneratorStrategy<
Options extends ApiOptions,
> extends GeneratorStrategy<Options, NoPolicy> {
/** Initializes the generator strategy
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private readonly encryptService: EncryptService,
private readonly keyService: CryptoService,
private stateProvider: StateProvider,
) {
super();
// Uses password generator since there aren't policies
// specific to usernames.
this.policy = PolicyType.PasswordGenerator;
this.cache_ms = ONE_MINUTE;
}
private durableStates = new Map<UserId, SecretState<Options, Record<string, never>>>();
/** {@link GeneratorStrategy.durableState} */
durableState = (userId: UserId) => {
let state = this.durableStates.get(userId);
if (!state) {
const encryptor = this.createEncryptor();
state = SecretState.from(userId, this.key, this.stateProvider, encryptor);
this.durableStates.set(userId, state);
}
return state;
};
private createEncryptor() {
// always exclude request properties
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
// construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
return new UserKeyEncryptor(this.encryptService, this.keyService, classifier, packer);
}
/** Determine where forwarder configuration is stored */
protected abstract readonly key: KeyDefinition<Options>;
/** {@link GeneratorStrategy.evaluator} */
evaluator = (_policy: Policy) => {
return new DefaultPolicyEvaluator<Options>();
};
}

View File

@@ -2,22 +2,30 @@
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { ADDY_IO_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { AddyIoForwarder } from "./addy-io";
import { mockApiService, mockI18nService } from "./mocks.jest";
describe("Addy.io Forwarder", () => {
it("key returns the Addy IO forwarder key", () => {
const forwarder = new AddyIoForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(ADDY_IO_FORWARDER);
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token,
domain: "example.com",
baseUrl: "https://api.example.com",
@@ -34,11 +42,12 @@ describe("Addy.io Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain,
baseUrl: "https://api.example.com",
@@ -56,11 +65,12 @@ describe("Addy.io Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl,
@@ -83,9 +93,10 @@ describe("Addy.io Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await forwarder.generate(website, {
await forwarder.generate({
website,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
@@ -107,9 +118,10 @@ describe("Addy.io Forwarder", () => {
const apiService = mockApiService(status, { data: { email } });
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate(null, {
const result = await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
@@ -124,11 +136,12 @@ describe("Addy.io Forwarder", () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
@@ -148,11 +161,12 @@ describe("Addy.io Forwarder", () => {
const apiService = mockApiService(500, {});
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",
@@ -181,11 +195,12 @@ describe("Addy.io Forwarder", () => {
const apiService = mockApiService(statusCode, {}, statusText);
const i18nService = mockI18nService();
const forwarder = new AddyIoForwarder(apiService, i18nService);
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
baseUrl: "https://api.example.com",

View File

@@ -1,24 +1,41 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { ADDY_IO_FORWARDER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { EmailDomainOptions, Forwarder, SelfHostedApiOptions } from "../options/forwarder-options";
import { EmailDomainOptions, SelfHostedApiOptions } from "../options/forwarder-options";
/** Generates a forwarding address for addy.io (formerly anon addy) */
export class AddyIoForwarder implements Forwarder {
export class AddyIoForwarder extends ForwarderGeneratorStrategy<
SelfHostedApiOptions & EmailDomainOptions
> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
) {}
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider);
}
/** {@link Forwarder.generate} */
async generate(
website: string | null,
options: SelfHostedApiOptions & EmailDomainOptions,
): Promise<string> {
/** {@link ForwarderGeneratorStrategy.key} */
get key() {
return ADDY_IO_FORWARDER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
throw error;
@@ -32,9 +49,11 @@ export class AddyIoForwarder implements Forwarder {
throw error;
}
const descriptionId =
website && website !== "" ? "forwarderGeneratedByWithWebsite" : "forwarderGeneratedBy";
const description = this.i18nService.t(descriptionId, website ?? "");
let descriptionId = "forwarderGeneratedByWithWebsite";
if (!options.website || options.website === "") {
descriptionId = "forwarderGeneratedBy";
}
const description = this.i18nService.t(descriptionId, options.website ?? "");
const url = options.baseUrl + "/api/v1/aliases";
const request = new Request(url, {
@@ -70,5 +89,5 @@ export class AddyIoForwarder implements Forwarder {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.AddyIo.name);
throw error;
}
}
};
}

View File

@@ -2,22 +2,30 @@
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { DuckDuckGoForwarder } from "./duck-duck-go";
import { mockApiService, mockI18nService } from "./mocks.jest";
describe("DuckDuckGo Forwarder", () => {
it("key returns the Duck Duck Go forwarder key", () => {
const forwarder = new DuckDuckGoForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER);
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService);
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token,
}),
).rejects.toEqual("forwaderInvalidToken");
@@ -40,9 +48,10 @@ describe("DuckDuckGo Forwarder", () => {
const apiService = mockApiService(status, { address });
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService);
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate(null, {
const result = await forwarder.generate({
website: null,
token: "token",
});
@@ -55,11 +64,12 @@ describe("DuckDuckGo Forwarder", () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService);
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwaderInvalidToken");
@@ -76,11 +86,12 @@ describe("DuckDuckGo Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService);
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");
@@ -99,11 +110,12 @@ describe("DuckDuckGo Forwarder", () => {
const apiService = mockApiService(statusCode, {});
const i18nService = mockI18nService();
const forwarder = new DuckDuckGoForwarder(apiService, i18nService);
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");

View File

@@ -1,21 +1,39 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { ApiOptions, Forwarder } from "../options/forwarder-options";
import { ApiOptions } from "../options/forwarder-options";
/** Generates a forwarding address for DuckDuckGo */
export class DuckDuckGoForwarder implements Forwarder {
export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
) {}
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider);
}
/** {@link Forwarder.generate} */
async generate(_website: string | null, options: ApiOptions): Promise<string> {
/** {@link ForwarderGeneratorStrategy.key} */
get key() {
return DUCK_DUCK_GO_FORWARDER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions): Promise<string> => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
throw error;
@@ -48,5 +66,5 @@ export class DuckDuckGoForwarder implements Forwarder {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name);
throw error;
}
}
};
}

View File

@@ -3,6 +3,7 @@
* @jest-environment ../../../../shared/test.environment.ts
*/
import { ApiService } from "../../../../abstractions/api.service";
import { FASTMAIL_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { FastmailForwarder } from "./fastmail";
@@ -45,16 +46,23 @@ const AccountIdSuccess: MockResponse = Object.freeze({
// the tests
describe("Fastmail Forwarder", () => {
it("key returns the Fastmail forwarder key", () => {
const forwarder = new FastmailForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(FASTMAIL_FORWARDER);
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(AccountIdSuccess, EmptyResponse);
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService);
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token,
domain: "example.com",
prefix: "prefix",
@@ -71,11 +79,12 @@ describe("Fastmail Forwarder", () => {
const apiService = mockApiService({ status, body: {} }, EmptyResponse);
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService);
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
@@ -105,9 +114,10 @@ describe("Fastmail Forwarder", () => {
});
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService);
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate(null, {
const result = await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
@@ -138,11 +148,12 @@ describe("Fastmail Forwarder", () => {
});
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService);
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
@@ -165,11 +176,12 @@ describe("Fastmail Forwarder", () => {
const apiService = mockApiService(AccountIdSuccess, { status, body: {} });
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService);
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
@@ -206,11 +218,12 @@ describe("Fastmail Forwarder", () => {
});
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService);
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",
@@ -232,11 +245,12 @@ describe("Fastmail Forwarder", () => {
const apiService = mockApiService(AccountIdSuccess, { status: statusCode, body: {} });
const i18nService = mockI18nService();
const forwarder = new FastmailForwarder(apiService, i18nService);
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
prefix: "prefix",

View File

@@ -1,24 +1,39 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { FASTMAIL_FORWARDER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { EmailPrefixOptions, Forwarder, ApiOptions } from "../options/forwarder-options";
import { EmailPrefixOptions, ApiOptions } from "../options/forwarder-options";
/** Generates a forwarding address for Fastmail */
export class FastmailForwarder implements Forwarder {
export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & EmailPrefixOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
) {}
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider);
}
/** {@link Forwarder.generate} */
async generate(
website: string | null,
options: ApiOptions & EmailPrefixOptions,
): Promise<string> {
/** {@link ForwarderGeneratorStrategy.key} */
get key() {
return FASTMAIL_FORWARDER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions & EmailPrefixOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
throw error;
@@ -41,7 +56,7 @@ export class FastmailForwarder implements Forwarder {
"new-masked-email": {
state: "enabled",
description: "",
forDomain: website,
forDomain: options.website,
emailPrefix: options.prefix,
},
},
@@ -104,7 +119,7 @@ export class FastmailForwarder implements Forwarder {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.Fastmail.name);
throw error;
}
};
private async getAccountId(options: ApiOptions): Promise<string> {
const requestInit: RequestInit = {

View File

@@ -2,22 +2,30 @@
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { FIREFOX_RELAY_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { FirefoxRelayForwarder } from "./firefox-relay";
import { mockApiService, mockI18nService } from "./mocks.jest";
describe("Firefox Relay Forwarder", () => {
it("key returns the Firefox Relay forwarder key", () => {
const forwarder = new FirefoxRelayForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(FIREFOX_RELAY_FORWARDER);
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService);
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token,
}),
).rejects.toEqual("forwaderInvalidToken");
@@ -40,9 +48,10 @@ describe("Firefox Relay Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService);
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await forwarder.generate(website, {
await forwarder.generate({
website,
token: "token",
});
@@ -62,9 +71,10 @@ describe("Firefox Relay Forwarder", () => {
const apiService = mockApiService(status, { full_address });
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService);
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate(null, {
const result = await forwarder.generate({
website: null,
token: "token",
});
@@ -77,11 +87,12 @@ describe("Firefox Relay Forwarder", () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService);
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwaderInvalidToken");
@@ -101,11 +112,12 @@ describe("Firefox Relay Forwarder", () => {
const apiService = mockApiService(statusCode, {});
const i18nService = mockI18nService();
const forwarder = new FirefoxRelayForwarder(apiService, i18nService);
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
}),
).rejects.toEqual("forwarderUnknownError");

View File

@@ -1,21 +1,39 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { FIREFOX_RELAY_FORWARDER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { Forwarder, ApiOptions } from "../options/forwarder-options";
import { ApiOptions } from "../options/forwarder-options";
/** Generates a forwarding address for Firefox Relay */
export class FirefoxRelayForwarder implements Forwarder {
export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
) {}
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider);
}
/** {@link Forwarder.generate} */
async generate(website: string | null, options: ApiOptions): Promise<string> {
/** {@link ForwarderGeneratorStrategy.key} */
get key() {
return FIREFOX_RELAY_FORWARDER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
throw error;
@@ -23,9 +41,11 @@ export class FirefoxRelayForwarder implements Forwarder {
const url = "https://relay.firefox.com/api/v1/relayaddresses/";
const descriptionId =
website && website !== "" ? "forwarderGeneratedByWithWebsite" : "forwarderGeneratedBy";
const description = this.i18nService.t(descriptionId, website ?? "");
let descriptionId = "forwarderGeneratedByWithWebsite";
if (!options.website || options.website === "") {
descriptionId = "forwarderGeneratedBy";
}
const description = this.i18nService.t(descriptionId, options.website ?? "");
const request = new Request(url, {
redirect: "manual",
@@ -37,7 +57,7 @@ export class FirefoxRelayForwarder implements Forwarder {
}),
body: JSON.stringify({
enabled: true,
generated_for: website,
generated_for: options.website,
description,
}),
});
@@ -53,5 +73,5 @@ export class FirefoxRelayForwarder implements Forwarder {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.FirefoxRelay.name);
throw error;
}
}
};
}

View File

@@ -2,22 +2,30 @@
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { FORWARD_EMAIL_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { ForwardEmailForwarder } from "./forward-email";
import { mockApiService, mockI18nService } from "./mocks.jest";
describe("ForwardEmail Forwarder", () => {
it("key returns the Forward Email forwarder key", () => {
const forwarder = new ForwardEmailForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(FORWARD_EMAIL_FORWARDER);
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token,
domain: "example.com",
}),
@@ -36,11 +44,12 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain,
}),
@@ -65,9 +74,10 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await forwarder.generate(website, {
await forwarder.generate({
website,
token: "token",
domain: "example.com",
});
@@ -92,9 +102,10 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(status, response);
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate(null, {
const result = await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
});
@@ -108,11 +119,12 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
@@ -132,11 +144,12 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(401, { message: "A message" });
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
@@ -158,11 +171,12 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(500, json);
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
@@ -191,11 +205,12 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(statusCode, { message });
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),
@@ -225,11 +240,12 @@ describe("ForwardEmail Forwarder", () => {
const apiService = mockApiService(statusCode, { error });
const i18nService = mockI18nService();
const forwarder = new ForwardEmailForwarder(apiService, i18nService);
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
domain: "example.com",
}),

View File

@@ -1,25 +1,42 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { Utils } from "../../../../platform/misc/utils";
import { StateProvider } from "../../../../platform/state";
import { FORWARD_EMAIL_FORWARDER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { EmailDomainOptions, Forwarder, ApiOptions } from "../options/forwarder-options";
import { EmailDomainOptions, ApiOptions } from "../options/forwarder-options";
/** Generates a forwarding address for Forward Email */
export class ForwardEmailForwarder implements Forwarder {
export class ForwardEmailForwarder extends ForwarderGeneratorStrategy<
ApiOptions & EmailDomainOptions
> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
) {}
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider);
}
/** {@link Forwarder.generate} */
async generate(
website: string | null,
options: ApiOptions & EmailDomainOptions,
): Promise<string> {
/** {@link ForwarderGeneratorStrategy.key} */
get key() {
return FORWARD_EMAIL_FORWARDER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: ApiOptions & EmailDomainOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name);
throw error;
@@ -31,9 +48,11 @@ export class ForwardEmailForwarder implements Forwarder {
const url = `https://api.forwardemail.net/v1/domains/${options.domain}/aliases`;
const descriptionId =
website && website !== "" ? "forwarderGeneratedByWithWebsite" : "forwarderGeneratedBy";
const description = this.i18nService.t(descriptionId, website ?? "");
let descriptionId = "forwarderGeneratedByWithWebsite";
if (!options.website || options.website === "") {
descriptionId = "forwarderGeneratedBy";
}
const description = this.i18nService.t(descriptionId, options.website ?? "");
const request = new Request(url, {
redirect: "manual",
@@ -44,7 +63,7 @@ export class ForwardEmailForwarder implements Forwarder {
"Content-Type": "application/json",
}),
body: JSON.stringify({
labels: website,
labels: options.website,
description,
}),
});
@@ -75,5 +94,5 @@ export class ForwardEmailForwarder implements Forwarder {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.ForwardEmail.name);
throw error;
}
}
};
}

View File

@@ -2,22 +2,30 @@
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { mockApiService, mockI18nService } from "./mocks.jest";
import { SimpleLoginForwarder } from "./simple-login";
describe("SimpleLogin Forwarder", () => {
it("key returns the Simple Login forwarder key", () => {
const forwarder = new SimpleLoginForwarder(null, null, null, null, null);
expect(forwarder.key).toBe(SIMPLE_LOGIN_FORWARDER);
});
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService);
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token,
baseUrl: "https://api.example.com",
}),
@@ -36,11 +44,12 @@ describe("SimpleLogin Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService);
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
baseUrl,
}),
@@ -62,9 +71,10 @@ describe("SimpleLogin Forwarder", () => {
const apiService = mockApiService(200, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService);
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await forwarder.generate(website, {
await forwarder.generate({
website,
token: "token",
baseUrl: "https://api.example.com",
});
@@ -85,9 +95,10 @@ describe("SimpleLogin Forwarder", () => {
const apiService = mockApiService(status, { alias });
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService);
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
const result = await forwarder.generate(null, {
const result = await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
});
@@ -101,11 +112,12 @@ describe("SimpleLogin Forwarder", () => {
const apiService = mockApiService(401, {});
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService);
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
}),
@@ -126,11 +138,12 @@ describe("SimpleLogin Forwarder", () => {
const apiService = mockApiService(500, body);
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService);
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
}),
@@ -159,11 +172,12 @@ describe("SimpleLogin Forwarder", () => {
const apiService = mockApiService(statusCode, { error });
const i18nService = mockI18nService();
const forwarder = new SimpleLoginForwarder(apiService, i18nService);
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
await expect(
async () =>
await forwarder.generate(null, {
await forwarder.generate({
website: null,
token: "token",
baseUrl: "https://api.example.com",
}),

View File

@@ -1,21 +1,39 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { Forwarder, SelfHostedApiOptions } from "../options/forwarder-options";
import { SelfHostedApiOptions } from "../options/forwarder-options";
/** Generates a forwarding address for Simple Login */
export class SimpleLoginForwarder implements Forwarder {
export class SimpleLoginForwarder extends ForwarderGeneratorStrategy<SelfHostedApiOptions> {
/** Instantiates the forwarder
* @param apiService used for ajax requests to the forwarding service
* @param i18nService used to look up error strings
* @param encryptService protects sensitive forwarder options
* @param keyService looks up the user key when protecting data.
* @param stateProvider creates the durable state for options storage
*/
constructor(
private apiService: ApiService,
private i18nService: I18nService,
) {}
encryptService: EncryptService,
keyService: CryptoService,
stateProvider: StateProvider,
) {
super(encryptService, keyService, stateProvider);
}
/** {@link Forwarder.generate} */
async generate(website: string, options: SelfHostedApiOptions): Promise<string> {
/** {@link ForwarderGeneratorStrategy.key} */
get key() {
return SIMPLE_LOGIN_FORWARDER;
}
/** {@link ForwarderGeneratorStrategy.generate} */
generate = async (options: SelfHostedApiOptions) => {
if (!options.token || options.token === "") {
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
throw error;
@@ -27,11 +45,11 @@ export class SimpleLoginForwarder implements Forwarder {
let url = options.baseUrl + "/api/alias/random/new";
let noteId = "forwarderGeneratedBy";
if (website && website !== "") {
url += "?hostname=" + website;
if (options.website && options.website !== "") {
url += "?hostname=" + options.website;
noteId = "forwarderGeneratedByWithWebsite";
}
const note = this.i18nService.t(noteId, website ?? "");
const note = this.i18nService.t(noteId, options.website ?? "");
const request = new Request(url, {
redirect: "manual",
@@ -60,5 +78,5 @@ export class SimpleLoginForwarder implements Forwarder {
const error = this.i18nService.t("forwarderUnknownError", Forwarders.SimpleLogin.name);
throw error;
}
}
};
}

View File

@@ -85,27 +85,33 @@ export const DefaultOptions: UsernameGeneratorOptions = Object.freeze({
forwarders: Object.freeze({
service: Forwarders.Fastmail.id,
fastMail: Object.freeze({
website: null,
domain: "",
prefix: "",
token: "",
}),
addyIo: Object.freeze({
website: null,
baseUrl: "https://app.addy.io",
domain: "",
token: "",
}),
forwardEmail: Object.freeze({
website: null,
token: "",
domain: "",
}),
simpleLogin: Object.freeze({
website: null,
baseUrl: "https://app.simplelogin.io",
token: "",
}),
duckDuckGo: Object.freeze({
website: null,
token: "",
}),
firefoxRelay: Object.freeze({
website: null,
token: "",
}),
}),

View File

@@ -1,5 +1,3 @@
import { EncString } from "../../../../platform/models/domain/enc-string";
/** Identifiers for email forwarding services.
* @remarks These are used to select forwarder-specific options.
* The must be kept in sync with the forwarder implementations.
@@ -24,26 +22,24 @@ export type ForwarderMetadata = {
validForSelfHosted: boolean;
};
/** An email forwarding service configurable through an API. */
export interface Forwarder {
/** Generate a forwarding email.
* @param website The website to generate a username for.
* @param options The options to use when generating the username.
*/
generate(website: string | null, options: ApiOptions): Promise<string>;
}
/** Options common to all forwarder APIs */
export type ApiOptions = {
/** bearer token that authenticates bitwarden to the forwarder.
* This is required to issue an API request.
*/
token?: string;
} & RequestOptions;
/** encrypted bearer token that authenticates bitwarden to the forwarder.
* This is used to store the token at rest and must be decoded before use.
/** Options that provide contextual information about the application state
* when a forwarder is invoked.
* @remarks these fields should always be omitted when saving options.
*/
export type RequestOptions = {
/** @param website The domain of the website the generated email is used
* within. This should be set to `null` when the request is not specific
* to any website.
*/
encryptedToken?: EncString;
website: string | null;
};
/** Api configuration for forwarders that support self-hosted installations. */

View File

@@ -24,27 +24,33 @@ const TestOptions: UsernameGeneratorOptions = {
forwarders: {
service: Forwarders.Fastmail.id,
fastMail: {
website: null,
domain: "httpbin.com",
prefix: "foo",
token: "some-token",
},
addyIo: {
website: null,
baseUrl: "https://app.addy.io",
domain: "example.com",
token: "some-token",
},
forwardEmail: {
website: null,
token: "some-token",
domain: "example.com",
},
simpleLogin: {
website: null,
baseUrl: "https://app.simplelogin.io",
token: "some-token",
},
duckDuckGo: {
website: null,
token: "some-token",
},
firefoxRelay: {
website: null,
token: "some-token",
},
},

View File

@@ -1,8 +1,8 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { RouterLink, RouterLinkActive } from "@angular/router";
/**
* Base class used in `NavGroupComponent` and `NavItemComponent`
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`.
*/
@Directive()
export abstract class NavBaseComponent {
@@ -22,14 +22,36 @@ export abstract class NavBaseComponent {
@Input() icon: string;
/**
* Route to be passed to internal `routerLink`
* Optional route to be passed to internal `routerLink`. If not provided, the nav component will render as a button.
*
* See: {@link RouterLink.routerLink}
*
* ---
*
* We can't name this "routerLink" because Angular will mount the `RouterLink` directive.
*
* See: {@link https://github.com/angular/angular/issues/24482}
*/
@Input() route: string | any[];
@Input() route?: RouterLink["routerLink"];
/**
* Passed to internal `routerLink`
*
* See {@link RouterLink.relativeTo}
*/
@Input() relativeTo?: ActivatedRoute | null;
@Input() relativeTo?: RouterLink["relativeTo"];
/**
* Passed to internal `routerLink`
*
* See {@link RouterLinkActive.routerLinkActiveOptions}
*/
@Input() routerLinkActiveOptions?: RouterLinkActive["routerLinkActiveOptions"] = {
paths: "subset",
queryParams: "ignored",
fragment: "ignored",
matrixParams: "ignored",
};
/**
* If this item is used within a tree, set `variant` to `"tree"`

View File

@@ -4,12 +4,12 @@
[icon]="icon"
[route]="route"
[relativeTo]="relativeTo"
[routerLinkActiveOptions]="routerLinkActiveOptions"
[variant]="variant"
(mainContentClicked)="toggle()"
[treeDepth]="treeDepth"
(mainContentClicked)="mainContentClicked.emit()"
[ariaLabel]="ariaLabel"
[exactMatch]="exactMatch"
[hideActiveStyles]="parentHideActiveStyles"
>
<ng-template #button>

View File

@@ -39,11 +39,6 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
@Input()
open = false;
/**
* if `true`, use `exact` match for path instead of `subset`.
*/
@Input() exactMatch: boolean;
@Output()
openChange = new EventEmitter<boolean>();

View File

@@ -54,7 +54,7 @@
[relativeTo]="relativeTo"
[attr.aria-label]="ariaLabel || text"
routerLinkActive
[routerLinkActiveOptions]="rlaOptions"
[routerLinkActiveOptions]="routerLinkActiveOptions"
[ariaCurrentWhenActive]="'page'"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"

View File

@@ -1,5 +1,4 @@
import { Component, HostListener, Input, Optional } from "@angular/core";
import { IsActiveMatchOptions } from "@angular/router";
import { Component, HostListener, Optional } from "@angular/core";
import { BehaviorSubject, map } from "rxjs";
import { NavBaseComponent } from "./nav-base.component";
@@ -24,19 +23,6 @@ export class NavItemComponent extends NavBaseComponent {
protected get showActiveStyles() {
return this._isActive && !this.hideActiveStyles;
}
protected rlaOptions: IsActiveMatchOptions = {
paths: "subset",
queryParams: "exact",
fragment: "ignored",
matrixParams: "ignored",
};
/**
* if `true`, use `exact` match for path instead of `subset`.
*/
@Input() set exactMatch(val: boolean) {
this.rlaOptions.paths = val ? "exact" : "subset";
}
/**
* The design spec calls for the an outline to wrap the entire element when the template's anchor/button has :focus-visible.

View File

@@ -34,4 +34,8 @@ export class I18nMockService implements I18nService {
translate(id: string, p1?: string, p2?: string, p3?: string) {
return this.t(id, p1, p2, p3);
}
async setLocale(locale: string): Promise<void> {
throw new Error("Method not implemented.");
}
}