diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts new file mode 100644 index 000000000..a9c741536 --- /dev/null +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -0,0 +1,374 @@ +import { HTTPError } from 'got'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js'; +import { + CreateRCloneRemoteDto, + DeleteRCloneRemoteDto, + GetRCloneJobStatusDto, + GetRCloneRemoteConfigDto, + GetRCloneRemoteDetailsDto, + RCloneStartBackupInput, + UpdateRCloneRemoteDto, +} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; + +vi.mock('got'); +vi.mock('execa'); +vi.mock('p-retry'); +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})); +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + rm: vi.fn(), + writeFile: vi.fn(), +})); +vi.mock('@app/core/log.js', () => ({ + sanitizeParams: vi.fn((params) => params), +})); +vi.mock('@app/store/index.js', () => ({ + getters: { + paths: () => ({ + 'rclone-socket': '/tmp/rclone.sock', + 'log-base': '/var/log', + }), + }, +})); + +// Mock NestJS Logger to suppress logs during tests +vi.mock('@nestjs/common', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Logger: vi.fn(() => ({ + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + }; +}); + +describe('RCloneApiService', () => { + let service: RCloneApiService; + let mockGot: any; + let mockExeca: any; + let mockPRetry: any; + let mockExistsSync: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { default: got } = await import('got'); + const { execa } = await import('execa'); + const pRetry = await import('p-retry'); + const { existsSync } = await import('node:fs'); + + mockGot = vi.mocked(got); + mockExeca = vi.mocked(execa); + mockPRetry = vi.mocked(pRetry.default); + mockExistsSync = vi.mocked(existsSync); + + mockGot.post = vi.fn().mockResolvedValue({ body: {} }); + mockExeca.mockReturnValue({ + on: vi.fn(), + kill: vi.fn(), + killed: false, + pid: 12345, + } as any); + mockPRetry.mockResolvedValue(undefined); + mockExistsSync.mockReturnValue(false); + + service = new RCloneApiService(); + await service.onModuleInit(); + }); + + describe('getProviders', () => { + it('should return list of providers', async () => { + const mockProviders = [ + { name: 'aws', prefix: 's3', description: 'Amazon S3' }, + { name: 'google', prefix: 'drive', description: 'Google Drive' }, + ]; + mockGot.post.mockResolvedValue({ + body: { providers: mockProviders }, + }); + + const result = await service.getProviders(); + + expect(result).toEqual(mockProviders); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/providers', + expect.objectContaining({ + json: {}, + responseType: 'json', + enableUnixSockets: true, + }) + ); + }); + + it('should return empty array when no providers', async () => { + mockGot.post.mockResolvedValue({ body: {} }); + + const result = await service.getProviders(); + + expect(result).toEqual([]); + }); + }); + + describe('listRemotes', () => { + it('should return list of remotes', async () => { + const mockRemotes = ['backup-s3', 'drive-storage']; + mockGot.post.mockResolvedValue({ + body: { remotes: mockRemotes }, + }); + + const result = await service.listRemotes(); + + expect(result).toEqual(mockRemotes); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/listremotes', + expect.objectContaining({ + json: {}, + }) + ); + }); + + it('should return empty array when no remotes', async () => { + mockGot.post.mockResolvedValue({ body: {} }); + + const result = await service.listRemotes(); + + expect(result).toEqual([]); + }); + }); + + describe('getRemoteDetails', () => { + it('should return remote details', async () => { + const input: GetRCloneRemoteDetailsDto = { name: 'test-remote' }; + const mockConfig = { type: 's3', provider: 'AWS' }; + mockGot.post.mockResolvedValue({ body: mockConfig }); + + const result = await service.getRemoteDetails(input); + + expect(result).toEqual(mockConfig); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/get', + expect.objectContaining({ + json: { name: 'test-remote' }, + }) + ); + }); + }); + + describe('getRemoteConfig', () => { + it('should return remote configuration', async () => { + const input: GetRCloneRemoteConfigDto = { name: 'test-remote' }; + const mockConfig = { type: 's3', access_key_id: 'AKIA...' }; + mockGot.post.mockResolvedValue({ body: mockConfig }); + + const result = await service.getRemoteConfig(input); + + expect(result).toEqual(mockConfig); + }); + }); + + describe('createRemote', () => { + it('should create a new remote', async () => { + const input: CreateRCloneRemoteDto = { + name: 'new-remote', + type: 's3', + parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' }, + }; + const mockResponse = { success: true }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.createRemote(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/create', + expect.objectContaining({ + json: { + name: 'new-remote', + type: 's3', + parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' }, + }, + }) + ); + }); + }); + + describe('updateRemote', () => { + it('should update an existing remote', async () => { + const input: UpdateRCloneRemoteDto = { + name: 'existing-remote', + parameters: { access_key_id: 'NEW_AKIA...' }, + }; + const mockResponse = { success: true }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.updateRemote(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/update', + expect.objectContaining({ + json: { + name: 'existing-remote', + access_key_id: 'NEW_AKIA...', + }, + }) + ); + }); + }); + + describe('deleteRemote', () => { + it('should delete a remote', async () => { + const input: DeleteRCloneRemoteDto = { name: 'remote-to-delete' }; + const mockResponse = { success: true }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.deleteRemote(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/config/delete', + expect.objectContaining({ + json: { name: 'remote-to-delete' }, + }) + ); + }); + }); + + describe('startBackup', () => { + it('should start a backup operation', async () => { + const input: RCloneStartBackupInput = { + srcPath: '/source/path', + dstPath: 'remote:backup/path', + options: { delete_on: 'dst' }, + }; + const mockResponse = { jobid: 'job-123' }; + mockGot.post.mockResolvedValue({ body: mockResponse }); + + const result = await service.startBackup(input); + + expect(result).toEqual(mockResponse); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/sync/copy', + expect.objectContaining({ + json: { + srcFs: '/source/path', + dstFs: 'remote:backup/path', + delete_on: 'dst', + }, + }) + ); + }); + }); + + describe('getJobStatus', () => { + it('should return job status', async () => { + const input: GetRCloneJobStatusDto = { jobId: 'job-123' }; + const mockStatus = { status: 'running', progress: 0.5 }; + mockGot.post.mockResolvedValue({ body: mockStatus }); + + const result = await service.getJobStatus(input); + + expect(result).toEqual(mockStatus); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/job/status', + expect.objectContaining({ + json: { jobid: 'job-123' }, + }) + ); + }); + }); + + describe('listRunningJobs', () => { + it('should return list of running jobs', async () => { + const mockJobs = [ + { id: 'job-1', status: 'running' }, + { id: 'job-2', status: 'finished' }, + ]; + mockGot.post.mockResolvedValue({ body: mockJobs }); + + const result = await service.listRunningJobs(); + + expect(result).toEqual(mockJobs); + expect(mockGot.post).toHaveBeenCalledWith( + 'http://unix:/tmp/rclone.sock:/job/list', + expect.objectContaining({ + json: {}, + }) + ); + }); + }); + + describe('error handling', () => { + it('should handle HTTP errors with detailed messages', async () => { + const httpError = { + name: 'HTTPError', + message: 'Request failed', + response: { + statusCode: 500, + body: JSON.stringify({ error: 'Internal server error' }), + }, + }; + Object.setPrototypeOf(httpError, HTTPError.prototype); + mockGot.post.mockRejectedValue(httpError); + + await expect(service.getProviders()).rejects.toThrow( + 'Rclone API Error (config/providers, HTTP 500): Rclone Error: Internal server error' + ); + }); + + it('should handle HTTP errors with empty response body', async () => { + const httpError = { + name: 'HTTPError', + message: 'Request failed', + response: { + statusCode: 404, + body: '', + }, + }; + Object.setPrototypeOf(httpError, HTTPError.prototype); + mockGot.post.mockRejectedValue(httpError); + + await expect(service.getProviders()).rejects.toThrow( + 'Rclone API Error (config/providers, HTTP 404): Failed to process error response body. Raw body:' + ); + }); + + it('should handle HTTP errors with malformed JSON', async () => { + const httpError = { + name: 'HTTPError', + message: 'Request failed', + response: { + statusCode: 400, + body: 'invalid json', + }, + }; + Object.setPrototypeOf(httpError, HTTPError.prototype); + mockGot.post.mockRejectedValue(httpError); + + await expect(service.getProviders()).rejects.toThrow( + 'Rclone API Error (config/providers, HTTP 400): Failed to process error response body. Raw body: invalid json' + ); + }); + + it('should handle non-HTTP errors', async () => { + const networkError = new Error('Network connection failed'); + mockGot.post.mockRejectedValue(networkError); + + await expect(service.getProviders()).rejects.toThrow('Network connection failed'); + }); + + it('should handle unknown errors', async () => { + mockGot.post.mockRejectedValue('unknown error'); + + await expect(service.getProviders()).rejects.toThrow( + 'Unknown error calling RClone API (config/providers) with params {}: unknown error' + ); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts index 3db4b22d2..c8af35aa4 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -24,25 +24,6 @@ import { } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; -// Define the structure returned by mapProviderOptions inline -interface MappedRCloneProviderOption { - name: string; - help: string; - provider?: string; - default: any; - value: any; - shortOpt?: string; - hide?: number; - required?: boolean; - isPassword?: boolean; - noPrefix?: boolean; - advanced?: boolean; - defaultStr?: string; - valueStr?: string; - type?: string; - examples?: { value: string; help: string; provider?: string }[]; -} - @Injectable() export class RCloneApiService implements OnModuleInit, OnModuleDestroy { private isInitialized: boolean = false; @@ -241,33 +222,6 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { return response?.providers || []; } - /** - * Maps RClone provider options from API format to our model format - */ - private mapProviderOptions(options: RCloneProviderOptionResponse[]): MappedRCloneProviderOption[] { - return options.map((option) => ({ - name: option.Name, - help: option.Help, - provider: option.Provider, - default: option.Default, - value: option.Value, - shortOpt: option.ShortOpt, - hide: option.Hide, - required: option.Required, - isPassword: option.IsPassword, - noPrefix: option.NoPrefix, - advanced: option.Advanced, - defaultStr: option.DefaultStr, - valueStr: option.ValueStr, - type: option.Type, - examples: option.Examples?.map((example) => ({ - value: example.Value, - help: example.Help, - provider: example.Provider, - })), - })); - } - /** * List all remotes configured in rclone */ @@ -367,7 +321,9 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { private async callRcloneApi(endpoint: string, params: Record = {}): Promise { const url = `${this.rcloneBaseUrl}/${endpoint}`; try { - this.logger.debug(`Calling RClone API: ${url} with params: ${JSON.stringify(params)}`); + this.logger.debug( + `Calling RClone API: ${url} with params: ${JSON.stringify(sanitizeParams(params))}` + ); const response = await got.post(url, { json: params, @@ -376,68 +332,63 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { headers: { Authorization: `Basic ${Buffer.from(`${this.rcloneUsername}:${this.rclonePassword}`).toString('base64')}`, }, - // Add timeout? retry logic? Consider these based on need. }); return response.body; } catch (error: unknown) { - let detailedErrorMessage = 'An unknown error occurred'; - if (error instanceof HTTPError) { - const statusCode = error.response.statusCode; - let rcloneError = 'Could not extract Rclone error details.'; - const responseBody = error.response.body; // Get the body + this.handleApiError(error, endpoint, params); + } + } - try { - let errorBody: any; - // Check if the body is a string that needs parsing or already an object - if (typeof responseBody === 'string') { - errorBody = JSON.parse(responseBody); - } else if (typeof responseBody === 'object' && responseBody !== null) { - errorBody = responseBody; // It's already an object - } + private handleApiError(error: unknown, endpoint: string, params: Record): never { + if (error instanceof HTTPError) { + const statusCode = error.response.statusCode; + const rcloneError = this.extractRcloneError(error.response.body, params); + const detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`; - if (errorBody && errorBody.error) { - rcloneError = `Rclone Error: ${errorBody.error}`; - // Add input details if available, check for different structures - if (errorBody.input) { - rcloneError += ` | Input: ${JSON.stringify(errorBody.input)}`; - } else if (params) { - // Fallback to original params if errorBody.input is missing - rcloneError += ` | Original Params: ${JSON.stringify(params)}`; - } - } else if (responseBody) { - // Body exists but doesn't match expected error structure - rcloneError = `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; - } else { - rcloneError = 'Empty error response body received.'; - } - } catch (parseOrAccessError) { - // Handle errors during parsing or accessing properties - rcloneError = `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; - } - // Construct the detailed message for the new error - detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`; + const sanitizedParams = sanitizeParams(params); + this.logger.error( + `Original ${detailedErrorMessage} | Params: ${JSON.stringify(sanitizedParams)}`, + error.stack + ); - // Log the detailed error including the original stack if available - const sanitizedParams = sanitizeParams(params); - this.logger.error( - `Original ${detailedErrorMessage} | Params: ${JSON.stringify(sanitizedParams)}`, - error.stack - ); + throw new Error(detailedErrorMessage); + } else if (error instanceof Error) { + const detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${error.message}`; + this.logger.error(detailedErrorMessage, error.stack); + throw error; + } else { + const detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${String(error)}`; + this.logger.error(detailedErrorMessage); + throw new Error(detailedErrorMessage); + } + } - // Throw a NEW error with the detailed Rclone message - throw new Error(detailedErrorMessage); - } else if (error instanceof Error) { - // For non-HTTP errors, log and re-throw as before - detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${error.message}`; - this.logger.error(detailedErrorMessage, error.stack); - throw error; // Re-throw original non-HTTP error - } else { - // Handle unknown error types - detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${String(error)}`; - this.logger.error(detailedErrorMessage); - throw new Error(detailedErrorMessage); // Throw a new error for unknown types + private extractRcloneError(responseBody: unknown, fallbackParams: Record): string { + try { + let errorBody: unknown; + if (typeof responseBody === 'string') { + errorBody = JSON.parse(responseBody); + } else if (typeof responseBody === 'object' && responseBody !== null) { + errorBody = responseBody; } + + if (errorBody && typeof errorBody === 'object' && 'error' in errorBody) { + const typedErrorBody = errorBody as { error: unknown; input?: unknown }; + let rcloneError = `Rclone Error: ${String(typedErrorBody.error)}`; + if (typedErrorBody.input) { + rcloneError += ` | Input: ${JSON.stringify(typedErrorBody.input)}`; + } else if (fallbackParams) { + rcloneError += ` | Original Params: ${JSON.stringify(fallbackParams)}`; + } + return rcloneError; + } else if (responseBody) { + return `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; + } else { + return 'Empty error response body received.'; + } + } catch (parseOrAccessError) { + return `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`; } } } diff --git a/unraid-ui/src/components.ts b/unraid-ui/src/components.ts index d486f79f8..97d6da9d7 100644 --- a/unraid-ui/src/components.ts +++ b/unraid-ui/src/components.ts @@ -18,3 +18,4 @@ export * from '@/components/common/tooltip'; export * from '@/components/common/toast'; export * from '@/components/common/popover'; export * from '@/components/modals'; +export * from '@/components/common/accordion';