diff --git a/.eslintignore b/.eslintignore index b3cadd6a6..d41f22155 100644 --- a/.eslintignore +++ b/.eslintignore @@ -31,4 +31,6 @@ npm-debug.log.* src/workers/backups/process.ts src/workers/backups/* src/workers/filesystems/* -src/test/* \ No newline at end of file +src/test/* + +tests/vitest/* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e81f21abb..d613357da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,12 +120,14 @@ "style-loader": "^3.3.1", "tailwindcss": "^3.3.3", "terser-webpack-plugin": "^5.3.0", + "ts-essentials": "^10.1.1", "ts-loader": "^9.2.6", "ts-node": "^10.4.0", "ts-prune": "^0.10.3", "typescript": "^5.2.2", "vite-plugin-svgr": "^4.5.0", "vitest": "^3.2.4", + "vitest-mock-extended": "^3.1.0", "webpack": "^5.73.0", "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^4.9.1", @@ -21686,6 +21688,21 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-essentials": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", + "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -22763,6 +22780,20 @@ } } }, + "node_modules/vitest-mock-extended": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-3.1.0.tgz", + "integrity": "sha512-vCM0VkuocOUBwwqwV7JB7YStw07pqeKvEIrZnR8l3PtwYi6rAAJAyJACeC1UYNfbQWi85nz7EdiXWBFI5hll2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": ">=10.0.0" + }, + "peerDependencies": { + "typescript": "3.x || 4.x || 5.x", + "vitest": ">=3.0.0" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/package.json b/package.json index ef70a2da9..f04b6a81e 100644 --- a/package.json +++ b/package.json @@ -258,7 +258,9 @@ "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^4.9.1", "webpack-dev-server": "^4.7.1", - "webpack-merge": "^5.8.0" + "webpack-merge": "^5.8.0", + "vitest-mock-extended": "^3.1.0", + "ts-essentials": "^10.1.1" }, "dependencies": { "@headlessui/react": "^1.4.2", diff --git a/src/apps/main/auth/deeplink/initialize_current_user.ts b/src/apps/main/auth/deeplink/initialize_current_user.ts index 6c9146f9a..6d1024c2a 100644 --- a/src/apps/main/auth/deeplink/initialize_current_user.ts +++ b/src/apps/main/auth/deeplink/initialize_current_user.ts @@ -1,24 +1,22 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { updateCredentials } from '../service'; import { User } from '../../types'; import { driveServerModule } from '../../../../infra/drive-server/drive-server.module'; import ConfigStore from '../../config'; +import { updateCredentials } from '../service'; export async function initializeCurrentUser() { try { - logger.debug({ msg: 'Initializing current user...' }); + logger.debug({ tag: 'AUTH', msg: 'Initializing current user...' }); + const refreshResult = await driveServerModule.auth.refresh(); if (refreshResult.isLeft()) { throw new Error(`Failed to refresh user data: ${refreshResult.getLeft().message}`); } const refreshData = refreshResult.getRight(); - - logger.debug({ msg: 'User data refreshed successfully', userId: refreshData.user?.userId }); updateCredentials(refreshData.token, refreshData.newToken); const currentUser = ConfigStore.get('userData') as User; - const updatedUser: User = { ...currentUser, ...refreshData.user, @@ -27,8 +25,8 @@ export async function initializeCurrentUser() { ConfigStore.set('userData', updatedUser); - logger.debug({ msg: 'Current user initialized successfully', userEmail: updatedUser.email }); + logger.debug({ tag: 'AUTH', msg: 'Current user initialized successfully' }); } catch (error) { - throw logger.error({ msg: 'Failed to initialize current user', error }); + throw logger.error({ tag: 'AUTH', msg: 'Failed to initialize current user', error }); } } diff --git a/src/apps/main/auth/handlers.ts b/src/apps/main/auth/handlers.ts index 2de7725bf..720a43628 100644 --- a/src/apps/main/auth/handlers.ts +++ b/src/apps/main/auth/handlers.ts @@ -1,23 +1,11 @@ import { ipcMain } from 'electron'; import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { AccessResponse } from '../../renderer/pages/Login/service'; import { applicationOpened } from '../analytics/service'; import eventBus from '../event-bus'; -import { setupRootFolder } from '../virtual-root-folder/service'; import { getWidget } from '../windows/widget'; -import { createTokenSchedule } from './refresh-token/refresh-token'; -import { - canHisConfigBeRestored, - encryptToken, - getHeaders, - getNewApiHeaders, - getUser, - logout, - obtainToken, - setCredentials, - tokensArePresent, -} from './service'; +import { createTokenScheduleWithRetry } from './refresh-token/create-token-schedule-with-retry'; +import { encryptToken, getHeaders, getNewApiHeaders, getUser, logout, obtainToken, tokensArePresent } from './service'; let isLoggedIn = false; @@ -73,7 +61,7 @@ eventBus.on('APP_IS_READY', async (): Promise => { if (isLoggedIn) { encryptToken(); applicationOpened(); - await createTokenSchedule(); + await createTokenScheduleWithRetry(); eventBus.emit('USER_LOGGED_IN'); } }); diff --git a/src/apps/main/auth/refresh-token/create-token-schedule-with-retry.test.ts b/src/apps/main/auth/refresh-token/create-token-schedule-with-retry.test.ts new file mode 100644 index 000000000..362841db3 --- /dev/null +++ b/src/apps/main/auth/refresh-token/create-token-schedule-with-retry.test.ts @@ -0,0 +1,58 @@ +import { createTokenScheduleWithRetry } from './create-token-schedule-with-retry'; +import * as authServiceModule from '../service'; +import { call, calls, partialSpyOn } from 'tests/vitest/utils.helper'; +import { loggerMock } from 'tests/vitest/mocks.helper'; +import { TokenScheduler } from '../../token-scheduler/TokenScheduler'; +import { Job } from 'node-schedule'; + +describe('createTokenScheduleWithRetry', () => { + const obtainTokensMock = partialSpyOn(authServiceModule, 'obtainTokens'); + const scheduleMock = partialSpyOn(TokenScheduler.prototype, 'schedule'); + + const jobMock: Partial = { + cancel: vi.fn(), + }; + const validTokens = ['token-1', 'token-2']; + + beforeEach(() => { + scheduleMock.mockReturnValue(jobMock as Job); + }); + + it('should create token schedule with provided refreshedTokens parameter', async () => { + await createTokenScheduleWithRetry({ refreshedTokens: validTokens }); + + calls(scheduleMock).toHaveLength(1); + calls(obtainTokensMock).toHaveLength(0); + }); + + it('should create token schedule using obtainStoredTokens when no parameter provided', async () => { + obtainTokensMock.mockReturnValue(validTokens); + + await createTokenScheduleWithRetry(); + + calls(obtainTokensMock).toHaveLength(1); + calls(scheduleMock).toHaveLength(1); + }); + + it('should attempt to schedule only once when schedule() succeeds immediately', async () => { + scheduleMock.mockReturnValue(jobMock); + + await createTokenScheduleWithRetry({ refreshedTokens: validTokens }); + + calls(scheduleMock).toHaveLength(1); + calls(loggerMock.debug).toHaveLength(0); + }); + + it('should retry when schedule() fails and succeed on second attempt', async () => { + scheduleMock.mockReturnValueOnce(undefined).mockReturnValueOnce(jobMock); + + await createTokenScheduleWithRetry({ refreshedTokens: validTokens }); + + calls(scheduleMock).toHaveLength(2); + calls(loggerMock.debug).toHaveLength(1); + call(loggerMock.debug).toMatchObject({ + msg: '[TOKEN] Failed to create token schedule, retrying...', + tag: 'AUTH', + }); + }); +}); diff --git a/src/apps/main/auth/refresh-token/create-token-schedule-with-retry.ts b/src/apps/main/auth/refresh-token/create-token-schedule-with-retry.ts new file mode 100644 index 000000000..66ffffd45 --- /dev/null +++ b/src/apps/main/auth/refresh-token/create-token-schedule-with-retry.ts @@ -0,0 +1,28 @@ +import { TokenScheduler } from '../../token-scheduler/TokenScheduler'; +import { onUserUnauthorized } from '../handlers'; +import { obtainTokens as obtainStoredTokens } from '../service'; +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { refreshToken } from './refresh-token'; + +const CREATE_SCHEDULE_RETRY_LIMIT = 3; + +type Props = { + refreshedTokens?: Array; +}; + +export async function createTokenScheduleWithRetry({ refreshedTokens }: Props = {}) { + const tokens = refreshedTokens || obtainStoredTokens(); + const tokenScheduler = new TokenScheduler(5, tokens, onUserUnauthorized); + + let attempt = 0; + while (attempt < CREATE_SCHEDULE_RETRY_LIMIT) { + const schedule = tokenScheduler.schedule(() => refreshToken()); + if (schedule) break; + + attempt++; + logger.debug({ + tag: 'AUTH', + msg: '[TOKEN] Failed to create token schedule, retrying...', + }); + } +} diff --git a/src/apps/main/auth/refresh-token/refresh-token.test.ts b/src/apps/main/auth/refresh-token/refresh-token.test.ts index 8e52d04e1..8cbf8605d 100644 --- a/src/apps/main/auth/refresh-token/refresh-token.test.ts +++ b/src/apps/main/auth/refresh-token/refresh-token.test.ts @@ -1,266 +1,55 @@ -import { driveServerModule } from '../../../../infra/drive-server/drive-server.module'; import { Either, left, right } from '../../../../context/shared/domain/Either'; -import { obtainTokens, refreshToken } from './refresh-token'; +import { refreshToken } from './refresh-token'; import { RefreshTokenResponse } from '../../../../infra/drive-server/services/auth/auth.types'; -import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { updateCredentials } from '../service'; -import { Mock } from 'vitest'; - -vi.mock('../../../../infra/drive-server/drive-server.module', () => ({ - driveServerModule: { - auth: { - refresh: vi.fn(), - }, - }, -})); - -vi.mock('@internxt/drive-desktop-core/build/backend', () => ({ - logger: { - error: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - }, -})); - -vi.mock('../service', () => { - return { - updateCredentials: vi.fn(), - obtainTokens: vi.fn().mockReturnValue(['stored-token-1', 'stored-token-2']), - getUser: vi.fn().mockReturnValue({ - /* mock user object */ - }), - tokensArePresent: vi.fn().mockReturnValue(true), - logout: vi.fn(), - }; -}); - -vi.mock('../../token-scheduler/TokenScheduler', () => { - return { - TokenScheduler: vi.fn().mockImplementation(() => ({ - schedule: vi.fn().mockReturnValue(true), - })), - }; -}); - -describe('refresh-token', () => { - beforeEach(() => { - vi.clearAllMocks(); +import * as authServiceModule from '../service'; +import { driveServerModule } from '../../../../infra/drive-server/drive-server.module'; +import { call, calls, partialSpyOn } from 'tests/vitest/utils.helper'; +import * as handlers from '../handlers'; + +describe('refreshToken', () => { + const authRefreshMock = partialSpyOn(driveServerModule.auth, 'refresh'); + const updateCredentialsMock = partialSpyOn(authServiceModule, 'updateCredentials'); + const onUserUnauthorizedMock = partialSpyOn(handlers, 'onUserUnauthorized'); + + const refreshResult: Either = right({ + token: 'abc', + newToken: 'xyz', + user: { + email: 'user@example.com', + userId: 'user-id', + uuid: 'user-uuid', + } as RefreshTokenResponse['user'], }); - describe('obtainTokens', () => { - it('should properly return the user refresh token and the old token if the refresh was successful', async () => { - const refreshResult: Either = right({ - token: 'abc', - newToken: 'xyz', - user: { - email: 'user@example.com', - userId: 'user-id', - mnemonic: 'user mnemonic', - root_folder_id: 1, - rootFolderId: 'root-folder-id', - name: 'John', - lastname: 'Doe', - uuid: 'user-uuid', - credit: 100, - createdAt: new Date().toISOString(), - registerCompleted: true, - username: 'johndoe', - bridgeUser: 'bridge-user-id', - backupsBucket: 'backups-bucket-id', - avatar: 'avatar-url', - emailVerified: true, - lastPasswordChangedAt: new Date().toISOString(), - bucket: 'user-bucket', - sharedWorkspace: false, - hasReferralsProgram: false, - teams: false, - revocateKey: {}, - keys: { - ecc: { - publicKey: 'ecc-public-key', - privateKey: 'ecc-private-key', - revocationKey: 'ecc-revocation-key', - }, - kyber: { - publicKey: 'kyber-public-key', - privateKey: 'kyber-private-key', - }, - }, - privateKey: {}, - publicKey: {}, - }, - }); - (driveServerModule.auth.refresh as Mock).mockResolvedValue(refreshResult); - - const result = await obtainTokens(); + it('should call onUserUnauthorized and return error when refresh fails', async () => { + const mockError = new Error('Refresh request was not successful'); + authRefreshMock.mockResolvedValue(left(mockError)); - expect(result.isRight()).toBe(true); - expect(result.getRight()).toStrictEqual(expect.objectContaining({ token: 'abc', newToken: 'xyz' })); - }); + const result = await refreshToken(); - it('should return an error if the token is not obtained', async () => { - const mockError = new Error('Refresh request was not successful'); - const leftResult: Either = left(mockError); - (driveServerModule.auth.refresh as Mock).mockResolvedValue(leftResult); - - const result = await obtainTokens(); - - expect(result.isLeft()).toBe(true); - expect(result.getLeft()).toEqual(mockError); - }); - - it('should return an error if an error is thrown during the refresh process', async () => { - const thrownError = new Error('Unexpected failure'); - (driveServerModule.auth.refresh as Mock).mockRejectedValue(thrownError); - - const result = await obtainTokens(); - - expect(result.isLeft()).toBe(true); - expect(result.getLeft()).toEqual(thrownError); - expect(logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - msg: '[TOKEN] Could not obtain tokens', - tag: 'AUTH', - error: thrownError, - }), - ); - }); + expect(result.isLeft()).toBe(true); + expect(result.getLeft()).toBe(mockError); + calls(onUserUnauthorizedMock).toHaveLength(1); + calls(updateCredentialsMock).toHaveLength(0); }); - describe('refreshToken', () => { - it('should return an error if the refresh token is not obtained', async () => { - const mockError = new Error('Refresh request was not successful'); - (driveServerModule.auth.refresh as Mock).mockResolvedValue(left(mockError)); - - const result = await refreshToken(); - - expect(result.isLeft()).toBe(true); - expect(result.getLeft()).toBe(mockError); - }); - - it('should updateCredentials if the refresh was successful', async () => { - const mockData = { token: 'abc', newToken: 'xyz' }; - (driveServerModule.auth.refresh as Mock).mockResolvedValue(right(mockData)); - - await refreshToken(); + it('should return the token and the new token as an array if the refresh was successful', async () => { + authRefreshMock.mockResolvedValue(refreshResult); - expect(updateCredentials).toHaveBeenCalledWith('abc', 'xyz'); - }); + const result = await refreshToken(); - it('should return the token and the new token as an array if the refresh was successfull', async () => { - const mockData = { token: 'abc', newToken: 'xyz' }; - (driveServerModule.auth.refresh as Mock).mockResolvedValue(right(mockData)); - - const result = await refreshToken(); - - expect(result.isRight()).toBe(true); - expect(result.getRight()).toEqual(['abc', 'xyz']); - }); + expect(result.isRight()).toBe(true); + expect(result.getRight()).toEqual(['abc', 'xyz']); + call(updateCredentialsMock).toMatchObject(['abc', 'xyz']); + calls(onUserUnauthorizedMock).toHaveLength(0); }); - describe('createTokenSchedule', () => { - let refreshTokenMock: Mock; - let TokenSchedulerMock: Mock; - let scheduleMock: Mock; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - - scheduleMock = vi.fn().mockReturnValue(true); - TokenSchedulerMock = vi.fn().mockImplementation(() => ({ - schedule: scheduleMock, - })); - - vi.doMock('../../token-scheduler/TokenScheduler', () => ({ - TokenScheduler: TokenSchedulerMock, - })); - - refreshTokenMock = vi.fn().mockResolvedValue(right(['new-token', 'new-token-2'])); - vi.doMock('./refresh-token', async () => { - const actualModule = await vi.importActual('./refresh-token'); - return { - ...actualModule, - refreshToken: refreshTokenMock, - }; - }); - }); - - it('should properly create a token schedule with given refreshedTokens parameter', async () => { - const { createTokenSchedule } = await import('./refresh-token'); - const { onUserUnauthorized } = await import('../handlers'); - - const refreshedTokens = ['new-token-1', 'new-token-2']; - - await createTokenSchedule(refreshedTokens); - - expect(TokenSchedulerMock).toHaveBeenCalledWith(5, refreshedTokens, onUserUnauthorized); - - expect(scheduleMock).toHaveBeenCalled(); - expect(refreshTokenMock).not.toHaveBeenCalled(); - }); - - it('should properly create a token schedule with the result of obtainStoredTokens function', async () => { - const { createTokenSchedule } = await import('./refresh-token'); - const serviceModule = await import('../service'); - const storedTokens = ['stored-token-1', 'stored-token-2']; - - const obtainTokensMock = serviceModule.obtainTokens as Mock; - obtainTokensMock.mockReturnValue(storedTokens); - - await createTokenSchedule(); - - expect(obtainTokensMock).toHaveBeenCalled(); - expect(TokenSchedulerMock).toHaveBeenCalledWith(5, storedTokens, expect.any(Function)); - expect(scheduleMock).toHaveBeenCalled(); - expect(refreshTokenMock).not.toHaveBeenCalled(); - }); - - it('should logs debug and calls refreshToken when schedule() is false', async () => { - scheduleMock.mockReturnValue(false); - - // Mock driveServerModule.auth.refresh to simulate successful token refresh - const mockRefreshResponse = { - token: 'new-token', - newToken: 'new-token-2', - user: { - email: 'test@example.com', - userId: 'user-id', - mnemonic: 'user mnemonic', - root_folder_id: 1, - rootFolderId: 'root-folder-id', - name: 'John', - lastname: 'Doe', - uuid: 'user-uuid', - credit: 100, - createdAt: new Date().toISOString(), - registerCompleted: true, - username: 'johndoe', - bridgeUser: 'bridge-user-id', - backupsBucket: 'backups-bucket-id', - avatar: 'avatar-url', - emailVerified: true, - lastPasswordChangedAt: new Date().toISOString(), - }, - }; - - (driveServerModule.auth.refresh as Mock).mockResolvedValue(right(mockRefreshResponse)); - - const { createTokenSchedule } = await import('./refresh-token'); + it('should update credentials with the new tokens', async () => { + authRefreshMock.mockResolvedValue(refreshResult); - await createTokenSchedule(); + await refreshToken(); - expect(logger.debug).toHaveBeenCalledWith({ - msg: '[TOKEN] Failed to create token schedule', - tag: 'AUTH', - }); - // scheduleMock called twice: initially (returns false), then in recursive createTokenSchedule - expect(scheduleMock).toHaveBeenCalledTimes(2); - // Verify that driveServerModule.auth.refresh was called (which means refreshToken was called) - expect(driveServerModule.auth.refresh).toHaveBeenCalled(); - // Verify updateCredentials was called with the new tokens - expect(updateCredentials).toHaveBeenCalledWith('new-token', 'new-token-2'); - }); + calls(updateCredentialsMock).toHaveLength(1); + call(updateCredentialsMock).toMatchObject(['abc', 'xyz']); }); }); diff --git a/src/apps/main/auth/refresh-token/refresh-token.ts b/src/apps/main/auth/refresh-token/refresh-token.ts index 2293eeffa..a5aee73a4 100644 --- a/src/apps/main/auth/refresh-token/refresh-token.ts +++ b/src/apps/main/auth/refresh-token/refresh-token.ts @@ -1,70 +1,25 @@ -import { TokenScheduler } from '../../token-scheduler/TokenScheduler'; import { onUserUnauthorized } from '../handlers'; -import { obtainTokens as obtainStoredTokens, updateCredentials } from '../service'; -import { driveServerModule } from '../../../../infra/drive-server/drive-server.module'; +import { updateCredentials } from '../service'; import { Either, left, right } from '../../../../context/shared/domain/Either'; -import { RefreshTokenResponse } from '../../../../infra/drive-server/services/auth/auth.types'; +import { driveServerModule } from '../../../../infra/drive-server/drive-server.module'; import { logger } from '@internxt/drive-desktop-core/build/backend'; -export async function obtainTokens(): Promise> { - try { - const result = await driveServerModule.auth.refresh(); - if (result.isLeft()) { - onUserUnauthorized(); - } - return result; - } catch (err) { +export async function refreshToken(): Promise>> { + const result = await driveServerModule.auth.refresh(); + if (result.isLeft()) { + const error = result.getLeft(); logger.error({ - msg: '[TOKEN] Could not obtain tokens', - error: err, tag: 'AUTH', + msg: '[TOKEN] Could not refresh token, unauthorized user', + error, }); - return left(err as Error); + onUserUnauthorized(); + return left(error); } -} -export async function refreshToken(): Promise>> { - const response = await obtainTokens(); - - if (response.isLeft()) { - return left(response.getLeft()); - } - - const { token, newToken } = response.getRight(); + const { token, newToken } = result.getRight(); updateCredentials(token, newToken); return right([token, newToken]); } - -export async function createTokenSchedule(refreshedTokens?: Array): Promise { - const tokens = refreshedTokens || obtainStoredTokens(); - - const tokenScheduler = new TokenScheduler(5, tokens, onUserUnauthorized); - const schedule = tokenScheduler.schedule(() => { - refreshToken().catch((err) => { - logger.error({ - msg: '[TOKEN] Unexpected error during refresh', - error: err, - }); - }); - }); - - if (!schedule && !refreshedTokens) { - logger.debug({ - msg: '[TOKEN] Failed to create token schedule', - tag: 'AUTH', - }); - - const result = await refreshToken(); - - if (result.isRight()) { - await createTokenSchedule(result.getRight()); - } else { - logger.error({ - msg: '[TOKEN] Failed to refresh tokens after schedule failure', - error: result.getLeft(), - }); - } - } -} diff --git a/src/apps/main/auth/service.ts b/src/apps/main/auth/service.ts index 86328012e..0fb467991 100644 --- a/src/apps/main/auth/service.ts +++ b/src/apps/main/auth/service.ts @@ -134,13 +134,27 @@ export function obtainToken(tokenName: TokenKey): string { return token; } - if (!safeStorage.isEncryptionAvailable()) { - throw new Error('[AUTH] Safe Storage was not available when decrypting encrypted token'); - } + try { + if (!safeStorage.isEncryptionAvailable()) { + logger.error({ + msg: '[AUTH] Safe Storage was not available when decrypting encrypted token', + tag: 'AUTH', + }); + + ConfigStore.set(`${tokenName}Encrypted`, false); + return token; + } - const buffer = Buffer.from(token, TOKEN_ENCODING); + const buffer = Buffer.from(token, TOKEN_ENCODING); - return safeStorage.decryptString(buffer); + return safeStorage.decryptString(buffer); + } catch (err) { + throw logger.error({ + msg: '[AUTH] Failed to decrypt token', + tag: 'AUTH', + error: err, + }); + } } export function tokensArePresent(): boolean { diff --git a/src/apps/main/token-scheduler/TokenScheduler.ts b/src/apps/main/token-scheduler/TokenScheduler.ts index 50c94ad2e..9e9086689 100644 --- a/src/apps/main/token-scheduler/TokenScheduler.ts +++ b/src/apps/main/token-scheduler/TokenScheduler.ts @@ -19,7 +19,7 @@ export class TokenScheduler { try { const decoded = jwtDecode(token); - return decoded.exp || TokenScheduler.MAX_TIME; + return decoded.exp ? decoded.exp * 1000 : TokenScheduler.MAX_TIME; } catch (err) { logger.error({ msg: '[TOKEN] Token could be not decoded' }); @@ -34,18 +34,15 @@ export class TokenScheduler { } private calculateRenewDate(expiration: number): Date { - const renewSecondsBefore = this.daysBefore * 24 * 60 * 60; + const renewMillisBefore = this.daysBefore * 24 * 60 * 60 * 1000; - const renewDateInSeconds = expiration - renewSecondsBefore; + const renewDateInMillis = expiration - renewMillisBefore; - if (renewDateInSeconds >= Date.now()) { + if (renewDateInMillis <= Date.now()) { return new Date(Date.now() + FIVE_SECONDS); } - const date = new Date(0); - date.setUTCSeconds(renewDateInSeconds); - - return date; + return new Date(renewDateInMillis); } public schedule(refreshCallback: () => void) { @@ -57,7 +54,7 @@ export class TokenScheduler { return; } - if (expiration >= Date.now()) { + if (expiration <= Date.now()) { logger.warn({ msg: '[TOKEN] TOKEN IS EXPIRED' }); this.unauthorized(); diff --git a/src/apps/main/token-scheduler/token-scheduler.test.ts b/src/apps/main/token-scheduler/token-scheduler.test.ts index c382a7103..147087296 100644 --- a/src/apps/main/token-scheduler/token-scheduler.test.ts +++ b/src/apps/main/token-scheduler/token-scheduler.test.ts @@ -1,32 +1,27 @@ import jwt from 'jsonwebtoken'; -import ms from 'ms'; - +import ms, { StringValue } from 'ms'; import { TokenScheduler } from './TokenScheduler'; +import { calls } from 'tests/vitest/utils.helper'; -function calculateDayFromToday(t: string): Date { - const today = new Date(); - const milliseconds = ms(t as '25 days' | '29 day' | '20 days' | '26 days'); - return new Date(today.getTime() + (milliseconds as unknown as number)); +function createTokenExpiringIn(expiresIn: StringValue): string { + const email = 'test@internxt.com'; + const milliseconds = ms(expiresIn); + return jwt.sign({ email }, 'JWT_SECRET', { expiresIn: milliseconds / 1000 }); } -const dataSet = [ - { daysBefore: 5, day: calculateDayFromToday('25 days') }, - { daysBefore: 1, day: calculateDayFromToday('29 day') }, - { daysBefore: 10, day: calculateDayFromToday('20 days') }, -]; +function createExpiredToken(): string { + const email = 'test@internxt.com'; + return jwt.sign({ email }, 'JWT_SECRET', { expiresIn: -1 }); +} -function createToken(expiresIn: string) { - const email = 'test@inxternxt.com'; - const milliseconds = ms(expiresIn as '30 days' | '31 day'); - return jwt.sign({ email }, 'JWT_SECRET', { expiresIn: (milliseconds as unknown as number) / 1000 }); +function getDateInFuture(daysFromNow: number): Date { + return new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000); } -describe('Token Scheduler', () => { +describe('TokenScheduler', () => { let scheduler: TokenScheduler; - - const jwtExpiresInThirtyDays = createToken('30 days'); - - const jwtExpiresInThirtyOneDays = createToken('31 day'); + const unauthorizedCallbackMock = vi.fn(); + const refreshCallback = vi.fn(); const jwtWithoutExpiration = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXlsb2FkIjp7InV1aWQiOiIzMjE2YzUzNi1kZDJjLTVhNjEtOGM3Ni0yMmU0ZDQ4ZjY4OWUiLCJlbWFpbCI6InRlc3RAaW50ZXJueHQuY29tIiwibmFtZSI6InRlc3QiLCJsYXN0bmFtZSI6InRlc3QiLCJ1c2VybmFtZSI6InRlc3RAaW50ZXJueHQuY29tIiwic2hhcmVkV29ya3NwYWNlIjp0cnVlLCJuZXR3b3JrQ3JlZGVudGlhbHMiOnsidXNlciI6InRlc3RAaW50ZXJueHQuY29tIiwicGFzcyI6IiQyYSQwOCQ2QmhjZkRxaDE4c0kwN25kb2x0N29PNEtaTkpVQmpXSzYvZTRxMWppclR2SzdOTWE4dmZpLiJ9fSwiaWF0IjoxNjY3ODI4MDA2fQ.ckwjRsdNu9UUKUtdO3G32SwUUoMj7FAAOuBqVsIemo0'; @@ -34,82 +29,174 @@ describe('Token Scheduler', () => { const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.am9hbnZpY2Vuc0Bwcm90b24ubWU.REeEpym9y3IoqMNjyuAGCnhWX7YHH9nA8DREqEqCU5Q'; - const task = () => { - // no op - }; + beforeEach(() => { + vi.useRealTimers(); + }); afterEach(() => { - scheduler.cancelAll(); + scheduler?.cancelAll(); }); - it.each(dataSet)('schedules the token refresh n days before fist token expiration', (data) => { - scheduler = new TokenScheduler(data.daysBefore, [jwtExpiresInThirtyDays, jwtExpiresInThirtyOneDays], () => { - // no op - }); + describe('schedule()', () => { + describe('when tokens are valid', () => { + it('executes the refresh callback when the scheduled time arrives', () => { + vi.useFakeTimers(); - const schedule = scheduler.schedule(task); + const token30Days = createTokenExpiringIn('30d'); + const daysBefore = 5; - const nextInvocation = schedule?.nextInvocation(); + scheduler = new TokenScheduler(daysBefore, [token30Days], unauthorizedCallbackMock); + scheduler.schedule(refreshCallback); - expect(nextInvocation?.getDate()).toBe(data.day.getDate()); - }); + calls(refreshCallback).toHaveLength(0); + vi.advanceTimersByTime(24 * 24 * 60 * 60 * 1000); + calls(refreshCallback).toHaveLength(0); + vi.advanceTimersByTime(1 * 24 * 60 * 60 * 1000); + calls(refreshCallback).toHaveLength(1); + }); - it('shcedules to refresh even if a token does not expire', () => { - scheduler = new TokenScheduler(4, [jwtWithoutExpiration, jwtExpiresInThirtyDays, jwtExpiresInThirtyOneDays], () => { - // no op - }); + it('executes the refresh callback immediately when renewal date is in the past', async () => { + vi.useFakeTimers(); - const expectedExpireDay = calculateDayFromToday('26 days'); + const token2Days = createTokenExpiringIn('2d'); + const daysBefore = 5; - const schedule = scheduler.schedule(task); + scheduler = new TokenScheduler(daysBefore, [token2Days], unauthorizedCallbackMock); + scheduler.schedule(refreshCallback); - const nextInvocation = schedule?.nextInvocation(); + calls(refreshCallback).toHaveLength(0); + vi.advanceTimersByTime(300); + calls(refreshCallback).toHaveLength(1); + }); - expect(schedule).toBeDefined(); - expect(nextInvocation?.getDay()).toBe(expectedExpireDay.getDay()); - }); + it('schedules refresh N days before the earliest expiration date', () => { + const token30Days = createTokenExpiringIn('30d'); + const token31Days = createTokenExpiringIn('31d'); + const daysBefore = 5; + + scheduler = new TokenScheduler(daysBefore, [token30Days, token31Days], unauthorizedCallbackMock); + + const schedule = scheduler.schedule(vi.fn()); + const nextInvocation = schedule?.nextInvocation(); + + const expectedDate = getDateInFuture(30 - daysBefore); + + expect(schedule).toBeDefined(); + expect(nextInvocation?.getDate()).toBe(expectedDate.getDate()); + }); + + it('executes callback based on the token that expires first when multiple tokens exist', () => { + vi.useFakeTimers(); + + const token10Days = createTokenExpiringIn('10d'); + const token20Days = createTokenExpiringIn('20d'); + const token30Days = createTokenExpiringIn('30d'); + const daysBefore = 3; + + scheduler = new TokenScheduler(daysBefore, [token30Days, token10Days, token20Days], unauthorizedCallbackMock); + scheduler.schedule(refreshCallback); + + calls(refreshCallback).toHaveLength(0); + vi.advanceTimersByTime(7 * 24 * 60 * 60 * 1000); + calls(refreshCallback).toHaveLength(1); + }); + + it('ignores tokens without expiration field and uses valid tokens', () => { + const token30Days = createTokenExpiringIn('30d'); + const daysBefore = 5; + + scheduler = new TokenScheduler(daysBefore, [jwtWithoutExpiration, token30Days], unauthorizedCallbackMock); + + const schedule = scheduler.schedule(vi.fn()); + + expect(schedule).toBeDefined(); + }); - it('schedules to refresh even if a token is not valid', () => { - scheduler = new TokenScheduler(4, [invalidToken, jwtExpiresInThirtyDays, jwtExpiresInThirtyOneDays], () => { - // no op + it('ignores invalid tokens and uses valid tokens', () => { + const token30Days = createTokenExpiringIn('30d'); + const daysBefore = 5; + + scheduler = new TokenScheduler(daysBefore, [invalidToken, token30Days], unauthorizedCallbackMock); + + const schedule = scheduler.schedule(vi.fn()); + + expect(schedule).toBeDefined(); + }); }); - const schedule = scheduler.schedule(task); + describe('when tokens are expired or about to expire', () => { + it('calls unauthorized callback and does not schedule when token is already expired', () => { + const expiredToken = createExpiredToken(); - expect(schedule).toBeDefined(); - }); + scheduler = new TokenScheduler(5, [expiredToken], unauthorizedCallbackMock); - it('shedules to refresh even if a token is not valid or does not expire', () => { - scheduler = new TokenScheduler( - 7, - [jwtWithoutExpiration, jwtExpiresInThirtyDays, invalidToken, jwtExpiresInThirtyOneDays], - () => { - // no op - }, - ); + const schedule = scheduler.schedule(vi.fn()); - const schedule = scheduler.schedule(task); + expect(schedule).toBeUndefined(); + calls(unauthorizedCallbackMock).toHaveLength(1); + }); - expect(schedule).toBeDefined(); - }); + it('schedules for 300ms from now when renewal date would be in the past (bug: should be 5 minutes)', () => { + const token2Days = createTokenExpiringIn('2d'); + const daysBefore = 5; + + const beforeSchedule = Date.now(); + + scheduler = new TokenScheduler(daysBefore, [token2Days], unauthorizedCallbackMock); + const schedule = scheduler.schedule(vi.fn()); + + const afterSchedule = Date.now(); + + const nextInvocation = schedule?.nextInvocation(); + const invocationTime = nextInvocation?.getTime() || 0; + + const expectedMinTime = beforeSchedule; + const expectedMaxTime = afterSchedule + 1000; - it('does not schedule if any token expires', () => { - scheduler = new TokenScheduler(7, [jwtWithoutExpiration], () => { - // no op + expect(schedule).toBeDefined(); + expect(invocationTime).toBeGreaterThanOrEqual(expectedMinTime); + expect(invocationTime).toBeLessThanOrEqual(expectedMaxTime); + }); }); - const schedule = scheduler.schedule(task); + describe('when no valid tokens exist', () => { + it('does not schedule when all tokens are invalid', () => { + scheduler = new TokenScheduler(5, [invalidToken, invalidToken], unauthorizedCallbackMock); - expect(schedule).not.toBeDefined(); - }); + const schedule = scheduler.schedule(vi.fn()); + + expect(schedule).toBeUndefined(); + calls(unauthorizedCallbackMock).toHaveLength(0); + }); + + it('does not schedule when all tokens have no expiration', () => { + scheduler = new TokenScheduler(5, [jwtWithoutExpiration], unauthorizedCallbackMock); - it('does not schedules if any token is valid', () => { - scheduler = new TokenScheduler(7, [invalidToken], () => { - // no op + const schedule = scheduler.schedule(vi.fn()); + + expect(schedule).toBeUndefined(); + calls(unauthorizedCallbackMock).toHaveLength(0); + }); }); + }); - const schedule = scheduler.schedule(task); + describe('cancelAll()', () => { + it('cancels all scheduled jobs', () => { + const token30Days = createTokenExpiringIn('30d'); + const refreshCallback = vi.fn(); - expect(schedule).not.toBeDefined(); + scheduler = new TokenScheduler(5, [token30Days], unauthorizedCallbackMock); + + const schedule1 = scheduler.schedule(refreshCallback); + const schedule2 = scheduler.schedule(refreshCallback); + + expect(schedule1).toBeDefined(); + expect(schedule2).toBeDefined(); + + scheduler.cancelAll(); + + expect(schedule1?.nextInvocation()).toBeNull(); + expect(schedule2?.nextInvocation()).toBeNull(); + }); }); }); diff --git a/src/infra/drive-server/client/drive-server.client.instance.test.ts b/src/infra/drive-server/client/drive-server.client.instance.test.ts index eb3836e72..6cc77d1a1 100644 --- a/src/infra/drive-server/client/drive-server.client.instance.test.ts +++ b/src/infra/drive-server/client/drive-server.client.instance.test.ts @@ -23,6 +23,7 @@ describe('driveServerClient instance', () => { beforeEach(() => { originalEnv = process.env.NEW_DRIVE_URL; + vi.resetModules(); }); afterEach(() => { @@ -44,7 +45,8 @@ describe('driveServerClient instance', () => { ); }); - it('should call eventBus.emit and logout when onUnauthorized is triggered', () => { + it('should call eventBus.emit and logout when onUnauthorized is triggered', async () => { + await import('./drive-server.client.instance'); const clientOptionsArg = (createClient as Mock).mock.calls[0][0]; clientOptionsArg.onUnauthorized(); diff --git a/src/infra/drive-server/services/auth/auth.service.test.ts b/src/infra/drive-server/services/auth/auth.service.test.ts index 3c7b6ccc3..91c308081 100644 --- a/src/infra/drive-server/services/auth/auth.service.test.ts +++ b/src/infra/drive-server/services/auth/auth.service.test.ts @@ -52,7 +52,7 @@ describe('AuthService', () => { 'internxt-version': '2.4.8', 'x-internxt-desktop-header': 'test-header', }; - (getNewApiHeaders as Mock).mockResolvedValue(mockedHeaders); + (getNewApiHeaders as Mock).mockReturnValue(mockedHeaders); const result = await sut.refresh(); expect(result.isRight()).toEqual(true); diff --git a/src/infra/drive-server/services/auth/auth.service.ts b/src/infra/drive-server/services/auth/auth.service.ts index dfee83c1f..0c9a341ed 100644 --- a/src/infra/drive-server/services/auth/auth.service.ts +++ b/src/infra/drive-server/services/auth/auth.service.ts @@ -70,7 +70,7 @@ export class AuthService { async refresh(): Promise> { try { const response = await authClient.GET('/users/refresh', { - headers: await getNewApiHeaders(), + headers: getNewApiHeaders(), }); if (!response.data) { diff --git a/tests/vitest/mocks.helper.ts b/tests/vitest/mocks.helper.ts new file mode 100644 index 000000000..95168299a --- /dev/null +++ b/tests/vitest/mocks.helper.ts @@ -0,0 +1,3 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; + +export const loggerMock = vi.mocked(logger); diff --git a/tests/vitest/utils.helper.ts b/tests/vitest/utils.helper.ts new file mode 100644 index 000000000..76861ab0e --- /dev/null +++ b/tests/vitest/utils.helper.ts @@ -0,0 +1,51 @@ +import { DeepPartial } from 'ts-essentials'; +import { MockedFunction, MockInstance } from 'vitest'; +import { loggerMock } from './mocks.helper'; + +function getCalls(object: any) { + return object.mock.calls.map((call: any) => { + if (call.length === 1) return call[0]; + return call; + }); +} + +export function calls(object: any) { + return expect(getCalls(object)); +} + +export function call(object: any) { + const calls = getCalls(object); + if (calls.length !== 1) throw new Error(`Invalid length in ${object.getMockName()}: ${calls.length} calls`); + return expect(calls[0]); +} + +export function mockProps unknown>(props: DeepPartial[0]>) { + const result = props as Parameters[0]; + result.ctx = { ...result.ctx, logger: loggerMock }; + return result; +} + +export function deepMocked unknown>(fn: T) { + return vi.mocked(fn) as MockedFunction<(...args: Parameters) => DeepPartial>>; +} + +export function testSleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * v2.5.1 Daniel Jiménez + * Code extracted from vitest + * https://github.com/vitest-dev/vitest/blob/c1f78d2adc78ef08ef8b61b0dd6a925fb08f20b6/packages/spy/src/index.ts#L464 + */ +type Procedure = (...args: any[]) => any; +type Methods = keyof { [K in keyof T as T[K] extends Procedure ? K : never]: T[K] }; +type Classes = { [K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never }[keyof T] & (string | symbol); +export function partialSpyOn> | Methods>>(obj: T, methodName: M, mock = true) { + type Fn = Required[M] extends (...args: any[]) => any ? Required[M] : never; + const objSpy = vi.spyOn(obj as Required, methodName); + // @ts-expect-error by default we want to remove always the real implementation + // se we don't run unexpected code + if (mock) objSpy.mockImplementation(() => {}); + return objSpy as MockInstance<(...args: Parameters) => DeepPartial>>; +} diff --git a/vitest.config.main.ts b/vitest.config.main.ts index 749b0d059..18a5522fc 100644 --- a/vitest.config.main.ts +++ b/vitest.config.main.ts @@ -6,6 +6,7 @@ export default defineConfig({ name: 'main', environment: 'node', setupFiles: ['./vitest.setup.main.ts'], + clearMocks: true, include: [ '**/*.test.ts', ],