From 48eb37233ba791fdebc1e59016eb8a64f4c1a28f Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:47:51 +0100 Subject: [PATCH 1/4] feat: Implement client version handling and update error management - Added a new middleware to handle client version checks and block requests if the CLI version is unsupported. - Introduced `ClientVersionUnsupportedError` to manage version-related errors. - Updated error handling to provide user-friendly messages when the CLI version is blocked. - Enhanced the `catchError` utility to differentiate between regular errors and version-related errors. - Modified various services and commands to integrate version checks and handle errors appropriately. - Added tests to ensure correct behavior when the CLI version is unsupported. This update improves the user experience by guiding users to update their CLI version when necessary. --- Dockerfile | 2 + docker/entrypoint.sh | 15 ++ src/commands/whoami.ts | 5 +- src/services/auth.service.ts | 6 + src/utils/cli.utils.ts | 44 +++--- src/utils/errors.utils.ts | 50 +++++++ src/utils/update.utils.ts | 13 ++ src/webdav/handlers/PUT.handler.ts | 1 + src/webdav/middewares/auth.middleware.ts | 5 + .../middewares/client-version.middleware.ts | 44 ++++++ src/webdav/middewares/errors.middleware.ts | 35 ++--- src/webdav/webdav-server.ts | 2 + test/commands/whoami.test.ts | 31 ++++ test/fixtures/errors.fixture.ts | 15 ++ test/services/auth.service.test.ts | 17 +++ test/utils/cli.utils.test.ts | 43 ++++++ test/utils/errors.utils.test.ts | 81 ++++++++++- test/webdav/handlers/PUT.handler.test.ts | 35 +++++ .../middlewares/auth.middleware.test.ts | 14 ++ .../client-version.middleware.test.ts | 135 ++++++++++++++++++ 20 files changed, 543 insertions(+), 50 deletions(-) create mode 100644 src/utils/update.utils.ts create mode 100644 src/webdav/middewares/client-version.middleware.ts create mode 100644 test/webdav/middlewares/client-version.middleware.test.ts diff --git a/Dockerfile b/Dockerfile index 7213d028..12affd7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM node:24-bookworm-slim RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/* +ENV INXT_RUNTIME=docker + WORKDIR /app COPY . . diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 31d0ede2..30e10e47 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -18,6 +18,10 @@ session_alive() { [ -n "$WHOAMI_EMAIL" ] && [ "$WHOAMI_EMAIL" = "$INXT_USER_LOWER" ] } +version_blocked() { + [ "$(echo "$WHOAMI_OUTPUT" | jq -r '.code // empty')" = "CLIENT_VERSION_UNSUPPORTED" ] +} + webdav_online() { STATUS_OUTPUT=$(internxt webdav status --json 2>/dev/null || true) WEBDAV_STATUS=$(echo "$STATUS_OUTPUT" | jq -r '.message | split(" ") | last // empty') @@ -100,10 +104,21 @@ else AUTO_HEAL=false fi +VERSION_BLOCKED=false + while true; do sleep "$KEEPALIVE_INTERVAL" + if [ "$VERSION_BLOCKED" = true ]; then + continue + fi + if ! session_alive; then + if version_blocked; then + echo "[keepalive] ERROR: $(echo "$WHOAMI_OUTPUT" | jq -r '.message')" + VERSION_BLOCKED=true + continue + fi if [ "$AUTO_HEAL" = true ]; then echo "[keepalive] Session expired. Re-authenticating..." login diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 65c093ba..c9ed3794 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -5,6 +5,7 @@ import { ValidationService } from '../services/validation.service'; import { LoginCredentials } from '../types/command.types'; import { AuthService } from '../services/auth.service'; import { TokenStatus } from '@internxt/lib'; +import { ErrorUtils } from '../utils/errors.utils'; export default class Whoami extends Command { static readonly args = {}; @@ -39,8 +40,8 @@ export default class Whoami extends Command { userCredentials.user.keys.ecc.privateKey, ); await ConfigService.instance.saveUser(refreshedCreds); - } catch { - /* noop */ + } catch (error) { + if (ErrorUtils.isClientVersionUnsupported(error)) throw error; } } const message = `You are logged in as: ${userCredentials.user.email}.`; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index b05ed1ea..3ab13bfe 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -11,6 +11,7 @@ import { } from '../types/command.types'; import { ValidationService } from './validation.service'; import { TokenStatus } from '@internxt/lib'; +import { ErrorUtils } from '../utils/errors.utils'; export class AuthService { public static readonly instance: AuthService = new AuthService(); @@ -125,6 +126,11 @@ export class AuthService { return true; } + // The session is still valid, so updating shouldn't require logging in again + if (ErrorUtils.isClientVersionUnsupported(error)) { + return false; + } + const status = this.getHttpStatusFromError(error); if (status === undefined) { return false; diff --git a/src/utils/cli.utils.ts b/src/utils/cli.utils.ts index 1db8eb6c..5f24a521 100644 --- a/src/utils/cli.utils.ts +++ b/src/utils/cli.utils.ts @@ -3,7 +3,7 @@ import cliProgress from 'cli-progress'; import Table, { Header } from 'tty-table'; import { LoginCredentials, LoginUserDetails, NotValidFolderUuidError, PromptOptions } from '../types/command.types'; import { InquirerUtils } from './inquirer.utils'; -import { ErrorUtils } from './errors.utils'; +import { CLIENT_VERSION_UNSUPPORTED, ErrorUtils } from './errors.utils'; import { ValidationService } from '../services/validation.service'; import { SdkManager } from '../services/sdk-manager.service'; import { Environment } from '@internxt/inxt-js'; @@ -247,6 +247,21 @@ export class CLIUtils { return megabytes / seconds; }; + private static readonly getErrorMessage = (error: Error | AppError | AxiosResponseError): string => { + let message = ''; + + if ('message' in error && typeof error.message === 'string' && error.message?.trim?.().length > 0) { + message = error.message; + } + + const apiMessage = ErrorUtils.getResponseMessage(error); + if (apiMessage) { + message += ' [' + apiMessage + ']'; + } + + return message.length > 0 ? message : JSON.stringify(error); + }; + static readonly catchError = ({ error, logReporter, @@ -260,29 +275,15 @@ export class CLIUtils { jsonFlag?: boolean; debugMode?: boolean; }) => { - let message = ''; const requestId = ErrorUtils.getRequestId(error); - - if ('message' in error && typeof error.message === 'string' && error.message?.trim?.().length > 0) { - message = error.message; - } - - if ('data' in error) { - const errorData = error.data as { message?: string | Array }; - if (typeof errorData.message === 'string' && errorData.message?.trim?.().length > 0) { - message += ' [' + errorData.message + ']'; - } else if (Array.isArray(errorData.message) && errorData.message.length > 0) { - message += ' [' + errorData.message.join(', ') + ']'; - } - } - - if (message.length === 0) { - message = JSON.stringify(error); - } + const versionUnsupported = ErrorUtils.isClientVersionUnsupported(error); + const message = versionUnsupported + ? ErrorUtils.toClientVersionUnsupportedError(error).message + : CLIUtils.getErrorMessage(error); CLIUtils.failed(jsonFlag); - const jsonLog: { success: boolean; message: string; command?: string; requestId?: string } = { + const jsonLog: { success: boolean; message: string; command?: string; requestId?: string; code?: string } = { success: false, message, command, @@ -290,6 +291,9 @@ export class CLIUtils { if (requestId) { jsonLog.requestId = requestId; } + if (versionUnsupported) { + jsonLog.code = CLIENT_VERSION_UNSUPPORTED; + } if (command) { jsonLog.command = command; } diff --git a/src/utils/errors.utils.ts b/src/utils/errors.utils.ts index 20960a4a..d8ad674a 100644 --- a/src/utils/errors.utils.ts +++ b/src/utils/errors.utils.ts @@ -1,11 +1,14 @@ import { Logger } from 'winston'; import { logger } from './logger.utils'; import { DriveItemType } from '../types/drive.types'; +import { UpdateUtils } from './update.utils'; export type LookupErrorKind = 'not-found' | 'auth' | 'inconclusive'; export const DEFAULT_RETRY_AFTER_SECONDS = 5; +export const CLIENT_VERSION_UNSUPPORTED = 'CLIENT_VERSION_UNSUPPORTED'; + export class ErrorUtils { static readonly isError = (error: unknown): error is Error => { return typeof Error.isError === 'function' @@ -72,6 +75,34 @@ export class ErrorUtils { return typeof error === 'object' && error !== null && 'data' in error; }; + private static readonly getResponseBody = (error: unknown): Record | undefined => { + if (!this.hasResponseBody(error)) return undefined; + const data = (error as { data: unknown }).data; + return typeof data === 'object' && data !== null ? (data as Record) : undefined; + }; + + static readonly getResponseMessage = (error: unknown): string | undefined => { + const message = this.getResponseBody(error)?.message; + if (typeof message === 'string' && message.trim().length > 0) return message; + if (Array.isArray(message) && message.length > 0) return message.join(', '); + return undefined; + }; + + static readonly isClientVersionUnsupported = (error: unknown): boolean => { + if (error instanceof ClientVersionUnsupportedError) return true; + return this.getResponseBody(error)?.errorCode === CLIENT_VERSION_UNSUPPORTED; + }; + + static readonly toClientVersionUnsupportedError = (error: unknown): ClientVersionUnsupportedError => { + if (error instanceof ClientVersionUnsupportedError) return error; + + const recommendedVersion = this.getResponseBody(error)?.recommendedVersion; + return new ClientVersionUnsupportedError( + this.getResponseMessage(error), + typeof recommendedVersion === 'string' ? recommendedVersion : undefined, + ); + }; + static readonly classifyLookupError = (error: unknown): LookupErrorKind => { const status = this.getStatusCode(error); @@ -88,6 +119,8 @@ export class ErrorUtils { /** Maps a failed path lookup to the error the WebDAV client should see, so only a * confirmed absence surfaces as 404. */ static readonly toLookupError = (itemType: DriveItemType, path: string, error: unknown): Error => { + if (this.isClientVersionUnsupported(error)) return this.toClientVersionUnsupportedError(error); + const label = itemType === 'file' ? 'File' : 'Folder'; switch (this.classifyLookupError(error)) { case 'auth': @@ -182,6 +215,23 @@ export class BadGatewayError extends Error { } } +export class ClientVersionUnsupportedError extends Error { + public statusCode = 403; + + constructor(apiMessage?: string, recommendedVersion?: string) { + const recommended = recommendedVersion ? `, recommended version: ${recommendedVersion}` : ''; + super( + [ + apiMessage ?? 'This version of the Internxt CLI is no longer supported.', + `Installed version: ${UpdateUtils.getCurrentVersion()}${recommended}.`, + UpdateUtils.getUpdateInstructions(), + ].join(' '), + ); + this.name = 'ClientVersionUnsupportedError'; + Object.setPrototypeOf(this, ClientVersionUnsupportedError.prototype); + } +} + export class BadRequestError extends Error { public statusCode = 400; diff --git a/src/utils/update.utils.ts b/src/utils/update.utils.ts new file mode 100644 index 00000000..8354465f --- /dev/null +++ b/src/utils/update.utils.ts @@ -0,0 +1,13 @@ +import packageJson from '../../package.json'; + +export class UpdateUtils { + static readonly isDockerRuntime = (): boolean => process.env.INXT_RUNTIME === 'docker'; + + static readonly getCurrentVersion = (): string => packageJson.version; + + static readonly getUpdateInstructions = (): string => { + return this.isDockerRuntime() + ? 'Pull the latest image with: docker pull internxt/webdav:latest' + : 'Update with: npm install -g @internxt/cli@latest'; + }; +} diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index 3d02e65b..80455081 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -113,6 +113,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { try { file = await DriveFileService.instance.replaceFile(driveFileItem.uuid, filePayload); } catch (error) { + if (ErrorUtils.isClientVersionUnsupported(error)) throw error; webdavLogger.warn( ErrorUtils.withRequestId( `[PUT] File replace failed for '${resource.url}', falling back to delete and create: ${ diff --git a/src/webdav/middewares/auth.middleware.ts b/src/webdav/middewares/auth.middleware.ts index 9ad1c9ba..5c16a49a 100644 --- a/src/webdav/middewares/auth.middleware.ts +++ b/src/webdav/middewares/auth.middleware.ts @@ -23,6 +23,11 @@ export const AuthMiddleware = (): RequestHandler => { CacheService.instance.set(CacheService.AUTH_CACHE_KEY, authDetails); next(); } catch (error) { + if (ErrorUtils.isClientVersionUnsupported(error)) { + next(error); + return; + } + let message = 'Authentication required to access this resource.'; if (ErrorUtils.isError(error)) { message = error.message; diff --git a/src/webdav/middewares/client-version.middleware.ts b/src/webdav/middewares/client-version.middleware.ts new file mode 100644 index 00000000..5fef163d --- /dev/null +++ b/src/webdav/middewares/client-version.middleware.ts @@ -0,0 +1,44 @@ +import { RequestHandler } from 'express'; +import { ClientVersionUnsupportedError, ErrorUtils } from '../../utils/errors.utils'; +import { webdavLogger } from '../../utils/logger.utils'; +import { UpdateUtils } from '../../utils/update.utils'; + +// Expires so a block lifted by the backend doesn't need a manual restart +export const BLOCK_RECHECK_MS = 5 * 60 * 1000; + +export class ClientVersionGate { + private static blockedError?: ClientVersionUnsupportedError; + private static blockedAt = 0; + + static readonly getBlockedError = (): ClientVersionUnsupportedError | undefined => { + if (this.blockedError && Date.now() - this.blockedAt >= BLOCK_RECHECK_MS) { + this.reset(); + } + return this.blockedError; + }; + + static readonly block = (error: unknown): ClientVersionUnsupportedError => { + const current = this.getBlockedError(); + if (current) return current; + + this.blockedError = ErrorUtils.toClientVersionUnsupportedError(error); + this.blockedAt = Date.now(); + const restartHint = UpdateUtils.isDockerRuntime() + ? '' + : ' After updating, restart the server with: internxt webdav restart.'; + webdavLogger.error( + `[CLIENT VERSION] ${this.blockedError.message}${restartHint} ` + + 'Requests are refused without reaching the API, re-checking every 5 minutes.', + ); + return this.blockedError; + }; + + static readonly reset = () => { + this.blockedError = undefined; + this.blockedAt = 0; + }; +} + +export const ClientVersionMiddleware: RequestHandler = (_req, _res, next) => { + next(ClientVersionGate.getBlockedError()); +}; diff --git a/src/webdav/middewares/errors.middleware.ts b/src/webdav/middewares/errors.middleware.ts index c2ef6da2..952a12c8 100644 --- a/src/webdav/middewares/errors.middleware.ts +++ b/src/webdav/middewares/errors.middleware.ts @@ -2,40 +2,25 @@ import { ErrorRequestHandler } from 'express'; import { webdavLogger } from '../../utils/logger.utils'; import { XMLUtils } from '../../utils/xml.utils'; import { ErrorUtils } from '../../utils/errors.utils'; - -/** - * SDK errors (AxiosResponseError/AxiosUnknownError) carry the upstream API's response - * body in `data`, which is discarded unless we read it explicitly. - */ -const getErrorDetail = (err: unknown): string | undefined => { - if (typeof err !== 'object' || err === null || !('data' in err)) return undefined; - - const data = (err as { data?: unknown }).data; - if (typeof data !== 'object' || data === null || !('message' in data)) return undefined; - - const message = (data as { message?: unknown }).message; - if (typeof message === 'string' && message.trim().length > 0) return message; - if (Array.isArray(message) && message.length > 0) return message.join(', '); - return undefined; -}; +import { ClientVersionGate } from './client-version.middleware'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -export const ErrorHandlingMiddleware: ErrorRequestHandler = (err, req, res, _) => { +export const ErrorHandlingMiddleware: ErrorRequestHandler = (rawErr, req, res, _) => { + const versionUnsupported = ErrorUtils.isClientVersionUnsupported(rawErr); + const err = versionUnsupported ? ClientVersionGate.block(rawErr) : rawErr; + let message = ErrorUtils.isError(err) ? err.message : 'Something went wrong'; - const detail = getErrorDetail(err); + const detail = ErrorUtils.getResponseMessage(err); if (detail) { message += ` [${detail}]`; } - const logMessage = ErrorUtils.withRequestId(message, err); + const logMessage = ErrorUtils.withRequestId(message, rawErr); - if (ErrorUtils.isError(err) && err.stack) { - webdavLogger.error( - `[ERROR MIDDLEWARE] [${req.method.toUpperCase()} - ${req.url}] ${logMessage}\nStack: ${err.stack}`, - ); - } else { - webdavLogger.error(`[ERROR MIDDLEWARE] [${req.method.toUpperCase()} - ${req.url}] ${logMessage}`); + if (!versionUnsupported) { + const stack = ErrorUtils.isError(err) && err.stack ? `\nStack: ${err.stack}` : ''; + webdavLogger.error(`[ERROR MIDDLEWARE] [${req.method.toUpperCase()} - ${req.url}] ${logMessage}${stack}`); } const errorBodyXML = XMLUtils.toWebDavXML( diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 598220f9..3290743e 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -25,6 +25,7 @@ import { UNLOCKRequestHandler } from './handlers/UNLOCK.handler'; import { MkcolMiddleware } from './middewares/mkcol.middleware'; import { WebdavConfig } from '../types/command.types'; import { WebDAVAuthMiddleware } from './middewares/webdav-auth.middleware'; +import { ClientVersionMiddleware } from './middewares/client-version.middleware'; export class WebDavServer { constructor(private readonly app: Express) {} @@ -38,6 +39,7 @@ export class WebDavServer { }), ); this.app.use(WebDAVAuthMiddleware(configs)); + this.app.use(ClientVersionMiddleware); this.app.use(AuthMiddleware()); this.app.use(bodyParser.text({ type: ['application/xml', 'text/xml'] })); this.app.use(MkcolMiddleware); diff --git a/test/commands/whoami.test.ts b/test/commands/whoami.test.ts index e15619c8..af2c7d74 100644 --- a/test/commands/whoami.test.ts +++ b/test/commands/whoami.test.ts @@ -4,6 +4,9 @@ import { ConfigService } from '../../src/services/config.service'; import { UserCredentialsFixture } from '../fixtures/login.fixture'; import Whoami from '../../src/commands/whoami'; import { ValidationService } from '../../src/services/validation.service'; +import { AuthService } from '../../src/services/auth.service'; +import { CLIUtils } from '../../src/utils/cli.utils'; +import { newClientVersionUnsupportedError } from '../fixtures/errors.fixture'; describe('Whoami Command', () => { test('When user is logged out, then it returns false', async () => { @@ -65,4 +68,32 @@ describe('Whoami Command', () => { expect(validateTokensSpy).toHaveBeenCalledOnce(); expect(validateMnemonicSpy).toHaveBeenCalledOnce(); }); + + test('When the token refresh is blocked for the CLI version, then it fails with the update message', async () => { + vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture); + vi.spyOn(ValidationService.instance, 'validateTokenAndCheckExpiration').mockReturnValueOnce( + TokenStatus.REFRESH_REQUIRED, + ); + vi.spyOn(ValidationService.instance, 'validateMnemonic').mockReturnValue(true); + const versionError = newClientVersionUnsupportedError(); + vi.spyOn(AuthService.instance, 'refreshUserToken').mockRejectedValue(versionError); + const catchErrorSpy = vi.spyOn(CLIUtils, 'catchError').mockImplementation(() => {}); + + await expect(Whoami.run()).rejects.toThrow('EEXIT: 1'); + + expect(catchErrorSpy).toHaveBeenCalledWith(expect.objectContaining({ error: versionError })); + }); + + test('When the token refresh fails for another reason, then the user is still reported as logged in', async () => { + vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture); + vi.spyOn(ValidationService.instance, 'validateTokenAndCheckExpiration').mockReturnValueOnce( + TokenStatus.REFRESH_REQUIRED, + ); + vi.spyOn(ValidationService.instance, 'validateMnemonic').mockReturnValue(true); + vi.spyOn(AuthService.instance, 'refreshUserToken').mockRejectedValue(new Error('socket hang up')); + + const result = await Whoami.run(); + + expect(result).toMatchObject({ success: true }); + }); }); diff --git a/test/fixtures/errors.fixture.ts b/test/fixtures/errors.fixture.ts index 2d6cb1a0..0d0fb4ad 100644 --- a/test/fixtures/errors.fixture.ts +++ b/test/fixtures/errors.fixture.ts @@ -1,5 +1,6 @@ import { AxiosResponseError, AxiosUnknownError } from '@internxt/sdk/dist/shared/types/errors'; import { AxiosError } from 'axios'; +import { CLIENT_VERSION_UNSUPPORTED } from '../../src/utils/errors.utils'; /** An error as @internxt/sdk's HttpClient raises it from a real API response. */ export const newApiError = ( @@ -18,3 +19,17 @@ export const newApiError = ( /** The SDK's error when no response arrived; it invents a status -- 500 if sent, 400 if not. */ export const newNetworkError = ({ sent = true } = {}) => new AxiosUnknownError('socket hang up', 'GET files/meta', { request: sent ? {} : undefined } as AxiosError); + +export const newClientVersionUnsupportedError = ({ + message = 'This version of the app is no longer supported. Please update to a newer version.', + recommendedVersion = '1.7.0' as string | null, +} = {}) => + newApiError(403, { + data: { + message, + error: 'Forbidden', + statusCode: 403, + errorCode: CLIENT_VERSION_UNSUPPORTED, + recommendedVersion, + }, + }); diff --git a/test/services/auth.service.test.ts b/test/services/auth.service.test.ts index f998331c..1dc46f88 100644 --- a/test/services/auth.service.test.ts +++ b/test/services/auth.service.test.ts @@ -18,6 +18,7 @@ import { UserCredentialsFixture } from '../fixtures/login.fixture'; import { fail } from 'node:assert'; import { paths } from '@internxt/sdk/dist/schema'; import { CacheService } from '../../src/services/cache.service'; +import { newClientVersionUnsupportedError } from '../fixtures/errors.fixture'; describe('Auth service', () => { beforeEach(() => { @@ -260,4 +261,20 @@ describe('Auth service', () => { expect(refreshTokenStub).toHaveBeenCalledOnce(); expect(clearUserStub).not.toHaveBeenCalled(); }); + + test('when the token refresh is blocked for the CLI version, then stored credentials are preserved', async () => { + const sut = AuthService.instance; + const versionError = newClientVersionUnsupportedError(); + + vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture); + vi.spyOn(ValidationService.instance, 'validateTokenAndCheckExpiration').mockImplementationOnce( + () => TokenStatus.REFRESH_REQUIRED, + ); + vi.spyOn(ValidationService.instance, 'validateMnemonic').mockReturnValue(true); + vi.spyOn(sut, 'refreshUserToken').mockRejectedValue(versionError); + const clearUserStub = vi.spyOn(ConfigService.instance, 'clearUser').mockResolvedValue(); + + await expect(() => sut.getAuthDetails()).rejects.toThrow(versionError); + expect(clearUserStub).not.toHaveBeenCalled(); + }); }); diff --git a/test/utils/cli.utils.test.ts b/test/utils/cli.utils.test.ts index 1a837531..14b4df33 100644 --- a/test/utils/cli.utils.test.ts +++ b/test/utils/cli.utils.test.ts @@ -11,6 +11,8 @@ import { Environment } from '@internxt/inxt-js'; import { UserFixture } from '../fixtures/auth.fixture'; import { getNetworkOptionsMock } from '../fixtures/webdav.fixture'; import { NetworkOptions } from '../../src/types/network.types'; +import { ErrorUtils } from '../../src/utils/errors.utils'; +import { newApiError, newClientVersionUnsupportedError } from '../fixtures/errors.fixture'; vi.mock('ux', () => { return { @@ -311,4 +313,45 @@ describe('CliUtils', () => { expect(throughput).toBe(0); }); }); + + describe('catchError', () => { + test('when the API blocks the CLI version, then the update message is shown instead of the raw API error', () => { + const reporter = vi.fn(); + const error = newClientVersionUnsupportedError(); + + CLIUtils.catchError({ error, logReporter: reporter, command: 'list' }); + + expect(reporter).toHaveBeenCalledOnce(); + const output = reporter.mock.calls[0][0] as string; + expect(output).toContain(ErrorUtils.toClientVersionUnsupportedError(error).message); + expect(output).not.toContain('Request failed with status code 403'); + }); + + test('when the API blocks the CLI version with --json, then the output carries the error code', () => { + const consoleLogSpy = vi.spyOn(CLIUtils, 'consoleLog').mockImplementation(() => {}); + const error = newClientVersionUnsupportedError(); + + CLIUtils.catchError({ error, logReporter: vi.fn(), command: 'list', jsonFlag: true }); + + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ + success: false, + message: ErrorUtils.toClientVersionUnsupportedError(error).message, + command: 'list', + code: 'CLIENT_VERSION_UNSUPPORTED', + }); + }); + + test('when a regular API error happens, then the message includes the API detail and no error code', () => { + const consoleLogSpy = vi.spyOn(CLIUtils, 'consoleLog').mockImplementation(() => {}); + const error = newApiError(403, { data: { message: 'Not your folder' } }); + + CLIUtils.catchError({ error, logReporter: vi.fn(), command: 'list', jsonFlag: true }); + + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ + success: false, + message: 'Request failed with status code 403 [Not your folder]', + command: 'list', + }); + }); + }); }); diff --git a/test/utils/errors.utils.test.ts b/test/utils/errors.utils.test.ts index ef8c2a2f..a7c8a805 100644 --- a/test/utils/errors.utils.test.ts +++ b/test/utils/errors.utils.test.ts @@ -1,12 +1,14 @@ -import { describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { BadGatewayError, + ClientVersionUnsupportedError, ErrorUtils, NotFoundError, NotImplementedError, ServiceUnavailableError, } from '../../src/utils/errors.utils'; -import { newApiError, newNetworkError } from '../fixtures/errors.fixture'; +import { newApiError, newClientVersionUnsupportedError, newNetworkError } from '../fixtures/errors.fixture'; +import { UpdateUtils } from '../../src/utils/update.utils'; import { logger } from '../../src/utils/logger.utils'; describe('Errors Utils', () => { @@ -179,7 +181,7 @@ describe('Errors Utils', () => { const error = ErrorUtils.toLookupError('file', '/a.txt', newApiError(401)); expect(error).toBeInstanceOf(BadGatewayError); - expect(error.message).toContain('log in again with \'internxt login\''); + expect(error.message).toContain("log in again with 'internxt login'"); }); test('when the lookup is inconclusive, then a service unavailable error is returned', () => { @@ -248,4 +250,77 @@ describe('Errors Utils', () => { expect.stringContaining('[REPORTED_ERROR]: Test Error (requestId: req-123)'), ); }); + + describe('isClientVersionUnsupported', () => { + test('when the API blocks the CLI version with 403 and its error code, then it is detected', () => { + expect(ErrorUtils.isClientVersionUnsupported(newClientVersionUnsupportedError())).toBe(true); + }); + + test('when the error code comes with another status, then it is still detected', () => { + const error = newApiError(426, { data: { errorCode: 'CLIENT_VERSION_UNSUPPORTED' } }); + expect(ErrorUtils.isClientVersionUnsupported(error)).toBe(true); + }); + + test('when the status matches but the error code does not, then it is not detected', () => { + expect(ErrorUtils.isClientVersionUnsupported(newApiError(403))).toBe(false); + expect(ErrorUtils.isClientVersionUnsupported(newApiError(426))).toBe(false); + expect(ErrorUtils.isClientVersionUnsupported(newApiError(403, { data: { errorCode: 'OTHER' } }))).toBe(false); + }); + + test('when the error is already a ClientVersionUnsupportedError, then it is detected', () => { + expect(ErrorUtils.isClientVersionUnsupported(new ClientVersionUnsupportedError())).toBe(true); + }); + + test('when the error is not an API error, then it is not detected', () => { + expect(ErrorUtils.isClientVersionUnsupported(new Error('boom'))).toBe(false); + expect(ErrorUtils.isClientVersionUnsupported(undefined)).toBe(false); + }); + }); + + describe('toClientVersionUnsupportedError', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('when the API sends a message and recommended version, then both are shown with npm instructions', () => { + vi.stubEnv('INXT_RUNTIME', ''); + const error = ErrorUtils.toClientVersionUnsupportedError(newClientVersionUnsupportedError()); + + expect(error).toBeInstanceOf(ClientVersionUnsupportedError); + expect(error.statusCode).toBe(403); + expect(error.message).toBe( + 'This version of the app is no longer supported. Please update to a newer version. ' + + `Installed version: ${UpdateUtils.getCurrentVersion()}, recommended version: 1.7.0. ` + + 'Update with: npm install -g @internxt/cli@latest', + ); + }); + + test('when running in Docker, then the instructions say to pull the latest image', () => { + vi.stubEnv('INXT_RUNTIME', 'docker'); + const error = ErrorUtils.toClientVersionUnsupportedError(newClientVersionUnsupportedError()); + + expect(error.message).toContain('Pull the latest image with: docker pull internxt/webdav:latest'); + expect(error.message).not.toContain('npm install'); + }); + + test('when the API sends no message or recommended version, then a default message is used', () => { + vi.stubEnv('INXT_RUNTIME', ''); + const error = ErrorUtils.toClientVersionUnsupportedError( + newApiError(403, { data: { errorCode: 'CLIENT_VERSION_UNSUPPORTED' } }), + ); + + expect(error.message).toBe( + 'This version of the Internxt CLI is no longer supported. ' + + `Installed version: ${UpdateUtils.getCurrentVersion()}. ` + + 'Update with: npm install -g @internxt/cli@latest', + ); + }); + }); + + test('when a path lookup is blocked for the CLI version, then the version error is returned instead of an auth error', () => { + const error = ErrorUtils.toLookupError('file', '/a.txt', newClientVersionUnsupportedError()); + + expect(error).toBeInstanceOf(ClientVersionUnsupportedError); + expect(error).not.toBeInstanceOf(BadGatewayError); + }); }); diff --git a/test/webdav/handlers/PUT.handler.test.ts b/test/webdav/handlers/PUT.handler.test.ts index cad2c12c..456db80d 100644 --- a/test/webdav/handlers/PUT.handler.test.ts +++ b/test/webdav/handlers/PUT.handler.test.ts @@ -15,6 +15,7 @@ import { WebDavRequestedResource } from '../../../src/types/webdav.types'; import { WebDavUtils } from '../../../src/utils/webdav.utils'; import { newFileItem, newFolderItem } from '../../fixtures/drive.fixture'; import { UserCredentialsFixture } from '../../fixtures/login.fixture'; +import { newClientVersionUnsupportedError } from '../../fixtures/errors.fixture'; import { CLIUtils } from '../../../src/utils/cli.utils'; import { UsageService } from '../../../src/services/usage.service'; import { webdavLogger } from '../../../src/utils/logger.utils'; @@ -254,4 +255,38 @@ describe('PUT request handler', () => { expect(deleteDriveFileStub).toHaveBeenCalledWith(fileFixture); expect(createDriveFileStub).toHaveBeenCalledOnce(); }); + + test('when replacing an existing file is blocked for the CLI version, then the existing file is left untouched', async () => { + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const requestedParentFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: '', + }); + const folderFixture = newFolderItem({ name: requestedParentFolderResource.name }); + const fileFixture = newFileItem({ folderUuid: folderFixture.uuid, size: 0, fileId: undefined }); + const request = createWebDavRequestFixture({ + method: 'PUT', + url: requestedFileResource.url, + headers: { 'content-length': '0' }, + }); + const response = createWebDavResponseFixture({ + status: vi.fn().mockReturnValue({ send: vi.fn() }), + }); + const versionError = newClientVersionUnsupportedError(); + + vi.spyOn(WebDavUtils, 'getRequestedResource') + .mockResolvedValueOnce(requestedFileResource) + .mockResolvedValueOnce(requestedParentFolderResource); + vi.spyOn(WebDavUtils, 'getDriveItemFromResource').mockResolvedValueOnce(fileFixture); + vi.spyOn(WebDavUtils, 'getDriveFolderFromResource').mockResolvedValue(folderFixture); + vi.spyOn(AuthService.instance, 'getAuthDetails').mockResolvedValue(UserCredentialsFixture); + const deleteDriveFileStub = vi.spyOn(WebDavUtils, 'deleteOrTrashItem').mockResolvedValue(); + vi.spyOn(DriveFileService.instance, 'replaceFile').mockRejectedValue(versionError); + const createDriveFileStub = vi.spyOn(DriveFileService.instance, 'createFile').mockResolvedValue(fileFixture); + + await expect(sut.handle(request, response)).rejects.toBe(versionError); + + expect(deleteDriveFileStub).not.toHaveBeenCalled(); + expect(createDriveFileStub).not.toHaveBeenCalled(); + }); }); diff --git a/test/webdav/middlewares/auth.middleware.test.ts b/test/webdav/middlewares/auth.middleware.test.ts index d2a76ce3..f1645e8b 100644 --- a/test/webdav/middlewares/auth.middleware.test.ts +++ b/test/webdav/middlewares/auth.middleware.test.ts @@ -6,6 +6,7 @@ import { AuthService } from '../../../src/services/auth.service'; import { CacheService } from '../../../src/services/cache.service'; import { MissingCredentialsError } from '../../../src/types/command.types'; import { XMLUtils } from '../../../src/utils/xml.utils'; +import { newClientVersionUnsupportedError } from '../../fixtures/errors.fixture'; describe('Auth middleware', () => { beforeEach(() => { @@ -72,4 +73,17 @@ describe('Auth middleware', () => { expect(res.status).not.toHaveBeenCalled(); expect(res.send).not.toHaveBeenCalled(); }); + + test('when the API blocks the CLI version, then the error is passed on instead of asking for credentials', async () => { + const req = createWebDavRequestFixture({}); + const res = createWebDavResponseFixture({}); + const next = vi.fn(); + const versionError = newClientVersionUnsupportedError(); + vi.spyOn(AuthService.instance, 'getAuthDetails').mockRejectedValue(versionError); + + await AuthMiddleware()(req, res, next); + + expect(next).toHaveBeenCalledWith(versionError); + expect(res.status).not.toHaveBeenCalled(); + }); }); diff --git a/test/webdav/middlewares/client-version.middleware.test.ts b/test/webdav/middlewares/client-version.middleware.test.ts new file mode 100644 index 00000000..421a83ed --- /dev/null +++ b/test/webdav/middlewares/client-version.middleware.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + BLOCK_RECHECK_MS, + ClientVersionGate, + ClientVersionMiddleware, +} from '../../../src/webdav/middewares/client-version.middleware'; +import { ErrorHandlingMiddleware } from '../../../src/webdav/middewares/errors.middleware'; +import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { newApiError, newClientVersionUnsupportedError } from '../../fixtures/errors.fixture'; +import { ClientVersionUnsupportedError } from '../../../src/utils/errors.utils'; +import { webdavLogger } from '../../../src/utils/logger.utils'; +import { XMLUtils } from '../../../src/utils/xml.utils'; + +describe('Client version middleware', () => { + beforeEach(() => { + ClientVersionGate.reset(); + }); + + test('when the CLI version has not been blocked, then the request continues', () => { + const next = vi.fn(); + + ClientVersionMiddleware(createWebDavRequestFixture({}), createWebDavResponseFixture({}), next); + + expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(undefined); + }); + + test('when the API has blocked the CLI version, then later requests are refused without reaching the API', () => { + ClientVersionGate.block(newClientVersionUnsupportedError()); + const next = vi.fn(); + + ClientVersionMiddleware(createWebDavRequestFixture({}), createWebDavResponseFixture({}), next); + + expect(next).toHaveBeenCalledWith(expect.any(ClientVersionUnsupportedError)); + }); + + test('when the version is blocked several times, then the clear error is logged only once', () => { + ClientVersionGate.block(newClientVersionUnsupportedError()); + ClientVersionGate.block(newClientVersionUnsupportedError()); + + expect(webdavLogger.error).toHaveBeenCalledOnce(); + expect(webdavLogger.error).toHaveBeenCalledWith(expect.stringContaining('[CLIENT VERSION]')); + }); + + describe('re-check after a block', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test('when less than 5 minutes have passed, then requests are still refused', () => { + vi.useFakeTimers(); + ClientVersionGate.block(newClientVersionUnsupportedError()); + + vi.advanceTimersByTime(BLOCK_RECHECK_MS - 1); + + expect(ClientVersionGate.getBlockedError()).toBeInstanceOf(ClientVersionUnsupportedError); + }); + + test('when 5 minutes have passed, then the next request reaches the API again', () => { + vi.useFakeTimers(); + ClientVersionGate.block(newClientVersionUnsupportedError()); + + vi.advanceTimersByTime(BLOCK_RECHECK_MS); + const next = vi.fn(); + ClientVersionMiddleware(createWebDavRequestFixture({}), createWebDavResponseFixture({}), next); + + expect(next).toHaveBeenCalledWith(undefined); + }); + + test('when the API still blocks after the re-check, then the block is set and logged again', () => { + vi.useFakeTimers(); + ClientVersionGate.block(newClientVersionUnsupportedError()); + vi.advanceTimersByTime(BLOCK_RECHECK_MS); + + ClientVersionGate.block(newClientVersionUnsupportedError()); + + expect(ClientVersionGate.getBlockedError()).toBeInstanceOf(ClientVersionUnsupportedError); + expect(webdavLogger.error).toHaveBeenCalledTimes(2); + }); + }); + + describe('restart hint', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('when installed with npm, then the log says to restart the WebDAV server after updating', () => { + vi.stubEnv('INXT_RUNTIME', ''); + ClientVersionGate.block(newClientVersionUnsupportedError()); + + expect(webdavLogger.error).toHaveBeenCalledWith(expect.stringContaining('internxt webdav restart')); + }); + + test('when running in Docker, then the log does not mention restarting the server', () => { + vi.stubEnv('INXT_RUNTIME', 'docker'); + ClientVersionGate.block(newClientVersionUnsupportedError()); + + expect(webdavLogger.error).toHaveBeenCalledWith(expect.not.stringContaining('internxt webdav restart')); + }); + }); + + describe('with the error handling middleware', () => { + test('when the API blocks the CLI version, then the client gets 403 with the update message and the gate closes', () => { + const res = createWebDavResponseFixture({ + status: vi.fn().mockReturnValue({ send: vi.fn() }), + }); + const req = createWebDavRequestFixture({ method: 'PROPFIND', url: '/folder/' }); + + ErrorHandlingMiddleware(newClientVersionUnsupportedError(), req, res, () => {}); + + const blockedError = ClientVersionGate.getBlockedError(); + expect(blockedError).toBeInstanceOf(ClientVersionUnsupportedError); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith( + XMLUtils.toWebDavXML( + { [XMLUtils.addDefaultNamespace('responsedescription')]: blockedError?.message }, + {}, + 'error', + ), + ); + }); + + test('when the API answers a regular 403, then the gate stays open and the client gets 502 as before', () => { + const res = createWebDavResponseFixture({ + status: vi.fn().mockReturnValue({ send: vi.fn() }), + }); + const req = createWebDavRequestFixture({ method: 'PROPFIND', url: '/folder/' }); + + ErrorHandlingMiddleware(newApiError(403, { data: {} }), req, res, () => {}); + + expect(ClientVersionGate.getBlockedError()).toBeUndefined(); + expect(res.status).toHaveBeenCalledWith(502); + }); + }); +}); From 693fc3da8d6d532d3858d9a7f2f98f99279fda99 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:26:50 +0100 Subject: [PATCH 2/4] feat: Enhance error handling for unsupported client versions - Updated `AuthService` to throw specific errors for unsupported client versions during security detail requests. - Modified `UniversalLinkService` to append a reason to the error page URL when login is blocked due to client version issues. - Added tests to verify correct behavior when handling unsupported client versions in both `AuthService` and `UniversalLinkService`. These changes improve error reporting and user guidance for CLI version compatibility. --- src/services/auth.service.ts | 1 + src/services/universal-link.service.ts | 4 +- test/services/auth.service.test.ts | 8 +++ test/services/universal-link.service.test.ts | 52 ++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/services/universal-link.service.test.ts diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 3ab13bfe..4fcbed2d 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -66,6 +66,7 @@ export class AuthService { public is2FANeeded = async (email: string): Promise => { const authClient = SdkManager.instance.getAuth(); const securityDetails = await authClient.securityDetails(email).catch((error) => { + if (ErrorUtils.isClientVersionUnsupported(error)) throw error; throw new Error(error.message ?? 'Login error'); }); return securityDetails.tfaEnabled; diff --git a/src/services/universal-link.service.ts b/src/services/universal-link.service.ts index 2ee4febc..839230e3 100644 --- a/src/services/universal-link.service.ts +++ b/src/services/universal-link.service.ts @@ -5,6 +5,7 @@ import { LoginCredentials } from '../types/command.types'; import { ConfigService } from './config.service'; import { AuthService } from './auth.service'; import { CLIUtils, LogReporter } from '../utils/cli.utils'; +import { CLIENT_VERSION_UNSUPPORTED, ErrorUtils } from '../utils/errors.utils'; export class UniversalLinkService { public static readonly instance: UniversalLinkService = new UniversalLinkService(); @@ -71,8 +72,9 @@ export class UniversalLinkService { resolve(loginCredentials); } catch (error) { + const reason = ErrorUtils.isClientVersionUnsupported(error) ? `?reason=${CLIENT_VERSION_UNSUPPORTED}` : ''; res.writeHead(302, { - Location: `${driveUrl}/auth-link-error`, + Location: `${driveUrl}/auth-link-error${reason}`, }); res.end(); reject(error); diff --git a/test/services/auth.service.test.ts b/test/services/auth.service.test.ts index 1dc46f88..0f5e5284 100644 --- a/test/services/auth.service.test.ts +++ b/test/services/auth.service.test.ts @@ -102,6 +102,14 @@ describe('Auth service', () => { expect(securityStub).toHaveBeenCalledOnce(); }); + test('when the security details request is blocked for the CLI version, then the API error is kept', async () => { + const versionError = newClientVersionUnsupportedError(); + vi.spyOn(Auth.prototype, 'securityDetails').mockRejectedValue(versionError); + vi.spyOn(SdkManager.instance, 'getAuth').mockReturnValue(Auth.prototype); + + await expect(AuthService.instance.is2FANeeded('user@example.com')).rejects.toBe(versionError); + }); + test('when all stored credentials are complete and valid, then authentication details are returned', async () => { const sut = AuthService.instance; diff --git a/test/services/universal-link.service.test.ts b/test/services/universal-link.service.test.ts new file mode 100644 index 00000000..77aa0714 --- /dev/null +++ b/test/services/universal-link.service.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { UniversalLinkService } from '../../src/services/universal-link.service'; +import { AuthService } from '../../src/services/auth.service'; +import { ConfigService } from '../../src/services/config.service'; +import { CLIUtils } from '../../src/utils/cli.utils'; +import { newApiError, newClientVersionUnsupportedError } from '../fixtures/errors.fixture'; + +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); + +const DRIVE_WEB_URL = 'https://drive.example.com'; + +const runFailedCallback = async (refreshError: unknown) => { + vi.spyOn(AuthService.instance, 'refreshUserToken').mockRejectedValue(refreshError); + + let loginUrl = ''; + vi.spyOn(CLIUtils, 'consoleLog').mockImplementation((message: string) => { + loginUrl = message; + }); + + const loginPromise = UniversalLinkService.instance.loginSSO(false, vi.fn()); + const loginRejection = expect(loginPromise).rejects.toBe(refreshError); + await vi.waitFor(() => expect(loginUrl).not.toBe('')); + + const redirectUri = Buffer.from(new URL(loginUrl).searchParams.get('redirectUri') ?? '', 'base64').toString(); + const callbackUrl = new URL(redirectUri); + callbackUrl.searchParams.set('mnemonic', Buffer.from('mnemonic').toString('base64')); + callbackUrl.searchParams.set('newToken', Buffer.from('token').toString('base64')); + callbackUrl.searchParams.set('privateKey', Buffer.from('key').toString('base64')); + + const response = await fetch(callbackUrl, { redirect: 'manual' }); + await loginRejection; + return response.headers.get('location'); +}; + +describe('Universal link service', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(ConfigService.instance, 'get').mockReturnValue(DRIVE_WEB_URL); + }); + + test('when the login is blocked for the CLI version, then the browser is sent to the error page with the reason', async () => { + const location = await runFailedCallback(newClientVersionUnsupportedError()); + + expect(location).toBe(`${DRIVE_WEB_URL}/auth-link-error?reason=CLIENT_VERSION_UNSUPPORTED`); + }); + + test('when the login fails for another reason, then the browser is sent to the generic error page', async () => { + const location = await runFailedCallback(newApiError(401, { data: { message: 'Unauthorized' } })); + + expect(location).toBe(`${DRIVE_WEB_URL}/auth-link-error`); + }); +}); From 696270778e6e6226fb2cfddbb6bf7745d9f8e070 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:38:56 +0100 Subject: [PATCH 3/4] fix: Correct error message formatting in client version middleware --- src/webdav/middewares/client-version.middleware.ts | 2 +- test/webdav/middlewares/client-version.middleware.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/webdav/middewares/client-version.middleware.ts b/src/webdav/middewares/client-version.middleware.ts index 5fef163d..2e251895 100644 --- a/src/webdav/middewares/client-version.middleware.ts +++ b/src/webdav/middewares/client-version.middleware.ts @@ -27,7 +27,7 @@ export class ClientVersionGate { ? '' : ' After updating, restart the server with: internxt webdav restart.'; webdavLogger.error( - `[CLIENT VERSION] ${this.blockedError.message}${restartHint} ` + + `[CLIENT VERSION] ${this.blockedError.message}.${restartHint} ` + 'Requests are refused without reaching the API, re-checking every 5 minutes.', ); return this.blockedError; diff --git a/test/webdav/middlewares/client-version.middleware.test.ts b/test/webdav/middlewares/client-version.middleware.test.ts index 421a83ed..f468a421 100644 --- a/test/webdav/middlewares/client-version.middleware.test.ts +++ b/test/webdav/middlewares/client-version.middleware.test.ts @@ -89,6 +89,7 @@ describe('Client version middleware', () => { ClientVersionGate.block(newClientVersionUnsupportedError()); expect(webdavLogger.error).toHaveBeenCalledWith(expect.stringContaining('internxt webdav restart')); + expect(webdavLogger.error).toHaveBeenCalledWith(expect.stringContaining('@latest. After updating')); }); test('when running in Docker, then the log does not mention restarting the server', () => { @@ -96,6 +97,7 @@ describe('Client version middleware', () => { ClientVersionGate.block(newClientVersionUnsupportedError()); expect(webdavLogger.error).toHaveBeenCalledWith(expect.not.stringContaining('internxt webdav restart')); + expect(webdavLogger.error).toHaveBeenCalledWith(expect.stringContaining('webdav:latest. Requests are refused')); }); }); From ff2cf6638ac832cf9e675352f2d78ad15416e776 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:34:51 +0100 Subject: [PATCH 4/4] feat: Improve session management and error handling in entrypoint script - Added relogin functionality to handle session expiration more gracefully. - Enhanced error handling for unsupported client versions with clearer messaging. - Introduced new functions to manage version checks and auto-healing behavior. - Improved logging for WebDAV server status and re-enablement attempts. These changes enhance the robustness of the keepalive mechanism and provide better user feedback during session management. --- docker/entrypoint.sh | 68 ++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 30e10e47..47dc2a4d 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -18,8 +18,13 @@ session_alive() { [ -n "$WHOAMI_EMAIL" ] && [ "$WHOAMI_EMAIL" = "$INXT_USER_LOWER" ] } +relogin() { + LOGIN_OUTPUT=$(internxt login-legacy $LOGIN_ARGS --json 2>/dev/null) +} + +# $1: JSON output of a CLI command run with --json version_blocked() { - [ "$(echo "$WHOAMI_OUTPUT" | jq -r '.code // empty')" = "CLIENT_VERSION_UNSUPPORTED" ] + [ "$(echo "$1" | jq -r '.code // empty' 2>/dev/null)" = "CLIENT_VERSION_UNSUPPORTED" ] } webdav_online() { @@ -104,40 +109,59 @@ else AUTO_HEAL=false fi +# $1: failure reason, prefixed to the standard stop message +stop_if_no_autoheal() { + if [ "$AUTO_HEAL" != true ]; then + echo "[keepalive] ERROR: $1 and WEBDAV_KEEPALIVE_ENABLED=false (auto-renewal disabled). Stopping container so it can be restarted." + exit 1 + fi +} + +# $1: JSON output carrying the version error. The flag only dedupes the log; +# checks keep running so a lifted block self-heals. +handle_version_blocked() { + if [ "$VERSION_BLOCKED" = false ]; then + echo "[keepalive] ERROR: $(echo "$1" | jq -r '.message')" + VERSION_BLOCKED=true + fi + stop_if_no_autoheal "Client version unsupported" +} + VERSION_BLOCKED=false while true; do sleep "$KEEPALIVE_INTERVAL" - if [ "$VERSION_BLOCKED" = true ]; then - continue - fi - - if ! session_alive; then - if version_blocked; then - echo "[keepalive] ERROR: $(echo "$WHOAMI_OUTPUT" | jq -r '.message')" - VERSION_BLOCKED=true - continue - fi - if [ "$AUTO_HEAL" = true ]; then + if session_alive; then + VERSION_BLOCKED=false + elif version_blocked "$WHOAMI_OUTPUT"; then + handle_version_blocked "$WHOAMI_OUTPUT" + else + stop_if_no_autoheal "Session expired" + if [ "$VERSION_BLOCKED" = false ]; then echo "[keepalive] Session expired. Re-authenticating..." - login - webdav_enable - echo "[keepalive] Session restored." + fi + if relogin; then + VERSION_BLOCKED=false + if webdav_enable; then + echo "[keepalive] Session restored." + else + echo "[keepalive] ERROR: Logged in, but the WebDAV server failed to start. Retrying in ${KEEPALIVE_INTERVAL}s." + fi + elif version_blocked "$LOGIN_OUTPUT"; then + handle_version_blocked "$LOGIN_OUTPUT" else - echo "[keepalive] ERROR: Session expired and WEBDAV_KEEPALIVE_ENABLED=false (auto-renewal disabled). Stopping container so it can be restarted." - exit 1 + echo "[keepalive] ERROR: Re-authentication failed: $(echo "$LOGIN_OUTPUT" | jq -r '.message // empty' 2>/dev/null). Retrying in ${KEEPALIVE_INTERVAL}s." fi fi if ! webdav_online; then - if [ "$AUTO_HEAL" = true ]; then - echo "[keepalive] WebDAV server is not online. Re-enabling..." - webdav_enable + stop_if_no_autoheal "WebDAV server is not online" + echo "[keepalive] WebDAV server is not online. Re-enabling..." + if webdav_enable; then echo "[keepalive] WebDAV server re-enabled." else - echo "[keepalive] ERROR: WebDAV server is not online and WEBDAV_KEEPALIVE_ENABLED=false (auto-renewal disabled). Stopping container so it can be restarted." - exit 1 + echo "[keepalive] ERROR: Failed to re-enable the WebDAV server. Retrying in ${KEEPALIVE_INTERVAL}s." fi fi done