Browse Source
* Clean up dangling behaviorSubject
* Handle null in utils
* fix null check
* Await promises, even in async functions
* Add to/fromJSON methods to State and Accounts
This is needed since all storage in manifest v3 is key-value-pair-based
and session storage of most data is actually serialized into an
encrypted string.
* Simplify AccountKeys json parsing
* Fix account key (de)serialization
* Remove unused DecodedToken state
* Correct filename typo
* Simplify keys `toJSON` tests
* Explain AccountKeys `toJSON` return type
* Remove unnecessary `any`s
* Remove unique ArrayBuffer serialization
* Initialize items in MemoryStorageService
* Revert "Fix account key (de)serialization"
This reverts commit b1dffb5c2c, which was breaking serializations
* Move fromJSON to owning object
* Add DeepJsonify type
* Use Records for storage
* Add new Account Settings to serialized data
* Fix failing serialization tests
* Extract complex type conversion to helper methods
* Remove unnecessary decorator
* Return null from json deserializers
* Remove unnecessary decorators
* Remove obsolete test
* Use type-fest `Jsonify` formatting rules for external library
* Update jsonify comment
Co-authored-by: @eliykat
* Remove erroneous comment
* Fix unintended deep-jsonify changes
* Fix prettierignore
* Fix formatting of deep-jsonify.ts
Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
pull/3591/head
37 changed files with 635 additions and 286 deletions
@ -1,59 +1,39 @@
@@ -1,59 +1,39 @@
|
||||
import { SyncedItemMetadata } from "./sync-item-metadata"; |
||||
|
||||
describe("build from key value pair", () => { |
||||
describe("builder", () => { |
||||
const propertyKey = "propertyKey"; |
||||
const key = "key"; |
||||
const initializer = (s: any) => "used initializer"; |
||||
class TestClass {} |
||||
const ctor = TestClass; |
||||
|
||||
it("should call initializer if provided", () => { |
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair( |
||||
{}, |
||||
{ |
||||
propertyKey, |
||||
sessionKey: "key", |
||||
initializer: initializer, |
||||
} |
||||
); |
||||
|
||||
expect(actual).toEqual("used initializer"); |
||||
it("should use initializer if provided", () => { |
||||
const metadata = { propertyKey, sessionKey: key, initializer }; |
||||
const builder = SyncedItemMetadata.builder(metadata); |
||||
expect(builder({})).toBe("used initializer"); |
||||
}); |
||||
|
||||
it("should call ctor if provided", () => { |
||||
const expected = { provided: "value" }; |
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair(expected, { |
||||
propertyKey, |
||||
sessionKey: key, |
||||
ctor: ctor, |
||||
}); |
||||
|
||||
expect(actual).toBeInstanceOf(ctor); |
||||
expect(actual).toEqual(expect.objectContaining(expected)); |
||||
it("should use ctor if initializer is not provided", () => { |
||||
const metadata = { propertyKey, sessionKey: key, ctor }; |
||||
const builder = SyncedItemMetadata.builder(metadata); |
||||
expect(builder({})).toBeInstanceOf(TestClass); |
||||
}); |
||||
|
||||
it("should prefer using initializer if both are provided", () => { |
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair( |
||||
{}, |
||||
{ |
||||
propertyKey, |
||||
sessionKey: key, |
||||
initializer: initializer, |
||||
ctor: ctor, |
||||
} |
||||
); |
||||
|
||||
expect(actual).toEqual("used initializer"); |
||||
it("should prefer initializer over ctor", () => { |
||||
const metadata = { propertyKey, sessionKey: key, ctor, initializer }; |
||||
const builder = SyncedItemMetadata.builder(metadata); |
||||
expect(builder({})).toBe("used initializer"); |
||||
}); |
||||
|
||||
it("should honor initialize as array", () => { |
||||
const actual = SyncedItemMetadata.buildFromKeyValuePair([1, 2], { |
||||
const metadata = { |
||||
propertyKey, |
||||
sessionKey: key, |
||||
initializer: initializer, |
||||
initializeAsArray: true, |
||||
}); |
||||
|
||||
expect(actual).toEqual(["used initializer", "used initializer"]); |
||||
}; |
||||
const builder = SyncedItemMetadata.builder(metadata); |
||||
expect(builder([{}])).toBeInstanceOf(Array); |
||||
expect(builder([{}])[0]).toBe("used initializer"); |
||||
}); |
||||
}); |
||||
|
||||
@ -1,82 +0,0 @@
@@ -1,82 +0,0 @@
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; |
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service"; |
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; |
||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory"; |
||||
import { Account } from "@bitwarden/common/models/domain/account"; |
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState"; |
||||
import { State } from "@bitwarden/common/models/domain/state"; |
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; |
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; |
||||
import { StateService } from "@bitwarden/common/services/state.service"; |
||||
import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; |
||||
|
||||
describe("Browser State Service backed by chrome.storage api", () => { |
||||
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>>; |
||||
let useAccountCache: boolean; |
||||
|
||||
let state: State<GlobalState, Account>; |
||||
const userId = "userId"; |
||||
|
||||
let sut: StateService; |
||||
|
||||
beforeEach(() => { |
||||
secureStorageService = Substitute.for(); |
||||
diskStorageService = Substitute.for(); |
||||
memoryStorageService = Substitute.for(); |
||||
logService = Substitute.for(); |
||||
stateMigrationService = Substitute.for(); |
||||
stateFactory = Substitute.for(); |
||||
useAccountCache = true; |
||||
|
||||
state = new State(new GlobalState()); |
||||
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state))); |
||||
memoryStorageService.get("state").mimicks(stateGetter); |
||||
memoryStorageService |
||||
.save("state", Arg.any(), Arg.any()) |
||||
.mimicks((key: string, obj: any, options: StorageOptions) => { |
||||
return new Promise(() => { |
||||
state = obj; |
||||
}); |
||||
}); |
||||
|
||||
sut = new StateService( |
||||
diskStorageService, |
||||
secureStorageService, |
||||
memoryStorageService, |
||||
logService, |
||||
stateMigrationService, |
||||
stateFactory, |
||||
useAccountCache |
||||
); |
||||
}); |
||||
|
||||
describe("account state getters", () => { |
||||
beforeEach(() => { |
||||
state.accounts[userId] = createAccount(userId); |
||||
state.activeUserId = userId; |
||||
}); |
||||
|
||||
describe("getCryptoMasterKey", () => { |
||||
it("should return the stored SymmetricCryptoKey", async () => { |
||||
const key = new SymmetricCryptoKey(new Uint8Array(32).buffer); |
||||
state.accounts[userId].keys.cryptoMasterKey = key; |
||||
|
||||
const actual = await sut.getCryptoMasterKey(); |
||||
expect(actual).toBeInstanceOf(SymmetricCryptoKey); |
||||
expect(actual).toMatchObject(key); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
function createAccount(userId: string): Account { |
||||
return new Account({ |
||||
profile: { userId: userId }, |
||||
}); |
||||
} |
||||
}); |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
import { |
||||
EnvironmentServerConfigData, |
||||
ServerConfigData, |
||||
ThirdPartyServerConfigData, |
||||
} from "./server-config.data"; |
||||
|
||||
describe("ServerConfigData", () => { |
||||
describe("fromJSON", () => { |
||||
it("should create a ServerConfigData from a JSON object", () => { |
||||
const serverConfigData = ServerConfigData.fromJSON({ |
||||
version: "1.0.0", |
||||
gitHash: "1234567890", |
||||
server: { |
||||
name: "test", |
||||
url: "https://test.com", |
||||
}, |
||||
environment: { |
||||
vault: "https://vault.com", |
||||
api: "https://api.com", |
||||
identity: "https://identity.com", |
||||
notifications: "https://notifications.com", |
||||
sso: "https://sso.com", |
||||
}, |
||||
utcDate: "2020-01-01T00:00:00.000Z", |
||||
}); |
||||
|
||||
expect(serverConfigData.version).toEqual("1.0.0"); |
||||
expect(serverConfigData.gitHash).toEqual("1234567890"); |
||||
expect(serverConfigData.server.name).toEqual("test"); |
||||
expect(serverConfigData.server.url).toEqual("https://test.com"); |
||||
expect(serverConfigData.environment.vault).toEqual("https://vault.com"); |
||||
expect(serverConfigData.environment.api).toEqual("https://api.com"); |
||||
expect(serverConfigData.environment.identity).toEqual("https://identity.com"); |
||||
expect(serverConfigData.environment.notifications).toEqual("https://notifications.com"); |
||||
expect(serverConfigData.environment.sso).toEqual("https://sso.com"); |
||||
expect(serverConfigData.utcDate).toEqual("2020-01-01T00:00:00.000Z"); |
||||
}); |
||||
|
||||
it("should be an instance of ServerConfigData", () => { |
||||
const serverConfigData = ServerConfigData.fromJSON({} as any); |
||||
|
||||
expect(serverConfigData).toBeInstanceOf(ServerConfigData); |
||||
}); |
||||
|
||||
it("should deserialize sub objects", () => { |
||||
const serverConfigData = ServerConfigData.fromJSON({ |
||||
server: {}, |
||||
environment: {}, |
||||
} as any); |
||||
|
||||
expect(serverConfigData.server).toBeInstanceOf(ThirdPartyServerConfigData); |
||||
expect(serverConfigData.environment).toBeInstanceOf(EnvironmentServerConfigData); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
|
||||
import { Utils } from "@bitwarden/common/misc/utils"; |
||||
|
||||
import { makeStaticByteArray } from "../../../spec/utils"; |
||||
|
||||
import { AccountKeys, EncryptionPair } from "./account"; |
||||
import { SymmetricCryptoKey } from "./symmetricCryptoKey"; |
||||
|
||||
describe("AccountKeys", () => { |
||||
describe("toJSON", () => { |
||||
it("should serialize itself", () => { |
||||
const keys = new AccountKeys(); |
||||
const buffer = makeStaticByteArray(64).buffer; |
||||
keys.publicKey = buffer; |
||||
|
||||
const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString"); |
||||
keys.toJSON(); |
||||
expect(bufferSpy).toHaveBeenCalledWith(buffer); |
||||
}); |
||||
|
||||
it("should serialize public key as a string", () => { |
||||
const keys = new AccountKeys(); |
||||
keys.publicKey = Utils.fromByteStringToArray("hello").buffer; |
||||
const json = JSON.stringify(keys); |
||||
expect(json).toContain('"publicKey":"hello"'); |
||||
}); |
||||
}); |
||||
|
||||
describe("fromJSON", () => { |
||||
it("should deserialize public key to a buffer", () => { |
||||
const keys = AccountKeys.fromJSON({ |
||||
publicKey: "hello", |
||||
}); |
||||
expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello").buffer); |
||||
}); |
||||
|
||||
it("should deserialize cryptoMasterKey", () => { |
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); |
||||
AccountKeys.fromJSON({} as any); |
||||
expect(spy).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it("should deserialize organizationKeys", () => { |
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); |
||||
AccountKeys.fromJSON({ organizationKeys: [{ orgId: "keyJSON" }] } as any); |
||||
expect(spy).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it("should deserialize providerKeys", () => { |
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); |
||||
AccountKeys.fromJSON({ providerKeys: [{ providerId: "keyJSON" }] } as any); |
||||
expect(spy).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it("should deserialize privateKey", () => { |
||||
const spy = jest.spyOn(EncryptionPair, "fromJSON"); |
||||
AccountKeys.fromJSON({ |
||||
privateKey: { encrypted: "encrypted", decrypted: "decrypted" }, |
||||
} as any); |
||||
expect(spy).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
import { AccountProfile } from "./account"; |
||||
|
||||
describe("AccountProfile", () => { |
||||
describe("fromJSON", () => { |
||||
it("should deserialize to an instance of itself", () => { |
||||
expect(AccountProfile.fromJSON({})).toBeInstanceOf(AccountProfile); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
import { AccountSettings, EncryptionPair } from "./account"; |
||||
import { EncString } from "./encString"; |
||||
|
||||
describe("AccountSettings", () => { |
||||
describe("fromJSON", () => { |
||||
it("should deserialize to an instance of itself", () => { |
||||
expect(AccountSettings.fromJSON(JSON.parse("{}"))).toBeInstanceOf(AccountSettings); |
||||
}); |
||||
|
||||
it("should deserialize pinProtected", () => { |
||||
const accountSettings = new AccountSettings(); |
||||
accountSettings.pinProtected = EncryptionPair.fromJSON<string, EncString>({ |
||||
encrypted: "encrypted", |
||||
decrypted: "3.data", |
||||
}); |
||||
const jsonObj = JSON.parse(JSON.stringify(accountSettings)); |
||||
const actual = AccountSettings.fromJSON(jsonObj); |
||||
|
||||
expect(actual.pinProtected).toBeInstanceOf(EncryptionPair); |
||||
expect(actual.pinProtected.encrypted).toEqual("encrypted"); |
||||
expect(actual.pinProtected.decrypted.encryptedString).toEqual("3.data"); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
import { AccountTokens } from "./account"; |
||||
|
||||
describe("AccountTokens", () => { |
||||
describe("fromJSON", () => { |
||||
it("should deserialize to an instance of itself", () => { |
||||
expect(AccountTokens.fromJSON({})).toBeInstanceOf(AccountTokens); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
import { Account, AccountKeys, AccountProfile, AccountSettings, AccountTokens } from "./account"; |
||||
|
||||
describe("Account", () => { |
||||
describe("fromJSON", () => { |
||||
it("should deserialize to an instance of itself", () => { |
||||
expect(Account.fromJSON({})).toBeInstanceOf(Account); |
||||
}); |
||||
|
||||
it("should call all the sub-fromJSONs", () => { |
||||
const keysSpy = jest.spyOn(AccountKeys, "fromJSON"); |
||||
const profileSpy = jest.spyOn(AccountProfile, "fromJSON"); |
||||
const settingsSpy = jest.spyOn(AccountSettings, "fromJSON"); |
||||
const tokensSpy = jest.spyOn(AccountTokens, "fromJSON"); |
||||
|
||||
Account.fromJSON({}); |
||||
|
||||
expect(keysSpy).toHaveBeenCalled(); |
||||
expect(profileSpy).toHaveBeenCalled(); |
||||
expect(settingsSpy).toHaveBeenCalled(); |
||||
expect(tokensSpy).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
import { Utils } from "@bitwarden/common/misc/utils"; |
||||
|
||||
import { EncryptionPair } from "./account"; |
||||
|
||||
describe("EncryptionPair", () => { |
||||
describe("toJSON", () => { |
||||
it("should populate decryptedSerialized for buffer arrays", () => { |
||||
const pair = new EncryptionPair<string, ArrayBuffer>(); |
||||
pair.decrypted = Utils.fromByteStringToArray("hello").buffer; |
||||
const json = pair.toJSON(); |
||||
expect(json.decrypted).toEqual("hello"); |
||||
}); |
||||
|
||||
it("should serialize encrypted and decrypted", () => { |
||||
const pair = new EncryptionPair<string, string>(); |
||||
pair.encrypted = "hello"; |
||||
pair.decrypted = "world"; |
||||
const json = pair.toJSON(); |
||||
expect(json.encrypted).toEqual("hello"); |
||||
expect(json.decrypted).toEqual("world"); |
||||
}); |
||||
}); |
||||
|
||||
describe("fromJSON", () => { |
||||
it("should deserialize encrypted and decrypted", () => { |
||||
const pair = EncryptionPair.fromJSON({ |
||||
encrypted: "hello", |
||||
decrypted: "world", |
||||
}); |
||||
expect(pair.encrypted).toEqual("hello"); |
||||
expect(pair.decrypted).toEqual("world"); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
import { Account } from "./account"; |
||||
import { State } from "./state"; |
||||
|
||||
describe("state", () => { |
||||
describe("fromJSON", () => { |
||||
it("should deserialize to an instance of itself", () => { |
||||
expect(State.fromJSON({})).toBeInstanceOf(State); |
||||
}); |
||||
|
||||
it("should always assign an object to accounts", () => { |
||||
const state = State.fromJSON({}); |
||||
expect(state.accounts).not.toBeNull(); |
||||
expect(state.accounts).toEqual({}); |
||||
}); |
||||
|
||||
it("should build an account map", () => { |
||||
const accountsSpy = jest.spyOn(Account, "fromJSON"); |
||||
const state = State.fromJSON({ |
||||
accounts: { |
||||
userId: {}, |
||||
}, |
||||
}); |
||||
|
||||
expect(state.accounts["userId"]).toBeInstanceOf(Account); |
||||
expect(accountsSpy).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
import { |
||||
PositiveInfinity, |
||||
NegativeInfinity, |
||||
JsonPrimitive, |
||||
TypedArray, |
||||
JsonValue, |
||||
} from "type-fest"; |
||||
import { NotJsonable } from "type-fest/source/jsonify"; |
||||
|
||||
/** |
||||
* Extracted from type-fest and extended with Jsonification of objects returned from `toJSON` methods. |
||||
*/ |
||||
export type DeepJsonify<T> = |
||||
// Check if there are any non-JSONable types represented in the union.
|
||||
// Note: The use of tuples in this first condition side-steps distributive conditional types
|
||||
// (see https://github.com/microsoft/TypeScript/issues/29368#issuecomment-453529532)
|
||||
[Extract<T, NotJsonable | bigint>] extends [never] |
||||
? T extends PositiveInfinity | NegativeInfinity ? null |
||||
: T extends JsonPrimitive |
||||
? T // Primitive is acceptable
|
||||
: T extends number ? number |
||||
: T extends string ? string |
||||
: T extends boolean ? boolean |
||||
: T extends Map<any, any> | Set<any> ? Record<string, unknown> // {}
|
||||
: T extends TypedArray ? Record<string, number> |
||||
: T extends Array<infer U> |
||||
? Array<DeepJsonify<U extends NotJsonable ? null : U>> // It's an array: recursive call for its children
|
||||
: T extends object |
||||
? T extends { toJSON(): infer J } |
||||
? (() => J) extends () => JsonValue // Is J assignable to JsonValue?
|
||||
? J // Then T is Jsonable and its Jsonable value is J
|
||||
: {[P in keyof J as P extends symbol |
||||
? never |
||||
: J[P] extends NotJsonable |
||||
? never |
||||
: P]: DeepJsonify<Required<J>[P]>; |
||||
} // Not Jsonable because its toJSON() method does not return JsonValue
|
||||
: {[P in keyof T as P extends symbol |
||||
? never |
||||
: T[P] extends NotJsonable |
||||
? never |
||||
: P]: DeepJsonify<Required<T>[P]>} // It's an object: recursive call for its children
|
||||
: never // Otherwise any other non-object is removed
|
||||
: never; // Otherwise non-JSONable type union was found not empty
|
||||
Loading…
Reference in new issue