feat(snjs): add sign in with recovery codes use case (#2130)
* feat(snjs): add sign in with recovery codes use case * fix(snjs): code review adjustments * fix(snjs): remove unnecessary exposed getter * fix(services): waiting for event handling * fix: preferences test Co-authored-by: Mo <mo@standardnotes.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
AuthApiService,
|
||||
AuthenticatorApiService,
|
||||
AuthenticatorApiServiceInterface,
|
||||
AuthenticatorServer,
|
||||
AuthenticatorServerInterface,
|
||||
AuthServer,
|
||||
HttpService,
|
||||
HttpServiceInterface,
|
||||
SubscriptionApiService,
|
||||
@@ -69,6 +71,8 @@ import {
|
||||
AccountEvent,
|
||||
AuthenticatorClientInterface,
|
||||
AuthenticatorManager,
|
||||
AuthClientInterface,
|
||||
AuthManager,
|
||||
} from '@standardnotes/services'
|
||||
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
|
||||
import { ComputePrivateUsername } from '@standardnotes/encryption'
|
||||
@@ -88,9 +92,11 @@ import { SNLog } from '../Log'
|
||||
import { ChallengeResponse, ListedClientInterface } from '../Services'
|
||||
import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions'
|
||||
import { ApplicationOptionsDefaults } from './Options/Defaults'
|
||||
import { LegacySession, MapperInterface, Session } from '@standardnotes/domain-core'
|
||||
import { LegacySession, MapperInterface, Session, UseCaseInterface } from '@standardnotes/domain-core'
|
||||
import { SessionStorageMapper } from '@Lib/Services/Mapping/SessionStorageMapper'
|
||||
import { LegacySessionStorageMapper } from '@Lib/Services/Mapping/LegacySessionStorageMapper'
|
||||
import { SignInWithRecoveryCodes } from '@Lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
|
||||
import { UseCaseContainerInterface } from '@Lib/Domain/UseCase/UseCaseContainerInterface'
|
||||
|
||||
/** How often to automatically sync, in milliseconds */
|
||||
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
|
||||
@@ -106,7 +112,7 @@ type ApplicationObserver = {
|
||||
|
||||
type ObserverRemover = () => void
|
||||
|
||||
export class SNApplication implements ApplicationInterface, AppGroupManagedApplication {
|
||||
export class SNApplication implements ApplicationInterface, AppGroupManagedApplication, UseCaseContainerInterface {
|
||||
onDeinit!: ExternalServices.DeinitCallback
|
||||
|
||||
/**
|
||||
@@ -168,6 +174,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private declare authenticatorApiService: AuthenticatorApiServiceInterface
|
||||
private declare authenticatorServer: AuthenticatorServerInterface
|
||||
private declare authenticatorManager: AuthenticatorClientInterface
|
||||
private declare authManager: AuthClientInterface
|
||||
|
||||
private declare _signInWithRecoveryCodes: SignInWithRecoveryCodes
|
||||
|
||||
private internalEventBus!: ExternalServices.InternalEventBusInterface
|
||||
|
||||
@@ -250,6 +259,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return this.workspaceManager
|
||||
}
|
||||
|
||||
get signInWithRecoveryCodes(): UseCaseInterface<void> {
|
||||
return this._signInWithRecoveryCodes
|
||||
}
|
||||
|
||||
public get files(): FilesClientInterface {
|
||||
return this.fileService
|
||||
}
|
||||
@@ -1150,6 +1163,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.createAuthenticatorServer()
|
||||
this.createAuthenticatorApiService()
|
||||
this.createAuthenticatorManager()
|
||||
this.createAuthManager()
|
||||
|
||||
this.createUseCases()
|
||||
}
|
||||
|
||||
private clearServices() {
|
||||
@@ -1200,6 +1216,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
;(this.authenticatorApiService as unknown) = undefined
|
||||
;(this.authenticatorServer as unknown) = undefined
|
||||
;(this.authenticatorManager as unknown) = undefined
|
||||
;(this.authManager as unknown) = undefined
|
||||
;(this._signInWithRecoveryCodes as unknown) = undefined
|
||||
|
||||
this.services = []
|
||||
}
|
||||
@@ -1212,6 +1230,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.internalEventBus.addEventHandler(this.featuresService, ExternalServices.ApiServiceEvent.MetaReceived)
|
||||
this.internalEventBus.addEventHandler(this.integrityService, ExternalServices.SyncEvent.SyncRequestsIntegrityCheck)
|
||||
this.internalEventBus.addEventHandler(this.syncService, ExternalServices.IntegrityEvent.IntegrityCheckCompleted)
|
||||
this.internalEventBus.addEventHandler(this.userService, AccountEvent.SignedInOrRegistered)
|
||||
}
|
||||
|
||||
private clearInternalEventBus(): void {
|
||||
@@ -1348,7 +1367,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
case AccountEvent.SignedOut: {
|
||||
await this.notifyEvent(ApplicationEvent.SignedOut)
|
||||
await this.prepareForDeinit()
|
||||
this.deinit(this.getDeinitMode(), data?.source || DeinitSource.SignOut)
|
||||
this.deinit(this.getDeinitMode(), data?.payload.source || DeinitSource.SignOut)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
@@ -1739,4 +1758,23 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private createAuthenticatorManager() {
|
||||
this.authenticatorManager = new AuthenticatorManager(this.authenticatorApiService, this.internalEventBus)
|
||||
}
|
||||
|
||||
private createAuthManager() {
|
||||
const authServer = new AuthServer(this.httpService)
|
||||
|
||||
const authApiService = new AuthApiService(authServer)
|
||||
|
||||
this.authManager = new AuthManager(authApiService, this.internalEventBus)
|
||||
}
|
||||
|
||||
private createUseCases() {
|
||||
this._signInWithRecoveryCodes = new SignInWithRecoveryCodes(
|
||||
this.authManager,
|
||||
this.protocolService,
|
||||
this.inMemoryStore,
|
||||
this.options.crypto,
|
||||
this.sessionManager,
|
||||
this.internalEventBus,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
AuthClientInterface,
|
||||
InternalEventBusInterface,
|
||||
KeyValueStoreInterface,
|
||||
SessionsClientInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { AnyKeyParamsContent } from '@standardnotes/common'
|
||||
import { DecryptedPayloadInterface, RootKeyContent, RootKeyInterface } from '@standardnotes/models'
|
||||
import { SessionBody } from '@standardnotes/responses'
|
||||
|
||||
import { SignInWithRecoveryCodes } from './SignInWithRecoveryCodes'
|
||||
|
||||
describe('SignInWithRecoveryCodes', () => {
|
||||
let authManager: AuthClientInterface
|
||||
let protocolService: EncryptionProviderInterface
|
||||
let inMemoryStore: KeyValueStoreInterface<string>
|
||||
let crypto: PureCryptoInterface
|
||||
let sessionManager: SessionsClientInterface
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
|
||||
const createUseCase = () => new SignInWithRecoveryCodes(
|
||||
authManager,
|
||||
protocolService,
|
||||
inMemoryStore,
|
||||
crypto,
|
||||
sessionManager,
|
||||
internalEventBus,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
authManager = {} as jest.Mocked<AuthClientInterface>
|
||||
authManager.recoveryKeyParams = jest.fn().mockReturnValue({
|
||||
identifier: 'test@test.te',
|
||||
pw_nonce: 'pw_nonce',
|
||||
created: new Date().toISOString(),
|
||||
/** The event that lead to the creation of these params */
|
||||
origination: 'register',
|
||||
version: '004',
|
||||
})
|
||||
authManager.signInWithRecoveryCodes = jest.fn()
|
||||
|
||||
const rootKey = {
|
||||
serverPassword: 'foobar',
|
||||
} as jest.Mocked<RootKeyInterface>
|
||||
const payload = {} as jest.Mocked<DecryptedPayloadInterface<RootKeyContent>>
|
||||
payload.ejected = jest.fn().mockReturnValue({
|
||||
uuid: 'uuid',
|
||||
})
|
||||
rootKey.payload = payload
|
||||
|
||||
protocolService = {} as jest.Mocked<EncryptionProviderInterface>
|
||||
protocolService.hasAccount = jest.fn()
|
||||
protocolService.computeRootKey = jest.fn().mockReturnValue(rootKey)
|
||||
protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(true)
|
||||
protocolService.supportedVersions = jest.fn().mockReturnValue([
|
||||
'001',
|
||||
'002',
|
||||
'003',
|
||||
'004',
|
||||
])
|
||||
protocolService.isVersionNewerThanLibraryVersion = jest.fn()
|
||||
|
||||
inMemoryStore = {} as jest.Mocked<KeyValueStoreInterface<string>>
|
||||
inMemoryStore.setValue = jest.fn()
|
||||
inMemoryStore.removeValue = jest.fn()
|
||||
|
||||
crypto = {} as jest.Mocked<PureCryptoInterface>
|
||||
crypto.generateRandomKey = jest.fn()
|
||||
crypto.base64URLEncode = jest.fn()
|
||||
crypto.sha256 = jest.fn()
|
||||
|
||||
sessionManager = {} as jest.Mocked<SessionsClientInterface>
|
||||
sessionManager.handleAuthentication = jest.fn()
|
||||
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
internalEventBus.publishSync = jest.fn()
|
||||
})
|
||||
|
||||
it('should fail if an account already exists', async () => {
|
||||
protocolService.hasAccount = jest.fn().mockReturnValue(true)
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Tried to sign in when an account already exists.')
|
||||
})
|
||||
|
||||
it('should fail if recovery key params could not be retrieved', async () => {
|
||||
authManager.recoveryKeyParams = jest.fn().mockReturnValue(false)
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Could not retrieve recovery key params')
|
||||
})
|
||||
|
||||
it('should fail if key params has unsupported deriviation', async () => {
|
||||
protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(false)
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Your account was created on a platform with higher security capabilities than this browser supports. If we attempted to generate your login keys here, it would take hours. Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to log in.')
|
||||
})
|
||||
|
||||
it('should fail if key params has unsupported version', async () => {
|
||||
protocolService.isVersionNewerThanLibraryVersion = jest.fn().mockReturnValue(true)
|
||||
|
||||
authManager.recoveryKeyParams = jest.fn().mockReturnValue({
|
||||
identifier: 'test@test.te',
|
||||
pw_nonce: 'pw_nonce',
|
||||
created: new Date().toISOString(),
|
||||
/** The event that lead to the creation of these params */
|
||||
origination: 'register',
|
||||
version: '006',
|
||||
})
|
||||
|
||||
protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(false)
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('This version of the application does not support your newer account type. Please upgrade to the latest version of Standard Notes to sign in.')
|
||||
})
|
||||
|
||||
it('should fail if key params has expired version', async () => {
|
||||
protocolService.isVersionNewerThanLibraryVersion = jest.fn().mockReturnValue(false)
|
||||
|
||||
authManager.recoveryKeyParams = jest.fn().mockReturnValue({
|
||||
identifier: 'test@test.te',
|
||||
pw_nonce: 'pw_nonce',
|
||||
created: new Date().toISOString(),
|
||||
/** The event that lead to the creation of these params */
|
||||
origination: 'register',
|
||||
version: '006',
|
||||
})
|
||||
|
||||
protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(false)
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.com/help/security for more information.')
|
||||
})
|
||||
|
||||
it('should fail if the sign in with recovery codes fails', async () => {
|
||||
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue(false)
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Could not sign in with recovery codes')
|
||||
})
|
||||
|
||||
it('should sign in with recovery codes', async () => {
|
||||
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue({
|
||||
keyParams: {} as AnyKeyParamsContent,
|
||||
session: {} as SessionBody,
|
||||
user: {
|
||||
uuid: '1-2-3',
|
||||
email: 'test@test.te',
|
||||
protocolVersion: '004',
|
||||
}
|
||||
})
|
||||
|
||||
const useCase = createUseCase()
|
||||
const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' })
|
||||
|
||||
expect(result.isFailed()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
|
||||
import { CopyPayloadWithContentOverride } from '@standardnotes/models'
|
||||
import {
|
||||
AccountEvent,
|
||||
AuthClientInterface,
|
||||
EXPIRED_PROTOCOL_VERSION,
|
||||
InternalEventBusInterface,
|
||||
InternalEventPublishStrategy,
|
||||
KeyValueStoreInterface,
|
||||
SessionsClientInterface,
|
||||
StorageKey,
|
||||
UNSUPPORTED_KEY_DERIVATION,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
} from '@standardnotes/services'
|
||||
import { CreateAnyKeyParams, EncryptionProviderInterface, SNRootKey } from '@standardnotes/encryption'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
|
||||
import { SignInWithRecoveryCodesDTO } from './SignInWithRecoveryCodesDTO'
|
||||
|
||||
export class SignInWithRecoveryCodes implements UseCaseInterface<void> {
|
||||
constructor(
|
||||
private authManager: AuthClientInterface,
|
||||
private protocolService: EncryptionProviderInterface,
|
||||
private inMemoryStore: KeyValueStoreInterface<string>,
|
||||
private crypto: PureCryptoInterface,
|
||||
private sessionManager: SessionsClientInterface,
|
||||
private internalEventBus: InternalEventBusInterface,
|
||||
) {}
|
||||
|
||||
async execute(dto: SignInWithRecoveryCodesDTO): Promise<Result<void>> {
|
||||
if (this.protocolService.hasAccount()) {
|
||||
return Result.fail('Tried to sign in when an account already exists.')
|
||||
}
|
||||
|
||||
const codeVerifier = this.crypto.generateRandomKey(256)
|
||||
this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier)
|
||||
|
||||
const codeChallenge = this.crypto.base64URLEncode(await this.crypto.sha256(codeVerifier))
|
||||
|
||||
const recoveryKeyParams = await this.authManager.recoveryKeyParams({
|
||||
codeChallenge,
|
||||
...dto,
|
||||
})
|
||||
|
||||
if (recoveryKeyParams === false) {
|
||||
return Result.fail('Could not retrieve recovery key params')
|
||||
}
|
||||
|
||||
const rootKeyParams = CreateAnyKeyParams(recoveryKeyParams)
|
||||
|
||||
if (!this.protocolService.supportedVersions().includes(rootKeyParams.version)) {
|
||||
if (this.protocolService.isVersionNewerThanLibraryVersion(rootKeyParams.version)) {
|
||||
return Result.fail(UNSUPPORTED_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
return Result.fail(EXPIRED_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
if (!this.protocolService.platformSupportsKeyDerivation(rootKeyParams)) {
|
||||
return Result.fail(UNSUPPORTED_KEY_DERIVATION)
|
||||
}
|
||||
|
||||
const rootKey = await this.protocolService.computeRootKey(dto.password, rootKeyParams)
|
||||
|
||||
const signInResult = await this.authManager.signInWithRecoveryCodes({
|
||||
codeVerifier,
|
||||
recoveryCodes: dto.recoveryCodes,
|
||||
username: dto.username,
|
||||
password: rootKey.serverPassword as string,
|
||||
})
|
||||
|
||||
if (signInResult === false) {
|
||||
return Result.fail('Could not sign in with recovery codes')
|
||||
}
|
||||
|
||||
this.inMemoryStore.removeValue(StorageKey.CodeVerifier)
|
||||
|
||||
const expandedRootKey = new SNRootKey(
|
||||
CopyPayloadWithContentOverride(rootKey.payload, {
|
||||
keyParams: signInResult.keyParams,
|
||||
}),
|
||||
)
|
||||
|
||||
await this.sessionManager.handleAuthentication({
|
||||
session: signInResult.session,
|
||||
user: signInResult.user,
|
||||
rootKey: expandedRootKey,
|
||||
})
|
||||
|
||||
await this.internalEventBus.publishSync(
|
||||
{
|
||||
type: AccountEvent.SignedInOrRegistered,
|
||||
payload: {
|
||||
payload: {
|
||||
ephemeral: false,
|
||||
mergeLocal: false,
|
||||
awaitSync: true,
|
||||
checkIntegrity: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
InternalEventPublishStrategy.SEQUENCE,
|
||||
)
|
||||
|
||||
return Result.ok()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SignInWithRecoveryCodesDTO {
|
||||
recoveryCodes: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UseCaseInterface } from '@standardnotes/domain-core'
|
||||
|
||||
export interface UseCaseContainerInterface {
|
||||
get signInWithRecoveryCodes(): UseCaseInterface<void>
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Challenge,
|
||||
} from '@standardnotes/services'
|
||||
import { Base64String } from '@standardnotes/sncrypto-common'
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { ClientDisplayableError, SessionBody } from '@standardnotes/responses'
|
||||
import { CopyPayloadWithContentOverride } from '@standardnotes/models'
|
||||
import { isNullOrUndefined } from '@standardnotes/utils'
|
||||
import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
|
||||
@@ -306,7 +306,12 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
|
||||
throw new ApiCallError((registerResponse.data as HttpErrorResponseBody).error.message)
|
||||
}
|
||||
|
||||
await this.handleAuthResponse(registerResponse.data, rootKey, wrappingKey)
|
||||
await this.handleAuthentication({
|
||||
rootKey,
|
||||
wrappingKey,
|
||||
session: registerResponse.data.session,
|
||||
user: registerResponse.data.user,
|
||||
})
|
||||
|
||||
return registerResponse.data
|
||||
}
|
||||
@@ -640,22 +645,30 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
|
||||
this.setSession(session)
|
||||
}
|
||||
|
||||
private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) {
|
||||
async handleAuthentication(dto: {
|
||||
session: SessionBody
|
||||
user: {
|
||||
uuid: string
|
||||
email: string
|
||||
}
|
||||
rootKey: SNRootKey
|
||||
wrappingKey?: SNRootKey
|
||||
}): Promise<void> {
|
||||
const session = this.createSession(
|
||||
body.session.access_token,
|
||||
body.session.access_expiration,
|
||||
body.session.refresh_token,
|
||||
body.session.refresh_expiration,
|
||||
body.session.readonly_access,
|
||||
dto.session.access_token,
|
||||
dto.session.access_expiration,
|
||||
dto.session.refresh_token,
|
||||
dto.session.refresh_expiration,
|
||||
dto.session.readonly_access,
|
||||
)
|
||||
|
||||
if (session !== null) {
|
||||
await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey)
|
||||
await this.populateSession(dto.rootKey, dto.user, session, this.apiService.getHost(), dto.wrappingKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use handleAuthResponse instead
|
||||
* @deprecated use handleAuthentication instead
|
||||
*/
|
||||
private async handleSuccessAuthResponse(
|
||||
response: Responses.SignInResponse | Responses.ChangeCredentialsResponse,
|
||||
|
||||
@@ -220,6 +220,16 @@ export class AppContext {
|
||||
})
|
||||
}
|
||||
|
||||
awaitUserPrefsSingletonResolution() {
|
||||
return new Promise((resolve) => {
|
||||
this.application.preferencesService.addEventObserver((eventName) => {
|
||||
if (eventName === PreferencesServiceEvent.PreferencesChanged) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async launch({ awaitDatabaseLoad = true, receiveChallenge } = { awaitDatabaseLoad: true }) {
|
||||
await this.application.prepareForLaunch({
|
||||
receiveChallenge: receiveChallenge || this.handleChallenge,
|
||||
|
||||
@@ -74,10 +74,16 @@ describe('preferences', function () {
|
||||
await register.call(this)
|
||||
await this.application.setPreference('editorLeft', 300)
|
||||
await this.application.sync.sync()
|
||||
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
|
||||
|
||||
this.application = await this.context.signout()
|
||||
|
||||
await this.application.setPreference('editorLeft', 200)
|
||||
await this.application.signIn(this.email, this.password)
|
||||
|
||||
const promise = this.context.awaitUserPrefsSingletonResolution()
|
||||
await this.application.sync.sync({ awaitAll: true })
|
||||
await promise
|
||||
|
||||
const editorLeft = this.application.getPreference('editorLeft')
|
||||
expect(editorLeft).to.equal(300)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user