Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add e2e test for refresh tokens cooldown period - skip e2e #2860

Merged
merged 5 commits into from Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
45 changes: 29 additions & 16 deletions packages/api/src/Domain/Http/HttpService.ts
@@ -1,6 +1,6 @@
import { LoggerInterface, joinPaths, sleep } from '@standardnotes/utils'
import { Environment } from '@standardnotes/models'
import { LegacySession, Session, SessionToken } from '@standardnotes/domain-core'
import { LegacySession, Result, Session, SessionToken } from '@standardnotes/domain-core'
import {
HttpStatusCode,
HttpRequestParams,
Expand All @@ -23,10 +23,11 @@ import { HttpRequestOptions } from './HttpRequestOptions'
export class HttpService implements HttpServiceInterface {
private session?: Session | LegacySession
private __latencySimulatorMs?: number
private __simulateNextSessionRefreshResponseDrop = false
private declare host: string
loggingEnabled = false

private inProgressRefreshSessionPromise?: Promise<boolean>
private inProgressRefreshSessionPromise?: Promise<Result<HttpResponse<SessionRefreshResponseBody>>>
private updateMetaCallback!: (meta: HttpResponseMeta) => void
private refreshSessionCallback!: (session: Session) => void

Expand Down Expand Up @@ -173,12 +174,19 @@ export class HttpService implements HttpServiceInterface {
if (this.inProgressRefreshSessionPromise) {
await this.inProgressRefreshSessionPromise
} else {
this.inProgressRefreshSessionPromise = this.refreshSession()
const isSessionRefreshed = await this.inProgressRefreshSessionPromise
this.inProgressRefreshSessionPromise = undefined

if (!isSessionRefreshed) {
return response
const hasSessionTokenRenewedInBetweenOurRequest = httpRequest.authentication !== this.getSessionAccessToken()
if (!hasSessionTokenRenewedInBetweenOurRequest) {
this.inProgressRefreshSessionPromise = this.refreshSession()
const isSessionRefreshedResultOrError = await this.inProgressRefreshSessionPromise
let isSessionRefreshed = false
if (!isSessionRefreshedResultOrError.isFailed()) {
isSessionRefreshed = !isErrorResponse(isSessionRefreshedResultOrError.getValue())
}
this.inProgressRefreshSessionPromise = undefined

if (!isSessionRefreshed) {
return response
}
}
}

Expand All @@ -190,22 +198,27 @@ export class HttpService implements HttpServiceInterface {
return response
}

async refreshSession(): Promise<boolean> {
async refreshSession(): Promise<Result<HttpResponse<SessionRefreshResponseBody>>> {
if (!this.session) {
return false
return Result.fail('No session to refresh')
}

if (this.session instanceof LegacySession) {
return false
return Result.fail('Cannot refresh legacy session')
}

const response = await this.post<SessionRefreshResponseBody>(Paths.v1.refreshSession, {
access_token: this.session.accessToken.value,
refresh_token: this.session.refreshToken.value,
})

if (this.__simulateNextSessionRefreshResponseDrop) {
this.__simulateNextSessionRefreshResponseDrop = false
return Result.fail('Simulating a dropped response')
}

if (isErrorResponse(response)) {
return false
return Result.ok(response)
}

if (response.meta) {
Expand All @@ -217,7 +230,7 @@ export class HttpService implements HttpServiceInterface {
response.data.session.access_expiration,
)
if (accessTokenOrError.isFailed()) {
return false
return Result.fail(accessTokenOrError.getError())
}

const accessToken = accessTokenOrError.getValue()
Expand All @@ -227,21 +240,21 @@ export class HttpService implements HttpServiceInterface {
response.data.session.refresh_expiration,
)
if (refreshTokenOrError.isFailed()) {
return false
return Result.fail(refreshTokenOrError.getError())
}

const refreshToken = refreshTokenOrError.getValue()

const sessionOrError = Session.create(accessToken, refreshToken, response.data.session.readonly_access)
if (sessionOrError.isFailed()) {
return false
return Result.fail(sessionOrError.getError())
}

this.setSession(sessionOrError.getValue())

this.refreshSessionCallback(this.session)

return true
return Result.ok(response)
}

private params(inParams: Record<string | number | symbol, unknown>): HttpRequestParams {
Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/Domain/Http/HttpServiceInterface.ts
@@ -1,7 +1,8 @@
import { LegacySession, Session } from '@standardnotes/domain-core'
import { LegacySession, Result, Session } from '@standardnotes/domain-core'
import { HttpRequest, HttpRequestParams, HttpResponse, HttpResponseMeta } from '@standardnotes/responses'

import { HttpRequestOptions } from './HttpRequestOptions'
import { SessionRefreshResponseBody } from '../Response'

export interface HttpServiceInterface {
setHost(host: string): void
Expand All @@ -16,7 +17,7 @@ export interface HttpServiceInterface {
runHttp<T>(httpRequest: HttpRequest): Promise<HttpResponse<T>>

setSession(session: Session | LegacySession): void
refreshSession(): Promise<boolean>
refreshSession(): Promise<Result<HttpResponse<SessionRefreshResponseBody>>>
setCallbacks(
updateMetaCallback: (meta: HttpResponseMeta) => void,
refreshSessionCallback: (session: Session) => void,
Expand Down
7 changes: 6 additions & 1 deletion packages/snjs/lib/Services/Api/ApiService.ts
Expand Up @@ -408,7 +408,12 @@ export class LegacyApiService
}
}

async refreshSession(): Promise<HttpResponse<SessionRenewalResponse>> {
/**
* @deprecated
*
* This function should be replaced with @standardnotes/api's `HttpService::refreshSession` function.
*/
async deprecatedRefreshSessionOnlyUsedInE2eTests(): Promise<HttpResponse<SessionRenewalResponse>> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
Expand Down
9 changes: 8 additions & 1 deletion packages/snjs/lib/Services/Session/SessionManager.ts
Expand Up @@ -874,7 +874,14 @@ export class SessionManager
const willRefreshTokenExpireSoon = refreshTokenExpiration.getTime() - Date.now() < ThirtyMinutes

if (willAccessTokenExpireSoon || willRefreshTokenExpireSoon) {
return this.httpService.refreshSession()
const refreshSessionResultOrError = await this.httpService.refreshSession()
if (refreshSessionResultOrError.isFailed()) {
return false
}

const refreshSessionResult = refreshSessionResultOrError.getValue()

return isErrorResponse(refreshSessionResult)
}

return false
Expand Down
61 changes: 56 additions & 5 deletions packages/snjs/mocha/session.test.js
Expand Up @@ -68,7 +68,7 @@ describe('server session', function () {
password: password,
})

const response = await application.legacyApi.refreshSession()
const response = await application.legacyApi.deprecatedRefreshSessionOnlyUsedInE2eTests()

expect(response.status).to.equal(200)
expect(response.data.session.access_token).to.be.a('string')
Expand Down Expand Up @@ -178,7 +178,7 @@ describe('server session', function () {
expect(sessionFromStorage.refreshExpiration).to.equal(sessionFromApiService.refreshToken.expiresAt)
expect(sessionFromStorage.readonlyAccess).to.equal(sessionFromApiService.isReadOnly())

await application.legacyApi.refreshSession()
await application.legacyApi.deprecatedRefreshSessionOnlyUsedInE2eTests()

const updatedSessionFromStorage = await getSessionFromStorage(application)
const updatedSessionFromApiService = application.legacyApi.getSession()
Expand Down Expand Up @@ -407,7 +407,7 @@ describe('server session', function () {

await sleepUntilSessionExpires(application, false)

const refreshSessionResponse = await application.legacyApi.refreshSession()
const refreshSessionResponse = await application.legacyApi.deprecatedRefreshSessionOnlyUsedInE2eTests()

expect(refreshSessionResponse.status).to.equal(400)
/**
Expand Down Expand Up @@ -452,7 +452,7 @@ describe('server session', function () {
})
application.sessions.initializeFromDisk()

const refreshSessionResponse = await application.legacyApi.refreshSession()
const refreshSessionResponse = await application.legacyApi.deprecatedRefreshSessionOnlyUsedInE2eTests()

expect(refreshSessionResponse.status).to.equal(400)
expect(refreshSessionResponse.data.error.tag).to.equal('invalid-refresh-token')
Expand All @@ -470,7 +470,7 @@ describe('server session', function () {
password: password,
})

const refreshPromise = application.legacyApi.refreshSession()
const refreshPromise = application.legacyApi.deprecatedRefreshSessionOnlyUsedInE2eTests()
const syncResponse = await application.legacyApi.sync([])

expect(syncResponse.data.error).to.be.ok
Expand All @@ -481,6 +481,57 @@ describe('server session', function () {
await refreshPromise
})

it('should tell the client to refresh the token if one is used during the cooldown period after a refresh', async function () {
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})

const mimickApplyingSessionFromTheServerUnsuccessfully = () => {}
const originalSetSessionFn = application.http.setSession
const originalRefreshSessionCallbackFn = application.http.refreshSessionCallback
application.http.setSession = mimickApplyingSessionFromTheServerUnsuccessfully
application.http.refreshSessionCallback = mimickApplyingSessionFromTheServerUnsuccessfully

const refreshResultOrError = await application.http.refreshSession()
expect(refreshResultOrError.isFailed()).to.equal(false)

const refreshResult = refreshResultOrError.getValue()
expect(isErrorResponse(refreshResult)).to.equal(false)

const secondRefreshResultOrErrorWithNotAppliedSession = await application.http.refreshSession()
expect(secondRefreshResultOrErrorWithNotAppliedSession.isFailed()).to.equal(false)

const secondRefreshResultWithNotAppliedSession = secondRefreshResultOrErrorWithNotAppliedSession.getValue()
expect(isErrorResponse(secondRefreshResultWithNotAppliedSession)).to.equal(false)

application.http.setSession = originalSetSessionFn
application.http.refreshSessionCallback = originalRefreshSessionCallbackFn
})

it('if session renewal response is dropped, next sync with server should return a 498 and successfully renew the session', async function () {
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})

await sleepUntilSessionExpires(application)

const refreshSpy = sinon.spy(application.http, 'refreshSession')

/**
* With this sync, we expect refreshSession to be called twice, once where the response is dropped,
* and the other time where the request succeeds
*/
application.http.__simulateNextSessionRefreshResponseDrop = true
await application.sync.sync(syncOptions)
await application.sync.sync(syncOptions)

expect(refreshSpy.callCount).to.equal(2)
})

it('notes should be synced as expected after refreshing a session', async function () {
await Factory.registerUserToApplication({
application: application,
Expand Down