mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
Ps/pm 5965/better config polling (#8325)
* Create tracker that can await until expected observables are received. * Test dates are almost equal * Remove unused class method * Allow for updating active account in accout service fake * Correct observable tracker behavior Clarify documentation * Transition config service to state provider Updates the config fetching behavior to be lazy and ensure that any emitted value has been updated if older than a configurable value (statically compiled). If desired, config fetching can be ensured fresh through an async. * Update calls to config service in DI and bootstrapping * Migrate account server configs * Fix global config fetching * Test migration rollback * Adhere to implementation naming convention * Adhere to abstract class naming convention * Complete config abstraction rename * Remove unnecessary cli config service * Fix builds * Validate observable does not complete * Use token service to determine authed or unauthed config pull * Remove superfluous factory config * Name describe blocks after the thing they test * Remove implementation documentation Unfortunately the experience when linking to external documentation is quite poor. Instead of following the link and retrieving docs, you get a link that can be clicked to take you out of context to the docs. No link _does_ retrieve docs, but lacks indication in the implementation that documentation exists at all. On the balance, removing the link is the better experience. * Fix storybook
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ServerConfigResponse } from "../../models/response/server-config.response";
|
||||
|
||||
export abstract class ConfigApiServiceAbstraction {
|
||||
get: () => Promise<ServerConfigResponse>;
|
||||
/**
|
||||
* Fetches the server configuration for the given user. If no user is provided, the configuration will not contain user-specific context.
|
||||
*/
|
||||
get: (userId: UserId | undefined) => Promise<ServerConfigResponse>;
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { Region } from "../environment.service";
|
||||
|
||||
import { ServerConfig } from "./server-config";
|
||||
|
||||
export abstract class ConfigServiceAbstraction {
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
cloudRegion$: Observable<Region>;
|
||||
getFeatureFlag$: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Observable<T>;
|
||||
getFeatureFlag: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Promise<T>;
|
||||
checkServerMeetsVersionRequirement$: (
|
||||
minimumRequiredServerVersion: SemVer,
|
||||
) => Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Force ConfigService to fetch an updated config from the server and emit it from serverConfig$
|
||||
* @deprecated The service implementation should subscribe to an observable and use that to trigger a new fetch from
|
||||
* server instead
|
||||
*/
|
||||
triggerServerConfigFetch: () => void;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { Region } from "../environment.service";
|
||||
|
||||
import { ServerConfig } from "./server-config";
|
||||
|
||||
export abstract class ConfigService {
|
||||
/** The server config of the currently active user */
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
/** The cloud region of the currently active user */
|
||||
cloudRegion$: Observable<Region>;
|
||||
/**
|
||||
* Retrieves the value of a feature flag for the currently active user
|
||||
* @param key The feature flag to retrieve
|
||||
* @param defaultValue The default value to return if the feature flag is not set or the server's config is irretrievable
|
||||
* @returns An observable that emits the value of the feature flag, updates as the server config changes
|
||||
*/
|
||||
getFeatureFlag$: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Observable<T>;
|
||||
/**
|
||||
* Retrieves the value of a feature flag for the currently active user
|
||||
* @param key The feature flag to retrieve
|
||||
* @param defaultValue The default value to return if the feature flag is not set or the server's config is irretrievable
|
||||
* @returns The value of the feature flag
|
||||
*/
|
||||
getFeatureFlag: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T,
|
||||
) => Promise<T>;
|
||||
/**
|
||||
* Verifies whether the server version meets the minimum required version
|
||||
* @param minimumRequiredServerVersion The minimum version required
|
||||
* @returns True if the server version is greater than or equal to the minimum required version
|
||||
*/
|
||||
checkServerMeetsVersionRequirement$: (
|
||||
minimumRequiredServerVersion: SemVer,
|
||||
) => Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Triggers a check that the config for the currently active user is up-to-date. If it is not, it will be fetched from the server and stored.
|
||||
*/
|
||||
abstract ensureConfigFetched(): Promise<void>;
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "../../models/data/server-config.data";
|
||||
|
||||
const dayInMilliseconds = 24 * 3600 * 1000;
|
||||
const eighteenHoursInMilliseconds = 18 * 3600 * 1000;
|
||||
|
||||
export class ServerConfig {
|
||||
version: string;
|
||||
@@ -38,10 +37,6 @@ export class ServerConfig {
|
||||
return this.getAgeInMilliseconds() <= dayInMilliseconds;
|
||||
}
|
||||
|
||||
expiresSoon(): boolean {
|
||||
return this.getAgeInMilliseconds() >= eighteenHoursInMilliseconds;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ServerConfig>): ServerConfig {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { LocalData } from "../../vault/models/data/local.data";
|
||||
import { CipherView } from "../../vault/models/view/cipher.view";
|
||||
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
|
||||
import { KdfType } from "../enums";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
import { Account } from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { StorageOptions } from "../models/domain/storage-options";
|
||||
@@ -278,14 +277,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
||||
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use ConfigService
|
||||
*/
|
||||
getServerConfig: (options?: StorageOptions) => Promise<ServerConfigData>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use ConfigService
|
||||
*/
|
||||
setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* fetches string value of URL user tried to navigate to while unauthenticated.
|
||||
* @param options Defines the storage options for the URL; Defaults to session Storage.
|
||||
|
||||
@@ -18,7 +18,6 @@ import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info";
|
||||
import { KdfType } from "../../enums";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
|
||||
import { EncryptedString, EncString } from "./enc-string";
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
@@ -196,7 +195,6 @@ export class AccountSettings {
|
||||
protectedPin?: string;
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string = "lock";
|
||||
serverConfig?: ServerConfigData;
|
||||
approveLoginRequests?: boolean;
|
||||
avatarColor?: string;
|
||||
trustDeviceChoiceForDecryption?: boolean;
|
||||
@@ -214,7 +212,6 @@ export class AccountSettings {
|
||||
obj?.pinProtected,
|
||||
EncString.fromJSON,
|
||||
),
|
||||
serverConfig: ServerConfigData.fromJSON(obj?.serverConfig),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ServerConfigResponse } from "../../models/response/server-config.response";
|
||||
|
||||
export class ConfigApiService implements ConfigApiServiceAbstraction {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private authService: AuthService,
|
||||
private tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
async get(): Promise<ServerConfigResponse> {
|
||||
async get(userId: UserId | undefined): Promise<ServerConfigResponse> {
|
||||
// Authentication adds extra context to config responses, if the user has an access token, we want to use it
|
||||
// We don't particularly care about ensuring the token is valid and not expired, just that it exists
|
||||
const authed: boolean =
|
||||
(await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut;
|
||||
userId == null ? false : (await this.tokenService.getAccessToken(userId)) != null;
|
||||
|
||||
const r = await this.apiService.send("GET", "/config", null, authed, true);
|
||||
return new ServerConfigResponse(r);
|
||||
|
||||
@@ -1,200 +1,264 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { ReplaySubject, skip, take } from "rxjs";
|
||||
/**
|
||||
* need to update test environment so structuredClone works appropriately
|
||||
* @jest-environment ../../libs/shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Subject, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { subscribeTo } from "../../../../spec/observable-tracker";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
import {
|
||||
EnvironmentServerConfigResponse,
|
||||
ServerConfigResponse,
|
||||
ThirdPartyServerConfigResponse,
|
||||
} from "../../models/response/server-config.response";
|
||||
import { StateProvider } from "../../state";
|
||||
|
||||
import { ConfigService } from "./config.service";
|
||||
import {
|
||||
ApiUrl,
|
||||
DefaultConfigService,
|
||||
RETRIEVAL_INTERVAL,
|
||||
GLOBAL_SERVER_CONFIGURATIONS,
|
||||
USER_SERVER_CONFIG,
|
||||
} from "./default-config.service";
|
||||
|
||||
describe("ConfigService", () => {
|
||||
let stateService: MockProxy<StateService>;
|
||||
let configApiService: MockProxy<ConfigApiServiceAbstraction>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let replaySubject: ReplaySubject<Environment>;
|
||||
let stateProvider: StateProvider;
|
||||
|
||||
let serverResponseCount: number; // increments to track distinct responses received from server
|
||||
|
||||
// Observables will start emitting as soon as this is created, so only create it
|
||||
// after everything is mocked
|
||||
const configServiceFactory = () => {
|
||||
const configService = new ConfigService(
|
||||
stateService,
|
||||
configApiService,
|
||||
authService,
|
||||
environmentService,
|
||||
logService,
|
||||
stateProvider,
|
||||
);
|
||||
configService.init();
|
||||
return configService;
|
||||
};
|
||||
const configApiService = mock<ConfigApiServiceAbstraction>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const logService = mock<LogService>();
|
||||
let stateProvider: FakeStateProvider;
|
||||
let globalState: FakeGlobalState<Record<ApiUrl, ServerConfig>>;
|
||||
let userState: FakeSingleUserState<ServerConfig>;
|
||||
const activeApiUrl = apiUrl(0);
|
||||
const userId = "userId" as UserId;
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
const tooOld = new Date(Date.now() - 1.1 * RETRIEVAL_INTERVAL);
|
||||
|
||||
beforeEach(() => {
|
||||
stateService = mock();
|
||||
configApiService = mock();
|
||||
authService = mock();
|
||||
environmentService = mock();
|
||||
logService = mock();
|
||||
replaySubject = new ReplaySubject<Environment>(1);
|
||||
const accountService = mockAccountServiceWith("0" as UserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
environmentService.environment$ = replaySubject.asObservable();
|
||||
|
||||
serverResponseCount = 1;
|
||||
configApiService.get.mockImplementation(() =>
|
||||
Promise.resolve(serverConfigResponseFactory("server" + serverResponseCount++)),
|
||||
);
|
||||
|
||||
jest.useFakeTimers();
|
||||
globalState = stateProvider.global.getFake(GLOBAL_SERVER_CONFIGURATIONS);
|
||||
userState = stateProvider.singleUser.getFake(userId, USER_SERVER_CONFIG);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Uses storage as fallback", (done) => {
|
||||
const storedConfigData = serverConfigDataFactory("storedConfig");
|
||||
stateService.getServerConfig.mockResolvedValueOnce(storedConfigData);
|
||||
describe.each([null, userId])("active user: %s", (activeUserId) => {
|
||||
let sut: DefaultConfigService;
|
||||
|
||||
configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch"));
|
||||
|
||||
const configService = configServiceFactory();
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
|
||||
expect(config).toEqual(new ServerConfig(storedConfigData));
|
||||
expect(stateService.getServerConfig).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.setServerConfig).not.toHaveBeenCalled();
|
||||
done();
|
||||
beforeAll(async () => {
|
||||
await accountService.switchAccount(activeUserId);
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
|
||||
it("Stream does not error out if fetch fails", (done) => {
|
||||
const storedConfigData = serverConfigDataFactory("storedConfig");
|
||||
stateService.getServerConfig.mockResolvedValueOnce(storedConfigData);
|
||||
|
||||
const configService = configServiceFactory();
|
||||
|
||||
configService.serverConfig$.pipe(skip(1), take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch"));
|
||||
configService.triggerServerConfigFetch();
|
||||
|
||||
configApiService.get.mockResolvedValueOnce(serverConfigResponseFactory("server1"));
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
|
||||
describe("Fetches config from server", () => {
|
||||
beforeEach(() => {
|
||||
stateService.getServerConfig.mockResolvedValueOnce(null);
|
||||
environmentService.environment$ = of(environmentFactory(activeApiUrl));
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
logService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
it.each<number | jest.DoneCallback>([1, 2, 3])(
|
||||
"after %p hour/s",
|
||||
(hours: number, done: jest.DoneCallback) => {
|
||||
const configService = configServiceFactory();
|
||||
describe("serverConfig$", () => {
|
||||
it.each([{}, null])("handles null stored state", async (globalTestState) => {
|
||||
globalState.stateSubject.next(globalTestState);
|
||||
userState.nextState(null);
|
||||
await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
// skip previous hours (if any)
|
||||
configService.serverConfig$.pipe(skip(hours - 1), take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server" + hours);
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(hours);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
describe.each(["stale", "missing"])("%s config", (configStateDescription) => {
|
||||
const userStored =
|
||||
configStateDescription === "missing"
|
||||
? null
|
||||
: serverConfigFactory(activeApiUrl + userId, tooOld);
|
||||
const globalStored =
|
||||
configStateDescription === "missing"
|
||||
? {}
|
||||
: {
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
globalState.stateSubject.next(globalStored);
|
||||
userState.nextState(userStored);
|
||||
});
|
||||
|
||||
const oneHourInMs = 1000 * 3600;
|
||||
jest.advanceTimersByTime(oneHourInMs * hours + 1);
|
||||
},
|
||||
);
|
||||
// sanity check
|
||||
test("authed and unauthorized state are different", () => {
|
||||
expect(globalStored[activeApiUrl]).not.toEqual(userStored);
|
||||
});
|
||||
|
||||
it("when environment URLs change", (done) => {
|
||||
const configService = configServiceFactory();
|
||||
describe("fail to fetch", () => {
|
||||
beforeEach(() => {
|
||||
configApiService.get.mockRejectedValue(new Error("Unable to fetch"));
|
||||
});
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
it("uses storage as fallback", async () => {
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not error out when fetch fails", async () => {
|
||||
await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow();
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs an error when unable to fetch", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
`Unable to fetch ServerConfig from ${activeApiUrl}: Unable to fetch`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch success", () => {
|
||||
const response = serverConfigResponseFactory();
|
||||
const newConfig = new ServerConfig(new ServerConfigData(response));
|
||||
|
||||
it("should be a new config", async () => {
|
||||
expect(newConfig).not.toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
});
|
||||
|
||||
it("fetches config from server when it's older than an hour", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the updated config", async () => {
|
||||
configApiService.get.mockResolvedValue(response);
|
||||
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
// This is the time the response is converted to a config
|
||||
expect(actual.utcDate).toAlmostEqual(newConfig.utcDate, 1000);
|
||||
delete actual.utcDate;
|
||||
delete newConfig.utcDate;
|
||||
|
||||
expect(actual).toEqual(newConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
replaySubject.next(null);
|
||||
});
|
||||
describe("fresh configuration", () => {
|
||||
const userStored = serverConfigFactory(activeApiUrl + userId);
|
||||
const globalStored = {
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl),
|
||||
};
|
||||
beforeEach(() => {
|
||||
globalState.stateSubject.next(globalStored);
|
||||
userState.nextState(userStored);
|
||||
});
|
||||
it("does not fetch from server", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
|
||||
it("when triggerServerConfigFetch() is called", (done) => {
|
||||
const configService = configServiceFactory();
|
||||
expect(configApiService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
it("uses stored value", async () => {
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
});
|
||||
|
||||
it("does not complete after emit", async () => {
|
||||
const emissions = [];
|
||||
const subscription = sut.serverConfig$.subscribe((v) => emissions.push(v));
|
||||
await awaitAsync();
|
||||
expect(emissions.length).toBe(1);
|
||||
expect(subscription.closed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
});
|
||||
|
||||
it("Saves server config to storage when the user is logged in", (done) => {
|
||||
stateService.getServerConfig.mockResolvedValueOnce(null);
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
|
||||
const configService = configServiceFactory();
|
||||
describe("environment change", () => {
|
||||
let sut: DefaultConfigService;
|
||||
let environmentSubject: Subject<Environment>;
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe(() => {
|
||||
try {
|
||||
expect(stateService.setServerConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitHash: "server1" }),
|
||||
);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
beforeAll(async () => {
|
||||
// updating environment with an active account is undefined behavior
|
||||
await accountService.switchAccount(null);
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
beforeEach(() => {
|
||||
environmentSubject = new Subject<Environment>();
|
||||
environmentService.environment$ = environmentSubject;
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
logService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
describe("serverConfig$", () => {
|
||||
it("emits a new config when the environment changes", async () => {
|
||||
const globalStored = {
|
||||
[apiUrl(0)]: serverConfigFactory(apiUrl(0)),
|
||||
[apiUrl(1)]: serverConfigFactory(apiUrl(1)),
|
||||
};
|
||||
globalState.stateSubject.next(globalStored);
|
||||
|
||||
const spy = subscribeTo(sut.serverConfig$);
|
||||
|
||||
environmentSubject.next(environmentFactory(apiUrl(0)));
|
||||
environmentSubject.next(environmentFactory(apiUrl(1)));
|
||||
|
||||
const expected = [globalStored[apiUrl(0)], globalStored[apiUrl(1)]];
|
||||
|
||||
const actual = await spy.pauseUntilReceived(2);
|
||||
expect(actual.length).toBe(2);
|
||||
|
||||
// validate dates this is done separately because the dates are created when ServerConfig is initialized
|
||||
expect(actual[0].utcDate).toAlmostEqual(expected[0].utcDate, 1000);
|
||||
expect(actual[1].utcDate).toAlmostEqual(expected[1].utcDate, 1000);
|
||||
delete actual[0].utcDate;
|
||||
delete actual[1].utcDate;
|
||||
delete expected[0].utcDate;
|
||||
delete expected[1].utcDate;
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
spy.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function serverConfigDataFactory(gitHash: string) {
|
||||
return new ServerConfigData(serverConfigResponseFactory(gitHash));
|
||||
function apiUrl(count: number) {
|
||||
return `https://api${count}.test.com`;
|
||||
}
|
||||
|
||||
function serverConfigResponseFactory(gitHash: string) {
|
||||
function serverConfigFactory(hash: string, date: Date = new Date()) {
|
||||
const config = new ServerConfig(serverConfigDataFactory(hash));
|
||||
config.utcDate = date;
|
||||
return config;
|
||||
}
|
||||
|
||||
function serverConfigDataFactory(hash?: string) {
|
||||
return new ServerConfigData(serverConfigResponseFactory(hash));
|
||||
}
|
||||
|
||||
function serverConfigResponseFactory(hash?: string) {
|
||||
return new ServerConfigResponse({
|
||||
version: "myConfigVersion",
|
||||
gitHash: gitHash,
|
||||
gitHash: hash ?? Utils.newGuid(), // Use optional git hash to store uniqueness value
|
||||
server: new ThirdPartyServerConfigResponse({
|
||||
name: "myThirdPartyServer",
|
||||
url: "www.example.com",
|
||||
@@ -209,3 +273,9 @@ function serverConfigResponseFactory(gitHash: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function environmentFactory(apiUrl: string) {
|
||||
return {
|
||||
getApiUrl: () => apiUrl,
|
||||
} as Environment;
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import {
|
||||
ReplaySubject,
|
||||
Subject,
|
||||
catchError,
|
||||
concatMap,
|
||||
defer,
|
||||
delayWhen,
|
||||
firstValueFrom,
|
||||
map,
|
||||
merge,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService, Region } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
import { StateProvider } from "../../state";
|
||||
|
||||
const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600;
|
||||
|
||||
export class ConfigService implements ConfigServiceAbstraction {
|
||||
private inited = false;
|
||||
|
||||
protected _serverConfig = new ReplaySubject<ServerConfig | null>(1);
|
||||
serverConfig$ = this._serverConfig.asObservable();
|
||||
|
||||
private _forceFetchConfig = new Subject<void>();
|
||||
protected refreshTimer$ = timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS); // after 1 hour, then every hour
|
||||
|
||||
cloudRegion$ = this.serverConfig$.pipe(
|
||||
map((config) => config?.environment?.cloudRegion ?? Region.US),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private configApiService: ConfigApiServiceAbstraction,
|
||||
private authService: AuthService,
|
||||
private environmentService: EnvironmentService,
|
||||
private logService: LogService,
|
||||
private stateProvider: StateProvider,
|
||||
|
||||
// Used to avoid duplicate subscriptions, e.g. in browser between the background and popup
|
||||
private subscribe = true,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
if (!this.subscribe || this.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestServerConfig$ = defer(() => this.configApiService.get()).pipe(
|
||||
map((response) => new ServerConfigData(response)),
|
||||
delayWhen((data) => this.saveConfig(data)),
|
||||
catchError((e: unknown) => {
|
||||
// fall back to stored ServerConfig (if any)
|
||||
this.logService.error("Unable to fetch ServerConfig: " + (e as Error)?.message);
|
||||
return this.stateService.getServerConfig();
|
||||
}),
|
||||
);
|
||||
|
||||
// If you need to fetch a new config when an event occurs, add an observable that emits on that event here
|
||||
merge(
|
||||
this.refreshTimer$, // an overridable interval
|
||||
this.environmentService.environment$, // when environment URLs change (including when app is started)
|
||||
this._forceFetchConfig, // manual
|
||||
)
|
||||
.pipe(
|
||||
concatMap(() => latestServerConfig$),
|
||||
map((data) => (data == null ? null : new ServerConfig(data))),
|
||||
)
|
||||
.subscribe((config) => this._serverConfig.next(config));
|
||||
|
||||
this.inited = true;
|
||||
}
|
||||
|
||||
getFeatureFlag$<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[key] as T;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getFeatureFlag<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return await firstValueFrom(this.getFeatureFlag$(key, defaultValue));
|
||||
}
|
||||
|
||||
triggerServerConfigFetch() {
|
||||
this._forceFetchConfig.next();
|
||||
}
|
||||
|
||||
private async saveConfig(data: ServerConfigData) {
|
||||
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
|
||||
await this.stateService.setServerConfig(data);
|
||||
await this.environmentService.setCloudRegion(userId, data.environment?.cloudRegion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies whether the server version meets the minimum required version
|
||||
* @param minimumRequiredServerVersion The minimum version required
|
||||
* @returns True if the server version is greater than or equal to the minimum required version
|
||||
*/
|
||||
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig == null) {
|
||||
return false;
|
||||
}
|
||||
const serverVersion = new SemVer(serverConfig.version);
|
||||
return serverVersion.compare(minimumRequiredServerVersion) >= 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
NEVER,
|
||||
Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
map,
|
||||
mergeWith,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService, Region } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
|
||||
|
||||
export const RETRIEVAL_INTERVAL = 3_600_000; // 1 hour
|
||||
|
||||
export type ApiUrl = string;
|
||||
|
||||
export const USER_SERVER_CONFIG = new UserKeyDefinition<ServerConfig>(CONFIG_DISK, "serverConfig", {
|
||||
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
|
||||
// TODO MDG: When to clean these up?
|
||||
export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, ApiUrl>(
|
||||
CONFIG_DISK,
|
||||
"byServer",
|
||||
{
|
||||
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
|
||||
},
|
||||
);
|
||||
|
||||
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it.
|
||||
export class DefaultConfigService implements ConfigService {
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig>();
|
||||
|
||||
serverConfig$: Observable<ServerConfig>;
|
||||
|
||||
cloudRegion$: Observable<Region>;
|
||||
|
||||
constructor(
|
||||
private configApiService: ConfigApiServiceAbstraction,
|
||||
private environmentService: EnvironmentService,
|
||||
private logService: LogService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
const apiUrl$ = this.environmentService.environment$.pipe(
|
||||
map((environment) => environment.getApiUrl()),
|
||||
);
|
||||
|
||||
this.serverConfig$ = combineLatest([this.stateProvider.activeUserId$, apiUrl$]).pipe(
|
||||
switchMap(([userId, apiUrl]) => {
|
||||
const config$ =
|
||||
userId == null ? this.globalConfigFor$(apiUrl) : this.userConfigFor$(userId);
|
||||
return config$.pipe(map((config) => [config, userId, apiUrl] as const));
|
||||
}),
|
||||
tap(async (rec) => {
|
||||
const [existingConfig, userId, apiUrl] = rec;
|
||||
// Grab new config if older retrieval interval
|
||||
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
||||
await this.renewConfig(existingConfig, userId, apiUrl);
|
||||
}
|
||||
}),
|
||||
switchMap(([existingConfig]) => {
|
||||
// If we needed to fetch, stop this emit, we'll get a new one after update
|
||||
// This is split up with the above tap because we need to return an observable from a failed promise,
|
||||
// which isn't very doable since promises are converted to observables in switchMap
|
||||
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
||||
return NEVER;
|
||||
}
|
||||
return of(existingConfig);
|
||||
}),
|
||||
// If fetch fails, we'll emit on this subject to fallback to the existing config
|
||||
mergeWith(this.failedFetchFallbackSubject),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
this.cloudRegion$ = this.serverConfig$.pipe(
|
||||
map((config) => config?.environment?.cloudRegion ?? Region.US),
|
||||
);
|
||||
}
|
||||
getFeatureFlag$<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[key] as T;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getFeatureFlag<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return await firstValueFrom(this.getFeatureFlag$(key, defaultValue));
|
||||
}
|
||||
|
||||
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig == null) {
|
||||
return false;
|
||||
}
|
||||
const serverVersion = new SemVer(serverConfig.version);
|
||||
return serverVersion.compare(minimumRequiredServerVersion) >= 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async ensureConfigFetched() {
|
||||
// Triggering a retrieval for the given user ensures that the config is less than RETRIEVAL_INTERVAL old
|
||||
await firstValueFrom(this.serverConfig$);
|
||||
}
|
||||
|
||||
private olderThanRetrievalInterval(date: Date) {
|
||||
return new Date().getTime() - date.getTime() > RETRIEVAL_INTERVAL;
|
||||
}
|
||||
|
||||
// Updates the on-disk configuration with a newly retrieved configuration
|
||||
private async renewConfig(
|
||||
existingConfig: ServerConfig,
|
||||
userId: UserId,
|
||||
apiUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await this.configApiService.get(userId);
|
||||
const newConfig = new ServerConfig(new ServerConfigData(response));
|
||||
|
||||
// Update the environment region
|
||||
if (
|
||||
newConfig?.environment?.cloudRegion != null &&
|
||||
existingConfig?.environment?.cloudRegion != newConfig.environment.cloudRegion
|
||||
) {
|
||||
// Null userId sets global, otherwise sets to the given user
|
||||
await this.environmentService.setCloudRegion(userId, newConfig?.environment?.cloudRegion);
|
||||
}
|
||||
|
||||
if (userId == null) {
|
||||
// update global state with new pulled config
|
||||
await this.stateProvider.getGlobal(GLOBAL_SERVER_CONFIGURATIONS).update((configs) => {
|
||||
return { ...configs, [apiUrl]: newConfig };
|
||||
});
|
||||
} else {
|
||||
// update state with new pulled config
|
||||
await this.stateProvider.setUserState(USER_SERVER_CONFIG, newConfig, userId);
|
||||
}
|
||||
} catch (e) {
|
||||
// mutate error to be handled by catchError
|
||||
this.logService.error(
|
||||
`Unable to fetch ServerConfig from ${apiUrl}: ${(e as Error)?.message}`,
|
||||
);
|
||||
// Emit the existing config
|
||||
this.failedFetchFallbackSubject.next(existingConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> {
|
||||
return this.stateProvider
|
||||
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl]));
|
||||
}
|
||||
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig> {
|
||||
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
import { HtmlStorageLocation, KdfType, StorageLocation } from "../enums";
|
||||
import { StateFactory } from "../factories/state-factory";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
import { Account, AccountData, AccountSettings } from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
@@ -1377,23 +1376,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async setServerConfig(value: ServerConfigData, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
account.settings.serverConfig = value;
|
||||
return await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getServerConfig(options: StorageOptions): Promise<ServerConfigData> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
)?.settings?.serverConfig;
|
||||
}
|
||||
|
||||
async getDeepLinkRedirectUrl(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
|
||||
@@ -73,6 +73,9 @@ export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk",
|
||||
});
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||
export const CONFIG_DISK = new StateDefinition("config", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
|
||||
|
||||
Reference in New Issue
Block a user