mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
PS-1133 Feature/mv3 browser observable memory caching (#3245)
* Create sessions sync structure * Add observing to session-syncer * Do not run syncer logic in decorator tests * Extract test constants * Change Observables to BehaviorSubject * Move sendMessage to static method in BrowserApi * Implement session sync * only watch in manifest v3 * Use session sync on folder service * Add array observable sync * Bypass cache on update from message * Create feature and dev flags for browser * Protect development-only methods with decorator * Improve todo comments for long-term residency * Use class properties in init * Do not reuse mocks * Use json (de)serialization patterns * Fix failing session storage in dev environment * Split up complex EncString constructor * Default false for decrypted session storage * Try removing hydrate EncString method * PR review * PR test review
This commit is contained in:
parent
9d0dd613fb
commit
5339344630
@ -8,6 +8,7 @@
|
||||
**/jest.config.js
|
||||
**/gulpfile.js
|
||||
|
||||
apps/browser/config/config.js
|
||||
apps/browser/src/content/autofill.js
|
||||
apps/browser/src/scripts/duo.js
|
||||
|
||||
@ -18,3 +19,5 @@ apps/web/config.js
|
||||
apps/web/scripts/*.js
|
||||
apps/web/src/theme.js
|
||||
apps/web/tailwind.config.js
|
||||
|
||||
apps/cli/config/config.js
|
||||
|
4
apps/browser/config/base.json
Normal file
4
apps/browser/config/base.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"dev_flags": {},
|
||||
"flags": {}
|
||||
}
|
30
apps/browser/config/config.js
Normal file
30
apps/browser/config/config.js
Normal file
@ -0,0 +1,30 @@
|
||||
function load(envName) {
|
||||
return {
|
||||
...loadConfig(envName),
|
||||
...loadConfig("local"),
|
||||
};
|
||||
}
|
||||
|
||||
function log(configObj) {
|
||||
const repeatNum = 50;
|
||||
console.log(`${"=".repeat(repeatNum)}\nenvConfig`);
|
||||
console.log(JSON.stringify(configObj, null, 2));
|
||||
console.log(`${"=".repeat(repeatNum)}`);
|
||||
}
|
||||
|
||||
function loadConfig(configName) {
|
||||
try {
|
||||
return require(`./${configName}.json`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.code === "MODULE_NOT_FOUND") {
|
||||
return {};
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
load,
|
||||
log,
|
||||
};
|
6
apps/browser/config/development.json
Normal file
6
apps/browser/config/development.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"devFlags": {
|
||||
"storeSessionDecrypted": false
|
||||
},
|
||||
"flags": {}
|
||||
}
|
3
apps/browser/config/production.json
Normal file
3
apps/browser/config/production.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"flags": {}
|
||||
}
|
@ -54,7 +54,6 @@ import { EventService } from "@bitwarden/common/services/event.service";
|
||||
import { ExportService } from "@bitwarden/common/services/export.service";
|
||||
import { FileUploadService } from "@bitwarden/common/services/fileUpload.service";
|
||||
import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service";
|
||||
import { FolderService } from "@bitwarden/common/services/folder/folder.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
|
||||
import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service";
|
||||
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
|
||||
@ -90,6 +89,7 @@ import BrowserLocalStorageService from "../services/browserLocalStorage.service"
|
||||
import BrowserMessagingService from "../services/browserMessaging.service";
|
||||
import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service";
|
||||
import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service";
|
||||
import { FolderService } from "../services/folders/folder.service";
|
||||
import I18nService from "../services/i18n.service";
|
||||
import { KeyGenerationService } from "../services/keyGeneration.service";
|
||||
import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service";
|
||||
|
@ -115,6 +115,11 @@ export class BrowserApi {
|
||||
);
|
||||
}
|
||||
|
||||
static sendMessage(subscriber: string, arg: any = {}) {
|
||||
const message = Object.assign({}, { command: subscriber }, arg);
|
||||
return chrome.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
static async closeLoginTab() {
|
||||
const tabs = await BrowserApi.tabsQuery({
|
||||
active: true,
|
||||
|
35
apps/browser/src/decorators/dev-flag.decorator.spec.ts
Normal file
35
apps/browser/src/decorators/dev-flag.decorator.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { devFlagEnabled } from "../flags";
|
||||
|
||||
import { devFlag } from "./dev-flag.decorator";
|
||||
|
||||
let devFlagEnabledMock: jest.Mock;
|
||||
jest.mock("../flags", () => ({
|
||||
...jest.requireActual("../flags"),
|
||||
devFlagEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
class TestClass {
|
||||
@devFlag("storeSessionDecrypted") test() {
|
||||
return "test";
|
||||
}
|
||||
}
|
||||
|
||||
describe("devFlag decorator", () => {
|
||||
beforeEach(() => {
|
||||
devFlagEnabledMock = devFlagEnabled as jest.Mock;
|
||||
});
|
||||
|
||||
it("should throw an error if the dev flag is disabled", () => {
|
||||
devFlagEnabledMock.mockReturnValue(false);
|
||||
expect(() => {
|
||||
new TestClass().test();
|
||||
}).toThrowError("This method should not be called, it is protected by a disabled dev flag.");
|
||||
});
|
||||
|
||||
it("should not throw an error if the dev flag is enabled", () => {
|
||||
devFlagEnabledMock.mockReturnValue(true);
|
||||
expect(() => {
|
||||
new TestClass().test();
|
||||
}).not.toThrowError();
|
||||
});
|
||||
});
|
15
apps/browser/src/decorators/dev-flag.decorator.ts
Normal file
15
apps/browser/src/decorators/dev-flag.decorator.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { devFlagEnabled, DevFlagName } from "../flags";
|
||||
|
||||
export function devFlag(flag: DevFlagName) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
descriptor.value = function (...args: any[]) {
|
||||
if (!devFlagEnabled(flag)) {
|
||||
throw new Error(
|
||||
`This method should not be called, it is protected by a disabled dev flag.`
|
||||
);
|
||||
}
|
||||
return originalMethod.apply(this, args);
|
||||
};
|
||||
};
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { StateService } from "../../services/state.service";
|
||||
|
||||
import { browserSession } from "./browser-session.decorator";
|
||||
import { SessionStorable } from "./session-storable";
|
||||
import { sessionSync } from "./session-sync.decorator";
|
||||
|
||||
// browserSession initializes SessionSyncers for each sessionSync decorated property
|
||||
// We don't want to test SessionSyncers, so we'll mock them
|
||||
jest.mock("./session-syncer");
|
||||
|
||||
describe("browserSession decorator", () => {
|
||||
it("should throw if StateService is not a constructor argument", () => {
|
||||
@browserSession
|
||||
class TestClass {}
|
||||
expect(() => {
|
||||
new TestClass();
|
||||
}).toThrowError(
|
||||
"Cannot decorate TestClass with browserSession, Browser's StateService must be injected"
|
||||
);
|
||||
});
|
||||
|
||||
it("should create if StateService is a constructor argument", () => {
|
||||
const stateService = Object.create(StateService.prototype, {});
|
||||
|
||||
@browserSession
|
||||
class TestClass {
|
||||
constructor(private stateService: StateService) {}
|
||||
}
|
||||
|
||||
expect(new TestClass(stateService)).toBeDefined();
|
||||
});
|
||||
|
||||
describe("interaction with @sessionSync decorator", () => {
|
||||
let stateService: StateService;
|
||||
|
||||
@browserSession
|
||||
class TestClass {
|
||||
@sessionSync({ initializer: (s: string) => s })
|
||||
behaviorSubject = new BehaviorSubject("");
|
||||
|
||||
constructor(private stateService: StateService) {}
|
||||
|
||||
fromJSON(json: any) {
|
||||
this.behaviorSubject.next(json);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stateService = Object.create(StateService.prototype, {}) as StateService;
|
||||
});
|
||||
|
||||
it("should create a session syncer", () => {
|
||||
const testClass = new TestClass(stateService) as any as SessionStorable;
|
||||
expect(testClass.__sessionSyncers.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should initialize the session syncer", () => {
|
||||
const testClass = new TestClass(stateService) as any as SessionStorable;
|
||||
expect(testClass.__sessionSyncers[0].init).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,47 @@
|
||||
import { Constructor } from "type-fest";
|
||||
|
||||
import { StateService } from "../../services/state.service";
|
||||
|
||||
import { SessionStorable } from "./session-storable";
|
||||
import { SessionSyncer } from "./session-syncer";
|
||||
import { SyncedItemMetadata } from "./sync-item-metadata";
|
||||
|
||||
/**
|
||||
* Mark the class as syncing state across the browser session. This decorator finds rxjs BehaviorSubject properties
|
||||
* marked with @sessionSync and syncs these values across the browser session.
|
||||
*
|
||||
* @param constructor
|
||||
* @returns A new constructor that extends the original one to add session syncing.
|
||||
*/
|
||||
export function browserSession<TCtor extends Constructor<any>>(constructor: TCtor) {
|
||||
return class extends constructor implements SessionStorable {
|
||||
__syncedItemMetadata: SyncedItemMetadata[];
|
||||
__sessionSyncers: SessionSyncer[];
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
// Require state service to be injected
|
||||
const stateService = args.find((arg) => arg instanceof StateService);
|
||||
if (!stateService) {
|
||||
throw new Error(
|
||||
`Cannot decorate ${constructor.name} with browserSession, Browser's StateService must be injected`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.__syncedItemMetadata == null || !(this.__syncedItemMetadata instanceof Array)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.__sessionSyncers = this.__syncedItemMetadata.map((metadata) =>
|
||||
this.buildSyncer(metadata, stateService)
|
||||
);
|
||||
}
|
||||
|
||||
buildSyncer(metadata: SyncedItemMetadata, stateService: StateService) {
|
||||
const syncer = new SessionSyncer((this as any)[metadata.key], stateService, metadata);
|
||||
syncer.init();
|
||||
return syncer;
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export { browserSession } from "./browser-session.decorator";
|
||||
export { sessionSync } from "./session-sync.decorator";
|
@ -0,0 +1,7 @@
|
||||
import { SessionSyncer } from "./session-syncer";
|
||||
import { SyncedItemMetadata } from "./sync-item-metadata";
|
||||
|
||||
export interface SessionStorable {
|
||||
__syncedItemMetadata: SyncedItemMetadata[];
|
||||
__sessionSyncers: SessionSyncer[];
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { sessionSync } from "./session-sync.decorator";
|
||||
|
||||
describe("sessionSync decorator", () => {
|
||||
const initializer = (s: string) => "test";
|
||||
const ctor = String;
|
||||
class TestClass {
|
||||
@sessionSync({ ctor: ctor, initializer: initializer })
|
||||
testProperty = new BehaviorSubject("");
|
||||
}
|
||||
|
||||
it("should add __syncedItemKeys to prototype", () => {
|
||||
const testClass = new TestClass();
|
||||
expect((testClass as any).__syncedItemMetadata).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "TestClass_testProperty",
|
||||
ctor: ctor,
|
||||
initializer: initializer,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
@ -0,0 +1,51 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SessionStorable } from "./session-storable";
|
||||
|
||||
class BuildOptions<T> {
|
||||
ctor?: new () => T;
|
||||
initializer?: (keyValuePair: Jsonify<T>) => T;
|
||||
initializeAsArray? = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorator used to indicate the BehaviorSubject should be synced for this browser session across all contexts.
|
||||
*
|
||||
* >**Note** This decorator does nothing if the enclosing class is not decorated with @browserSession.
|
||||
*
|
||||
* >**Note** The Behavior subject must be initialized with a default or in the constructor of the class. If it is not, an error will be thrown.
|
||||
*
|
||||
* >**!!Warning!!** If the property is overwritten at any time, the new value will not be synced across the browser session.
|
||||
*
|
||||
* @param buildOptions
|
||||
* Builders for the value, requires either a constructor (ctor) for your BehaviorSubject type or an
|
||||
* initializer function that takes a key value pair representation of the BehaviorSubject data
|
||||
* and returns your instantiated BehaviorSubject value. `initializeAsArray can optionally be used to indicate
|
||||
* the provided initializer function should be used to build an array of values. For example,
|
||||
* ```ts
|
||||
* \@sessionSync({ initializer: Foo.fromJSON, initializeAsArray: true })
|
||||
* ```
|
||||
* is equivalent to
|
||||
* ```
|
||||
* \@sessionSync({ initializer: (obj: any[]) => obj.map((f) => Foo.fromJSON })
|
||||
* ```
|
||||
*
|
||||
* @returns decorator function
|
||||
*/
|
||||
export function sessionSync<T>(buildOptions: BuildOptions<T>) {
|
||||
return (prototype: unknown, propertyKey: string) => {
|
||||
// Force prototype into SessionStorable and implement it.
|
||||
const p = prototype as SessionStorable;
|
||||
|
||||
if (p.__syncedItemMetadata == null) {
|
||||
p.__syncedItemMetadata = [];
|
||||
}
|
||||
|
||||
p.__syncedItemMetadata.push({
|
||||
key: `${prototype.constructor.name}_${propertyKey}`,
|
||||
ctor: buildOptions.ctor,
|
||||
initializer: buildOptions.initializer,
|
||||
initializeAsArray: buildOptions.initializeAsArray,
|
||||
});
|
||||
};
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { BrowserApi } from "../../browser/browserApi";
|
||||
import { StateService } from "../../services/abstractions/state.service";
|
||||
|
||||
import { SessionSyncer } from "./session-syncer";
|
||||
|
||||
describe("session syncer", () => {
|
||||
const key = "Test__behaviorSubject";
|
||||
const metaData = { key, initializer: (s: string) => s };
|
||||
let stateService: MockProxy<StateService>;
|
||||
let sut: SessionSyncer;
|
||||
let behaviorSubject: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
behaviorSubject = new BehaviorSubject<string>("");
|
||||
jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({
|
||||
name: "bitwarden-test",
|
||||
version: "0.0.0",
|
||||
manifest_version: 3,
|
||||
});
|
||||
|
||||
stateService = mock<StateService>();
|
||||
sut = new SessionSyncer(behaviorSubject, stateService, metaData);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
behaviorSubject.unsubscribe();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should throw if behaviorSubject is not an instance of BehaviorSubject", () => {
|
||||
expect(() => {
|
||||
new SessionSyncer({} as any, stateService, null);
|
||||
}).toThrowError("behaviorSubject must be an instance of BehaviorSubject");
|
||||
});
|
||||
|
||||
it("should create if either ctor or initializer is provided", () => {
|
||||
expect(
|
||||
new SessionSyncer(behaviorSubject, stateService, { key: key, ctor: String })
|
||||
).toBeDefined();
|
||||
expect(
|
||||
new SessionSyncer(behaviorSubject, stateService, {
|
||||
key: key,
|
||||
initializer: (s: any) => s,
|
||||
})
|
||||
).toBeDefined();
|
||||
});
|
||||
it("should throw if neither ctor or initializer is provided", () => {
|
||||
expect(() => {
|
||||
new SessionSyncer(behaviorSubject, stateService, { key: key });
|
||||
}).toThrowError("ctor or initializer must be provided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifest v2 init", () => {
|
||||
let observeSpy: jest.SpyInstance;
|
||||
let listenForUpdatesSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
observeSpy = jest.spyOn(behaviorSubject, "subscribe").mockReturnThis();
|
||||
listenForUpdatesSpy = jest.spyOn(BrowserApi, "messageListener").mockReturnValue();
|
||||
jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({
|
||||
name: "bitwarden-test",
|
||||
version: "0.0.0",
|
||||
manifest_version: 2,
|
||||
});
|
||||
|
||||
sut.init();
|
||||
});
|
||||
|
||||
it("should not start observing", () => {
|
||||
expect(observeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not start listening", () => {
|
||||
expect(listenForUpdatesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a value is emitted on the observable", () => {
|
||||
let sendMessageSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
|
||||
|
||||
sut.init();
|
||||
|
||||
behaviorSubject.next("test");
|
||||
});
|
||||
|
||||
it("should update the session memory", async () => {
|
||||
// await finishing of fire-and-forget operation
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(stateService.setInSessionMemory).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.setInSessionMemory).toHaveBeenCalledWith(key, "test");
|
||||
});
|
||||
|
||||
it("should update sessionSyncers in other contexts", async () => {
|
||||
// await finishing of fire-and-forget operation
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith(`${key}_update`, { id: sut.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe("A message is received", () => {
|
||||
let nextSpy: jest.SpyInstance;
|
||||
let sendMessageSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
nextSpy = jest.spyOn(behaviorSubject, "next");
|
||||
sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
|
||||
|
||||
sut.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should ignore messages with the wrong command", async () => {
|
||||
await sut.updateFromMessage({ command: "wrong_command", id: sut.id });
|
||||
|
||||
expect(stateService.getFromSessionMemory).not.toHaveBeenCalled();
|
||||
expect(nextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore messages from itself", async () => {
|
||||
await sut.updateFromMessage({ command: `${key}_update`, id: sut.id });
|
||||
|
||||
expect(stateService.getFromSessionMemory).not.toHaveBeenCalled();
|
||||
expect(nextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update from message on emit from another instance", async () => {
|
||||
stateService.getFromSessionMemory.mockResolvedValue("test");
|
||||
|
||||
await sut.updateFromMessage({ command: `${key}_update`, id: "different_id" });
|
||||
|
||||
expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(key);
|
||||
|
||||
expect(nextSpy).toHaveBeenCalledTimes(1);
|
||||
expect(nextSpy).toHaveBeenCalledWith("test");
|
||||
expect(behaviorSubject.value).toBe("test");
|
||||
|
||||
// Expect no circular messaging
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,79 @@
|
||||
import { BehaviorSubject, Subscription } from "rxjs";
|
||||
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
|
||||
import { BrowserApi } from "../../browser/browserApi";
|
||||
import { StateService } from "../../services/abstractions/state.service";
|
||||
|
||||
import { SyncedItemMetadata } from "./sync-item-metadata";
|
||||
|
||||
export class SessionSyncer {
|
||||
subscription: Subscription;
|
||||
id = Utils.newGuid();
|
||||
|
||||
// everyone gets the same initial values
|
||||
private ignoreNextUpdate = true;
|
||||
|
||||
constructor(
|
||||
private behaviorSubject: BehaviorSubject<any>,
|
||||
private stateService: StateService,
|
||||
private metaData: SyncedItemMetadata
|
||||
) {
|
||||
if (!(behaviorSubject instanceof BehaviorSubject)) {
|
||||
throw new Error("behaviorSubject must be an instance of BehaviorSubject");
|
||||
}
|
||||
|
||||
if (metaData.ctor == null && metaData.initializer == null) {
|
||||
throw new Error("ctor or initializer must be provided");
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
if (chrome.runtime.getManifest().manifest_version != 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.observe();
|
||||
this.listenForUpdates();
|
||||
}
|
||||
|
||||
private observe() {
|
||||
// This may be a memory leak.
|
||||
// There is no good time to unsubscribe from this observable. Hopefully Manifest V3 clears memory from temporary
|
||||
// contexts. If so, this is handled by destruction of the context.
|
||||
this.subscription = this.behaviorSubject.subscribe(async (next) => {
|
||||
if (this.ignoreNextUpdate) {
|
||||
this.ignoreNextUpdate = false;
|
||||
return;
|
||||
}
|
||||
await this.updateSession(next);
|
||||
});
|
||||
}
|
||||
|
||||
private listenForUpdates() {
|
||||
// This is an unawaited promise, but it will be executed asynchronously in the background.
|
||||
BrowserApi.messageListener(
|
||||
this.updateMessageCommand,
|
||||
async (message) => await this.updateFromMessage(message)
|
||||
);
|
||||
}
|
||||
|
||||
async updateFromMessage(message: any) {
|
||||
if (message.command != this.updateMessageCommand || message.id === this.id) {
|
||||
return;
|
||||
}
|
||||
const keyValuePair = await this.stateService.getFromSessionMemory(this.metaData.key);
|
||||
const value = SyncedItemMetadata.buildFromKeyValuePair(keyValuePair, this.metaData);
|
||||
this.ignoreNextUpdate = true;
|
||||
this.behaviorSubject.next(value);
|
||||
}
|
||||
|
||||
private async updateSession(value: any) {
|
||||
await this.stateService.setInSessionMemory(this.metaData.key, value);
|
||||
await BrowserApi.sendMessage(this.updateMessageCommand, { id: this.id });
|
||||
}
|
||||
|
||||
private get updateMessageCommand() {
|
||||
return `${this.metaData.key}_update`;
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
export class SyncedItemMetadata {
|
||||
key: string;
|
||||
ctor?: new () => any;
|
||||
initializer?: (keyValuePair: any) => any;
|
||||
initializeAsArray?: boolean;
|
||||
|
||||
static buildFromKeyValuePair(keyValuePair: any, metadata: SyncedItemMetadata): any {
|
||||
const builder = SyncedItemMetadata.getBuilder(metadata);
|
||||
|
||||
if (metadata.initializeAsArray) {
|
||||
return keyValuePair.map((o: any) => builder(o));
|
||||
} else {
|
||||
return builder(keyValuePair);
|
||||
}
|
||||
}
|
||||
|
||||
private static getBuilder(metadata: SyncedItemMetadata): (o: any) => any {
|
||||
return metadata.initializer != null
|
||||
? metadata.initializer
|
||||
: (o: any) => Object.assign(new metadata.ctor(), o);
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
import { SyncedItemMetadata } from "./sync-item-metadata";
|
||||
|
||||
describe("build from key value pair", () => {
|
||||
const key = "key";
|
||||
const initializer = (s: any) => "used initializer";
|
||||
class TestClass {}
|
||||
const ctor = TestClass;
|
||||
|
||||
it("should call initializer if provided", () => {
|
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair(
|
||||
{},
|
||||
{
|
||||
key: "key",
|
||||
initializer: initializer,
|
||||
}
|
||||
);
|
||||
|
||||
expect(actual).toEqual("used initializer");
|
||||
});
|
||||
|
||||
it("should call ctor if provided", () => {
|
||||
const expected = { provided: "value" };
|
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair(expected, {
|
||||
key: key,
|
||||
ctor: ctor,
|
||||
});
|
||||
|
||||
expect(actual).toBeInstanceOf(ctor);
|
||||
expect(actual).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("should prefer using initializer if both are provided", () => {
|
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair(
|
||||
{},
|
||||
{
|
||||
key: key,
|
||||
initializer: initializer,
|
||||
ctor: ctor,
|
||||
}
|
||||
);
|
||||
|
||||
expect(actual).toEqual("used initializer");
|
||||
});
|
||||
|
||||
it("should honor initialize as array", () => {
|
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair([1, 2], {
|
||||
key: key,
|
||||
initializer: initializer,
|
||||
initializeAsArray: true,
|
||||
});
|
||||
|
||||
expect(actual).toEqual(["used initializer", "used initializer"]);
|
||||
});
|
||||
});
|
41
apps/browser/src/flags.ts
Normal file
41
apps/browser/src/flags.ts
Normal file
@ -0,0 +1,41 @@
|
||||
function getFlags<T>(envFlags: string | T): T {
|
||||
if (typeof envFlags === "string") {
|
||||
return JSON.parse(envFlags) as T;
|
||||
} else {
|
||||
return envFlags as T;
|
||||
}
|
||||
}
|
||||
|
||||
/* Placeholder for when we have a relevant feature flag
|
||||
export type Flags = { test?: boolean };
|
||||
export type FlagName = keyof Flags;
|
||||
export function flagEnabled(flag: FlagName): boolean {
|
||||
const flags = getFlags<Flags>(process.env.FLAGS);
|
||||
return flags[flag] == null || flags[flag];
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* These flags are useful for development and testing.
|
||||
* Dev Flags are always OFF in production.
|
||||
*/
|
||||
export type DevFlags = {
|
||||
storeSessionDecrypted?: boolean;
|
||||
};
|
||||
|
||||
export type DevFlagName = keyof DevFlags;
|
||||
|
||||
/**
|
||||
* Gets the value of a dev flag from environment.
|
||||
* Will always return false unless in development.
|
||||
* @param flag The name of the dev flag to check
|
||||
* @returns The value of the flag
|
||||
*/
|
||||
export function devFlagEnabled(flag: DevFlagName): boolean {
|
||||
if (process.env.ENV !== "development") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const devFlags = getFlags<DevFlags>(process.env.DEV_FLAGS);
|
||||
return devFlags[flag] == null || devFlags[flag];
|
||||
}
|
@ -7,6 +7,8 @@ import { BrowserGroupingsComponentState } from "src/models/browserGroupingsCompo
|
||||
import { BrowserSendComponentState } from "src/models/browserSendComponentState";
|
||||
|
||||
export abstract class StateService extends BaseStateServiceAbstraction<Account> {
|
||||
abstract getFromSessionMemory<T>(key: string): Promise<T>;
|
||||
abstract setInSessionMemory(key: string, value: any): Promise<void>;
|
||||
getBrowserGroupingComponentState: (
|
||||
options?: StorageOptions
|
||||
) => Promise<BrowserGroupingsComponentState>;
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
|
||||
import { BrowserApi } from "../browser/browserApi";
|
||||
|
||||
export default class BrowserMessagingService implements MessagingService {
|
||||
send(subscriber: string, arg: any = {}) {
|
||||
const message = Object.assign({}, { command: subscriber }, arg);
|
||||
chrome.runtime.sendMessage(message);
|
||||
return BrowserApi.sendMessage(subscriber, arg);
|
||||
}
|
||||
}
|
||||
|
15
apps/browser/src/services/folders/folder.service.ts
Normal file
15
apps/browser/src/services/folders/folder.service.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { BehaviorSubject } from "rxjs/internal/BehaviorSubject";
|
||||
|
||||
import { Folder } from "@bitwarden/common/models/domain/folder";
|
||||
import { FolderView } from "@bitwarden/common/models/view/folderView";
|
||||
import { FolderService as BaseFolderService } from "@bitwarden/common/services/folder/folder.service";
|
||||
|
||||
import { browserSession, sessionSync } from "../../decorators/session-sync-observable";
|
||||
|
||||
@browserSession
|
||||
export class FolderService extends BaseFolderService {
|
||||
@sessionSync({ initializer: Folder.fromJSON, initializeAsArray: true })
|
||||
protected _folders: BehaviorSubject<Folder[]>;
|
||||
@sessionSync({ initializer: FolderView.fromJSON, initializeAsArray: true })
|
||||
protected _folderViews: BehaviorSubject<FolderView[]>;
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { EncString } from "@bitwarden/common/models/domain/encString";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
||||
|
||||
import { devFlag } from "../decorators/dev-flag.decorator";
|
||||
import { devFlagEnabled } from "../flags";
|
||||
|
||||
import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service";
|
||||
import BrowserLocalStorageService from "./browserLocalStorage.service";
|
||||
import BrowserMemoryStorageService from "./browserMemoryStorage.service";
|
||||
@ -12,8 +15,8 @@ const keys = {
|
||||
sessionKey: "session",
|
||||
};
|
||||
|
||||
export class LocalBackedSessionStorageService extends AbstractStorageService {
|
||||
private cache = new Map<string, any>();
|
||||
export class LocalBackedSessionStorageService extends AbstractCachedStorageService {
|
||||
private cache = new Map<string, unknown>();
|
||||
private localStorage = new BrowserLocalStorageService();
|
||||
private sessionStorage = new BrowserMemoryStorageService();
|
||||
|
||||
@ -26,23 +29,27 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
|
||||
|
||||
async get<T>(key: string): Promise<T> {
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key);
|
||||
return this.cache.get(key) as T;
|
||||
}
|
||||
|
||||
return await this.getBypassCache(key);
|
||||
}
|
||||
|
||||
async getBypassCache<T>(key: string): Promise<T> {
|
||||
const session = await this.getLocalSession(await this.getSessionEncKey());
|
||||
if (session == null || !Object.keys(session).includes(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cache.set(key, session[key]);
|
||||
return this.cache.get(key);
|
||||
return this.cache.get(key) as T;
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
async save(key: string, obj: any): Promise<void> {
|
||||
async save<T>(key: string, obj: T): Promise<void> {
|
||||
if (obj == null) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
@ -59,13 +66,17 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
|
||||
await this.save(key, null);
|
||||
}
|
||||
|
||||
async getLocalSession(encKey: SymmetricCryptoKey): Promise<any> {
|
||||
async getLocalSession(encKey: SymmetricCryptoKey): Promise<Record<string, unknown>> {
|
||||
const local = await this.localStorage.get<string>(keys.sessionKey);
|
||||
|
||||
if (local == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (devFlagEnabled("storeSessionDecrypted")) {
|
||||
return local as any as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const sessionJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey);
|
||||
if (sessionJson == null) {
|
||||
// Error with decryption -- session is lost, delete state and key and start over
|
||||
@ -76,7 +87,26 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
|
||||
return JSON.parse(sessionJson);
|
||||
}
|
||||
|
||||
async setLocalSession(session: any, key: SymmetricCryptoKey) {
|
||||
async setLocalSession(session: Record<string, unknown>, key: SymmetricCryptoKey) {
|
||||
if (devFlagEnabled("storeSessionDecrypted")) {
|
||||
await this.setDecryptedLocalSession(session);
|
||||
} else {
|
||||
await this.setEncryptedLocalSession(session, key);
|
||||
}
|
||||
}
|
||||
|
||||
@devFlag("storeSessionDecrypted")
|
||||
async setDecryptedLocalSession(session: Record<string, unknown>): Promise<void> {
|
||||
// Make sure we're storing the jsonified version of the session
|
||||
const jsonSession = JSON.parse(JSON.stringify(session));
|
||||
if (session == null) {
|
||||
await this.localStorage.remove(keys.sessionKey);
|
||||
} else {
|
||||
await this.localStorage.save(keys.sessionKey, jsonSession);
|
||||
}
|
||||
}
|
||||
|
||||
async setEncryptedLocalSession(session: Record<string, unknown>, key: SymmetricCryptoKey) {
|
||||
const jsonSession = JSON.stringify(session);
|
||||
const encSession = await this.encryptService.encrypt(jsonSession, key);
|
||||
|
||||
@ -87,14 +117,12 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
|
||||
}
|
||||
|
||||
async getSessionEncKey(): Promise<SymmetricCryptoKey> {
|
||||
let storedKey = (await this.sessionStorage.get(keys.encKey)) as SymmetricCryptoKey;
|
||||
let storedKey = await this.sessionStorage.get<SymmetricCryptoKey>(keys.encKey);
|
||||
if (storedKey == null || Object.keys(storedKey).length == 0) {
|
||||
storedKey = await this.keyGenerationService.makeEphemeralKey();
|
||||
await this.setSessionEncKey(storedKey);
|
||||
}
|
||||
return SymmetricCryptoKey.fromJSON(
|
||||
Object.create(SymmetricCryptoKey.prototype, Object.getOwnPropertyDescriptors(storedKey))
|
||||
);
|
||||
return SymmetricCryptoKey.fromJSON(storedKey);
|
||||
}
|
||||
|
||||
async setSessionEncKey(input: SymmetricCryptoKey): Promise<void> {
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import {
|
||||
AbstractCachedStorageService,
|
||||
AbstractStorageService,
|
||||
} from "@bitwarden/common/abstractions/storage.service";
|
||||
import { SendType } from "@bitwarden/common/enums/sendType";
|
||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||
@ -14,12 +17,12 @@ import { BrowserComponentState } from "../models/browserComponentState";
|
||||
import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState";
|
||||
import { BrowserSendComponentState } from "../models/browserSendComponentState";
|
||||
|
||||
import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service";
|
||||
import { StateService } from "./state.service";
|
||||
|
||||
describe("Browser State Service", () => {
|
||||
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let diskStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let memoryStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let logService: SubstituteOf<LogService>;
|
||||
let stateMigrationService: SubstituteOf<StateMigrationService>;
|
||||
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
|
||||
@ -33,7 +36,6 @@ describe("Browser State Service", () => {
|
||||
beforeEach(() => {
|
||||
secureStorageService = Substitute.for();
|
||||
diskStorageService = Substitute.for();
|
||||
memoryStorageService = Substitute.for();
|
||||
logService = Substitute.for();
|
||||
stateMigrationService = Substitute.for();
|
||||
stateFactory = Substitute.for();
|
||||
@ -44,66 +46,104 @@ describe("Browser State Service", () => {
|
||||
profile: { userId: userId },
|
||||
});
|
||||
state.activeUserId = userId;
|
||||
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state)));
|
||||
memoryStorageService.get("state").mimicks(stateGetter);
|
||||
|
||||
sut = new StateService(
|
||||
diskStorageService,
|
||||
secureStorageService,
|
||||
memoryStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
stateFactory,
|
||||
useAccountCache
|
||||
);
|
||||
});
|
||||
|
||||
describe("getBrowserGroupingComponentState", () => {
|
||||
it("should return a BrowserGroupingsComponentState", async () => {
|
||||
state.accounts[userId].groupings = new BrowserGroupingsComponentState();
|
||||
describe("direct memory storage access", () => {
|
||||
let memoryStorageService: AbstractCachedStorageService;
|
||||
|
||||
const actual = await sut.getBrowserGroupingComponentState();
|
||||
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState);
|
||||
beforeEach(() => {
|
||||
// We need `AbstractCachedStorageService` in the prototype chain to correctly test cache bypass.
|
||||
memoryStorageService = Object.create(LocalBackedSessionStorageService.prototype);
|
||||
|
||||
sut = new StateService(
|
||||
diskStorageService,
|
||||
secureStorageService,
|
||||
memoryStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
stateFactory,
|
||||
useAccountCache
|
||||
);
|
||||
});
|
||||
|
||||
it("should bypass cache if possible", async () => {
|
||||
const spyBypass = jest
|
||||
.spyOn(memoryStorageService, "getBypassCache")
|
||||
.mockResolvedValue("value");
|
||||
const spyGet = jest.spyOn(memoryStorageService, "get");
|
||||
const result = await sut.getFromSessionMemory("key");
|
||||
expect(spyBypass).toHaveBeenCalled();
|
||||
expect(spyGet).not.toHaveBeenCalled();
|
||||
expect(result).toBe("value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserCipherComponentState", () => {
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
state.accounts[userId].ciphers = componentState;
|
||||
describe("state methods", () => {
|
||||
let memoryStorageService: SubstituteOf<AbstractStorageService>;
|
||||
|
||||
const actual = await sut.getBrowserCipherComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
beforeEach(() => {
|
||||
memoryStorageService = Substitute.for();
|
||||
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state)));
|
||||
memoryStorageService.get("state").mimicks(stateGetter);
|
||||
|
||||
sut = new StateService(
|
||||
diskStorageService,
|
||||
secureStorageService,
|
||||
memoryStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
stateFactory,
|
||||
useAccountCache
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserSendComponentState", () => {
|
||||
it("should return a BrowserSendComponentState", async () => {
|
||||
const sendState = new BrowserSendComponentState();
|
||||
sendState.sends = [new SendView(), new SendView()];
|
||||
sendState.typeCounts = new Map<SendType, number>([
|
||||
[SendType.File, 3],
|
||||
[SendType.Text, 5],
|
||||
]);
|
||||
state.accounts[userId].send = sendState;
|
||||
describe("getBrowserGroupingComponentState", () => {
|
||||
it("should return a BrowserGroupingsComponentState", async () => {
|
||||
state.accounts[userId].groupings = new BrowserGroupingsComponentState();
|
||||
|
||||
const actual = await sut.getBrowserSendComponentState();
|
||||
expect(actual).toBeInstanceOf(BrowserSendComponentState);
|
||||
expect(actual).toMatchObject(sendState);
|
||||
const actual = await sut.getBrowserGroupingComponentState();
|
||||
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserSendTypeComponentState", () => {
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
state.accounts[userId].sendType = componentState;
|
||||
describe("getBrowserCipherComponentState", () => {
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
state.accounts[userId].ciphers = componentState;
|
||||
|
||||
const actual = await sut.getBrowserSendTypeComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
const actual = await sut.getBrowserCipherComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserSendComponentState", () => {
|
||||
it("should return a BrowserSendComponentState", async () => {
|
||||
const sendState = new BrowserSendComponentState();
|
||||
sendState.sends = [new SendView(), new SendView()];
|
||||
sendState.typeCounts = new Map<SendType, number>([
|
||||
[SendType.File, 3],
|
||||
[SendType.Text, 5],
|
||||
]);
|
||||
state.accounts[userId].send = sendState;
|
||||
|
||||
const actual = await sut.getBrowserSendComponentState();
|
||||
expect(actual).toBeInstanceOf(BrowserSendComponentState);
|
||||
expect(actual).toMatchObject(sendState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserSendTypeComponentState", () => {
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
state.accounts[userId].sendType = componentState;
|
||||
|
||||
const actual = await sut.getBrowserSendTypeComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
|
||||
import {
|
||||
@ -16,6 +17,16 @@ export class StateService
|
||||
extends BaseStateService<GlobalState, Account>
|
||||
implements StateServiceAbstraction
|
||||
{
|
||||
async getFromSessionMemory<T>(key: string): Promise<T> {
|
||||
return this.memoryStorageService instanceof AbstractCachedStorageService
|
||||
? await this.memoryStorageService.getBypassCache<T>(key)
|
||||
: await this.memoryStorageService.get<T>(key);
|
||||
}
|
||||
|
||||
async setInSessionMemory(key: string, value: any): Promise<void> {
|
||||
await this.memoryStorageService.save(key, value);
|
||||
}
|
||||
|
||||
async addAccount(account: Account) {
|
||||
// Apply browser overrides to default account values
|
||||
account = new Account(account);
|
||||
|
@ -1,26 +1,32 @@
|
||||
// Add chrome storage api
|
||||
const get = jest.fn();
|
||||
const set = jest.fn();
|
||||
const has = jest.fn();
|
||||
const remove = jest.fn();
|
||||
const QUOTA_BYTES = 10;
|
||||
const getBytesInUse = jest.fn();
|
||||
const clear = jest.fn();
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
set,
|
||||
get,
|
||||
remove,
|
||||
QUOTA_BYTES,
|
||||
getBytesInUse,
|
||||
clear,
|
||||
},
|
||||
session: {
|
||||
set,
|
||||
get,
|
||||
has,
|
||||
remove,
|
||||
},
|
||||
const storage = {
|
||||
local: {
|
||||
set: jest.fn(),
|
||||
get: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
QUOTA_BYTES,
|
||||
getBytesInUse: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
},
|
||||
session: {
|
||||
set: jest.fn(),
|
||||
get: jest.fn(),
|
||||
has: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const runtime = {
|
||||
onMessage: {
|
||||
addListener: jest.fn(),
|
||||
},
|
||||
sendMessage: jest.fn(),
|
||||
getManifest: jest.fn(),
|
||||
};
|
||||
|
||||
// set chrome
|
||||
global.chrome = {
|
||||
storage,
|
||||
runtime,
|
||||
} as any;
|
||||
|
@ -6,6 +6,7 @@ const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const { AngularWebpackPlugin } = require("@ngtools/webpack");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const configurator = require("./config/config");
|
||||
|
||||
if (process.env.NODE_ENV == null) {
|
||||
process.env.NODE_ENV = "development";
|
||||
@ -14,6 +15,8 @@ const ENV = (process.env.ENV = process.env.NODE_ENV);
|
||||
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
|
||||
|
||||
console.log(`Building Manifest Version ${manifestVersion} app`);
|
||||
const envConfig = configurator.load(ENV);
|
||||
configurator.log(envConfig);
|
||||
|
||||
const moduleRules = [
|
||||
{
|
||||
@ -116,6 +119,10 @@ const plugins = [
|
||||
exclude: [/content\/.*/, /notification\/.*/],
|
||||
filename: "[file].map",
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
FLAGS: envConfig.flags,
|
||||
DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {},
|
||||
}),
|
||||
];
|
||||
|
||||
const config = {
|
||||
|
@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-console */
|
||||
function load(envName) {
|
||||
return {
|
||||
...loadConfig(envName),
|
||||
|
@ -192,4 +192,12 @@ describe("EncString", () => {
|
||||
cryptoService.received().decryptToUtf8(encString, key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON", () => {
|
||||
it("Should be represented by the encrypted string", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
|
||||
|
||||
expect(encString.toJSON()).toBe(encString.encryptedString);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { FolderData } from "@bitwarden/common/models/data/folderData";
|
||||
import { EncString } from "@bitwarden/common/models/domain/encString";
|
||||
import { Folder } from "@bitwarden/common/models/domain/folder";
|
||||
|
||||
import { mockEnc } from "../../utils";
|
||||
@ -38,4 +39,27 @@ describe("Folder", () => {
|
||||
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromJSON", () => {
|
||||
jest.mock("@bitwarden/common/models/domain/encString");
|
||||
const mockFromJson = (stub: any) => (stub + "_fromJSON") as any;
|
||||
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
|
||||
|
||||
it("initializes nested objects", () => {
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const actual = Folder.fromJSON({
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
name: "name",
|
||||
id: "id",
|
||||
});
|
||||
|
||||
const expected = {
|
||||
revisionDate: revisionDate,
|
||||
name: "name_fromJSON",
|
||||
id: "id",
|
||||
};
|
||||
|
||||
expect(actual).toMatchObject(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
22
libs/common/spec/models/view/folderView.spec.ts
Normal file
22
libs/common/spec/models/view/folderView.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { FolderView } from "@bitwarden/common/models/view/folderView";
|
||||
|
||||
describe("FolderView", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("initializes nested objects", () => {
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const actual = FolderView.fromJSON({
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
name: "name",
|
||||
id: "id",
|
||||
});
|
||||
|
||||
const expected = {
|
||||
revisionDate: revisionDate,
|
||||
name: "name",
|
||||
id: "id",
|
||||
};
|
||||
|
||||
expect(actual).toMatchObject(expected);
|
||||
});
|
||||
});
|
||||
});
|
@ -6,3 +6,7 @@ export abstract class AbstractStorageService {
|
||||
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
||||
abstract remove(key: string, options?: StorageOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class AbstractCachedStorageService extends AbstractStorageService {
|
||||
abstract getBypassCache<T>(key: string, options?: StorageOptions): Promise<T>;
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { IEncrypted } from "@bitwarden/common/interfaces/IEncrypted";
|
||||
|
||||
import { CryptoService } from "../../abstractions/crypto.service";
|
||||
@ -21,80 +23,9 @@ export class EncString implements IEncrypted {
|
||||
mac?: string
|
||||
) {
|
||||
if (data != null) {
|
||||
// data and header
|
||||
const encType = encryptedStringOrType as EncryptionType;
|
||||
|
||||
if (iv != null) {
|
||||
this.encryptedString = encType + "." + iv + "|" + data;
|
||||
} else {
|
||||
this.encryptedString = encType + "." + data;
|
||||
}
|
||||
|
||||
// mac
|
||||
if (mac != null) {
|
||||
this.encryptedString += "|" + mac;
|
||||
}
|
||||
|
||||
this.encryptionType = encType;
|
||||
this.data = data;
|
||||
this.iv = iv;
|
||||
this.mac = mac;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.encryptedString = encryptedStringOrType as string;
|
||||
if (!this.encryptedString) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerPieces = this.encryptedString.split(".");
|
||||
let encPieces: string[] = null;
|
||||
|
||||
if (headerPieces.length === 2) {
|
||||
try {
|
||||
this.encryptionType = parseInt(headerPieces[0], null);
|
||||
encPieces = headerPieces[1].split("|");
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
this.initFromData(encryptedStringOrType as EncryptionType, data, iv, mac);
|
||||
} else {
|
||||
encPieces = this.encryptedString.split("|");
|
||||
this.encryptionType =
|
||||
encPieces.length === 3
|
||||
? EncryptionType.AesCbc128_HmacSha256_B64
|
||||
: EncryptionType.AesCbc256_B64;
|
||||
}
|
||||
|
||||
switch (this.encryptionType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
if (encPieces.length !== 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
this.mac = encPieces[2];
|
||||
break;
|
||||
case EncryptionType.AesCbc256_B64:
|
||||
if (encPieces.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
if (encPieces.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.data = encPieces[0];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
this.initFromEncryptedString(encryptedStringOrType as string);
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,4 +64,100 @@ export class EncString implements IEncrypted {
|
||||
get dataBytes(): ArrayBuffer {
|
||||
return this.data == null ? null : Utils.fromB64ToArray(this.data).buffer;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.encryptedString;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<EncString>): EncString {
|
||||
return new EncString(obj);
|
||||
}
|
||||
|
||||
private initFromData(encType: EncryptionType, data: string, iv: string, mac: string) {
|
||||
if (iv != null) {
|
||||
this.encryptedString = encType + "." + iv + "|" + data;
|
||||
} else {
|
||||
this.encryptedString = encType + "." + data;
|
||||
}
|
||||
|
||||
// mac
|
||||
if (mac != null) {
|
||||
this.encryptedString += "|" + mac;
|
||||
}
|
||||
|
||||
this.encryptionType = encType;
|
||||
this.data = data;
|
||||
this.iv = iv;
|
||||
this.mac = mac;
|
||||
}
|
||||
|
||||
private initFromEncryptedString(encryptedString: string) {
|
||||
this.encryptedString = encryptedString as string;
|
||||
if (!this.encryptedString) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { encType, encPieces } = this.parseEncryptedString(this.encryptedString);
|
||||
this.encryptionType = encType;
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
if (encPieces.length !== 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
this.mac = encPieces[2];
|
||||
break;
|
||||
case EncryptionType.AesCbc256_B64:
|
||||
if (encPieces.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
if (encPieces.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.data = encPieces[0];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private parseEncryptedString(encryptedString: string): {
|
||||
encType: EncryptionType;
|
||||
encPieces: string[];
|
||||
} {
|
||||
const headerPieces = encryptedString.split(".");
|
||||
let encType: EncryptionType;
|
||||
let encPieces: string[] = null;
|
||||
|
||||
if (headerPieces.length === 2) {
|
||||
try {
|
||||
encType = parseInt(headerPieces[0], null);
|
||||
encPieces = headerPieces[1].split("|");
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
encPieces = encryptedString.split("|");
|
||||
encType =
|
||||
encPieces.length === 3
|
||||
? EncryptionType.AesCbc128_HmacSha256_B64
|
||||
: EncryptionType.AesCbc256_B64;
|
||||
}
|
||||
|
||||
return {
|
||||
encType,
|
||||
encPieces,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { FolderData } from "../data/folderData";
|
||||
import { FolderView } from "../view/folderView";
|
||||
|
||||
@ -37,4 +39,9 @@ export class Folder extends Domain {
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<Folder>) {
|
||||
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
|
||||
return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate });
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Folder } from "../domain/folder";
|
||||
import { ITreeNodeObject } from "../domain/treeNode";
|
||||
|
||||
@ -16,4 +18,9 @@ export class FolderView implements View, ITreeNodeObject {
|
||||
this.id = f.id;
|
||||
this.revisionDate = f.revisionDate;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<FolderView>) {
|
||||
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
|
||||
return Object.assign(new FolderView(), obj, { revisionDate });
|
||||
}
|
||||
}
|
||||
|
@ -13,8 +13,8 @@ import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey";
|
||||
import { FolderView } from "../../models/view/folderView";
|
||||
|
||||
export class FolderService implements InternalFolderServiceAbstraction {
|
||||
private _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
|
||||
private _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
|
||||
protected _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
|
||||
protected _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
|
||||
|
||||
folders$ = this._folders.asObservable();
|
||||
folderViews$ = this._folderViews.asObservable();
|
||||
|
Loading…
Reference in New Issue
Block a user