diff --git a/api-docs/openapi.yaml b/api-docs/openapi.yaml index 31b71ebf..c1024394 100644 --- a/api-docs/openapi.yaml +++ b/api-docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 2.3.2 + version: 2.3.3 description: | REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention. diff --git a/docs/changelog.md b/docs/changelog.md index d4a17d70..b532111c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,36 @@ All notable changes to DBackup are documented here. +## v2.3.3 - Multiple Bug Fixes across MSSQL, Redis, Email, and Storage Adapters +*Released: May 19, 2026* + +### ๐Ÿ› Bug Fixes + +- **MSSQL**: Fixed Database Explorer showing "No user databases found" on production instances. Databases in non-ONLINE states (e.g. RESTORING, Availability Group replicas) are now included, and connection errors are surfaced to the UI instead of silently returning an empty list. +- **Redis**: Fixed `A credential profile is required but none is assigned` error when connecting to a Redis instance without authentication. The credential profile is now optional for Redis - when no profile is assigned, the structural config fields (inline password if any) are used directly, allowing Redis instances without ACL/password to work without a credential profile. ([#86](https://github.com/Skyfay/DBackup/issues/86)) +- **email**: Fixed automated email (SMTP) notifications failing with `A credential profile is required but none is assigned` during backup/restore job runs and system health checks. The v2.3.2 fix only covered the test-connection path; the runner pipeline still used a stricter resolver that threw unconditionally when no profile was assigned. The credential profile is now consistently optional in all code paths - when no profile is assigned, the structural config (host, port, from, to, inline user/password if any) is used directly. ([#87](https://github.com/Skyfay/DBackup/issues/87)) +- **Storage**: Fixed orphaned `.connection-test-*` / `.dbackup-test-*` files accumulating on remote storage destinations. All 10 storage adapters (FTP/FTPS, SMB, SFTP, WebDAV, S3/R2/Hetzner/AWS, Local, Rsync, Dropbox, Google Drive, OneDrive) placed the remote file deletion inside the `try` block without a `finally` guard. If the delete call threw (network hiccup, server-side error, permission edge case), the test file was left behind permanently. Every adapter now uses a `remoteFileCreated` flag and a `finally` block to guarantee a best-effort cleanup even when the delete itself fails. +- **Storage**: Fixed false "-100% change" spike notifications still triggering for users with a **Local Filesystem** destination. The v2.3.2 fix updated all 9 cloud/network adapters but missed the Local adapter: its `list()` catch block returned `[]` on any I/O error (e.g. a temporarily unmounted fstab disk) instead of throwing. This caused a 0-byte snapshot to be saved and a spike alert to fire, identical to the original bug. The inner `fs.access` wrapper is removed so the original error code propagates; the outer catch now throws on all errors except ENOENT on non-root sub-paths (legitimate "no backups for this job yet" scenario). ([#82](https://github.com/Skyfay/DBackup/issues/82)) +- **Storage**: Fixed the same class of bug in the **SMB** and **FTP** adapters. Both use an inner `walk()` helper that silently swallowed listing errors via `catch { return; }`. Because the SMB share connection is not established until the first `client.list()` call (unlike FTP/cloud adapters which connect upfront), any SMB authentication or network failure was silently turned into an empty list. The inner catch now re-throws when `currentDir === startDir` (root listing = connection/auth failure) and continues silently only for sub-directory errors (e.g. one folder with restricted permissions). ([#82](https://github.com/Skyfay/DBackup/issues/82)) + +### ๐ŸŽจ Improvements + +- **Storage**: Improved UX for S3 Glacier and Deep Archive storage classes. The file list now surfaces the storage class of each object from the AWS ListObjectsV2 response. In the Storage Explorer, Glacier and Deep Archive objects are labeled with an orange "Glacier" or "Deep Archive" badge. Download and Restore action buttons are disabled for archived objects with a tooltip explaining that the object must be restored via the AWS Console first. The S3 download function now throws a descriptive error (instead of returning a generic failure) when AWS returns `InvalidObjectState`, so the message is surfaced to the user in the UI. The "Storage Class" field description in the adapter configuration form now includes a warning that GLACIER and DEEP_ARCHIVE prevent direct download and restore. ([#88](https://github.com/Skyfay/DBackup/issues/88)) + +### ๐Ÿงช Tests + +- Updated Local Filesystem adapter `list()` tests: replaced "returns empty array on unexpected error" with three new assertions - throws on unexpected readdir errors, throws when the root path (`remotePath = ""`) is inaccessible (ENOENT), and throws on non-ENOENT access errors on sub-paths (EACCES). +- Updated **SMB** adapter `list()` tests: replaced the incorrect "returns empty array on list error" assertion (expected `[]`, now expects throw) with "throws when root directory listing fails"; added "continues when a subdirectory listing fails" to document the intentional silent-skip behavior for non-root walk errors. +- Updated **FTP** adapter `list()` tests: added "throws when initial directory listing fails after connection" covering the case where `connectFTP` succeeds but the first `client.list()` call fails (e.g. path does not exist or permission denied). The existing subdirectory-continue test is unchanged. + +### ๐Ÿณ Docker + +- **Image**: `skyfay/dbackup:v2.3.3` +- **Also tagged as**: `latest`, `v2` +- **CI Image**: `skyfay/dbackup:ci` +- **Platforms**: linux/amd64, linux/arm64 + + ## v2.3.2 - Backup Trigger Metadata, Job Trigger Locking, and Notification Improvements *Released: May 17, 2026* diff --git a/docs/package.json b/docs/package.json index 64294ea5..d8a7c5c0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "dbackup-docs", - "version": "2.3.2", + "version": "2.3.3", "private": true, "scripts": { "dev": "vitepress dev", diff --git a/package.json b/package.json index 7217037e..143c3eba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dbackup", - "version": "2.3.2", + "version": "2.3.3", "private": true, "scripts": { "dev": "next dev", diff --git a/public/openapi.yaml b/public/openapi.yaml index e20fbf99..407702a4 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 2.3.2 + version: 2.3.3 description: | REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention. diff --git a/src/app/dashboard/storage/columns.tsx b/src/app/dashboard/storage/columns.tsx index e8afe8df..ce1edb54 100644 --- a/src/app/dashboard/storage/columns.tsx +++ b/src/app/dashboard/storage/columns.tsx @@ -27,6 +27,7 @@ export type FileInfo = { compression?: string; locked?: boolean; trigger?: { type: string; actor?: string }; + storageClass?: string; }; interface ColumnsProps { @@ -123,8 +124,24 @@ export const getColumns = ({ onRestore, onDownload, onDelete, onToggleLock, onGe header: "Compression", cell: ({ row }) => { const comp = row.original.compression; - if (!comp || comp === "NONE") return -; - return {comp}; + const storageClass = row.original.storageClass; + const isArchived = storageClass === "GLACIER" || storageClass === "DEEP_ARCHIVE"; + const hasContent = isArchived || (comp && comp !== "NONE"); + + if (!hasContent) return -; + + return ( +
+ {isArchived && ( + + {storageClass === "DEEP_ARCHIVE" ? "Deep Archive" : "Glacier"} + + )} + {comp && comp !== "NONE" && ( + {comp} + )} +
+ ); } }, { diff --git a/src/components/adapter/form-sections.tsx b/src/components/adapter/form-sections.tsx index d29f59a6..7bb4cac5 100644 --- a/src/components/adapter/form-sections.tsx +++ b/src/components/adapter/form-sections.tsx @@ -27,7 +27,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; -import { Check, FolderOpen, Loader2 } from "lucide-react"; +import { AlertTriangle, Check, FolderOpen, Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { AdapterDefinition } from "@/lib/adapters/definitions"; import { SchemaField } from "./schema-field"; import { EmailTagField } from "./email-tag-field"; @@ -603,6 +604,8 @@ export function StorageFormContent({ }: { adapter: AdapterDefinition; initialData?: AdapterConfig; healthNotificationsDisabled?: boolean; onHealthNotificationsDisabledChange?: (disabled: boolean) => void } & CredentialPickerHostProps) { const { watch } = useFormContext(); const authType = watch("config.authType"); + const storageClass = watch("config.storageClass"); + const isArchivedStorageClass = storageClass === "GLACIER" || storageClass === "DEEP_ARCHIVE"; const hasRealConfigKeys = hasFields(adapter, STORAGE_CONFIG_KEYS); // Always show Configuration tab for storage adapters (health check switch lives there) const hasConfigKeys = hasRealConfigKeys || !!onHealthNotificationsDisabledChange; @@ -720,7 +723,19 @@ export function StorageFormContent({ hasRefreshToken={hasRefreshToken} /> ) : hasRealConfigKeys ? ( - + <> + + {isArchivedStorageClass && ( + + + + {storageClass === "DEEP_ARCHIVE" ? "Deep Archive" : "Glacier"} is an archived storage class. + Backups stored with this class cannot be downloaded or restored directly through DBackup. + You must first restore the object via the AWS Console (S3 - select object - Actions - Initiate restore) before accessing it. + + + )} + ) : null} {onHealthNotificationsDisabledChange && ( void; @@ -27,6 +29,9 @@ export function ActionsCell({ canRestore, canDelete }: ActionsCellProps) { + const isArchived = ARCHIVED_STORAGE_CLASSES.includes(file.storageClass ?? ""); + const archivedTooltip = "This backup is archived in S3 Glacier or Deep Archive. Restore it via the AWS Console first (S3 - select object - Actions - Initiate restore)."; + return (
{onToggleLock && canDelete && ( @@ -50,7 +55,20 @@ export function ActionsCell({ )} {canDownload && ( - file.isEncrypted ? ( + isArchived ? ( + + + + + + + + {archivedTooltip} + + + ) : file.isEncrypted ? ( @@ -118,16 +136,31 @@ export function ActionsCell({ )} {canRestore && ( - - - - - - Restore - - + isArchived ? ( + + + + + + + + {archivedTooltip} + + + ) : ( + + + + + + Restore + + + ) )} {canDelete && ( diff --git a/src/lib/adapters/config-resolver.ts b/src/lib/adapters/config-resolver.ts index 320f76f0..95063844 100644 --- a/src/lib/adapters/config-resolver.ts +++ b/src/lib/adapters/config-resolver.ts @@ -68,21 +68,27 @@ export async function resolveAdapterConfig(adapter: AdapterConfigInput): Promise } // --- Primary slot --- - // When the adapter declares a required primary credential, a profile must be assigned. + // When the adapter declares a required primary credential, a profile must be assigned + // unless the adapter marks the primary slot as optional (e.g. Redis without auth, + // SMTP unauthenticated relay). Optional adapters fall back to the structural config. if (requirements.primary) { if (!adapter.primaryCredentialId) { - throw new ConfigurationError( + if (!requirements.primaryOptional) { + throw new ConfigurationError( + adapter.adapterId, + "A credential profile is required but none is assigned" + ); + } + // Optional and no profile assigned - use structural config fields as-is + } else { + const profile = await loadAndValidate( + adapter.primaryCredentialId, + requirements.primary, adapter.adapterId, - "A credential profile is required but none is assigned" + "primary" ); + applyPrimaryOverlay(parsed, profile, requirements.primary); } - const profile = await loadAndValidate( - adapter.primaryCredentialId, - requirements.primary, - adapter.adapterId, - "primary" - ); - applyPrimaryOverlay(parsed, profile, requirements.primary); } // --- SSH slot (always optional at runtime - SSH mode is opt-in per adapter) --- diff --git a/src/lib/adapters/database/mssql/connection.ts b/src/lib/adapters/database/mssql/connection.ts index 764aab12..65f119b2 100644 --- a/src/lib/adapters/database/mssql/connection.ts +++ b/src/lib/adapters/database/mssql/connection.ts @@ -151,16 +151,18 @@ export async function getDatabasesWithStats(config: MSSQLConfig): Promise 4 - AND d.state = 0 - GROUP BY d.name + GROUP BY d.name, d.state_desc ORDER BY d.name `); @@ -191,7 +193,7 @@ export async function getDatabasesWithStats(config: MSSQLConfig): Promise; - credentials?: { primary?: CredentialType; ssh?: CredentialType }; + /** + * `primary` declares the credential type for the primary slot. + * `primaryOptional: true` means the adapter can work without a credential + * profile (e.g. Redis without auth, SMTP with an unauthenticated relay). + * When no profile is assigned and `primaryOptional` is true, the structural + * config fields (inline user/password) are used as-is. + */ + credentials?: { primary?: CredentialType; ssh?: CredentialType; primaryOptional?: boolean }; } // Validation: Reject paths with null bytes or obvious shell injection patterns diff --git a/src/lib/adapters/definitions/storage.ts b/src/lib/adapters/definitions/storage.ts index 091cce89..66535fab 100644 --- a/src/lib/adapters/definitions/storage.ts +++ b/src/lib/adapters/definitions/storage.ts @@ -23,7 +23,7 @@ export const S3AWSSchema = z.object({ accessKeyId: z.string().min(1, "Access Key is required"), secretAccessKey: z.string().min(1, "Secret Key is required"), pathPrefix: z.string().optional().describe("Optional folder prefix"), - storageClass: z.enum(["STANDARD", "STANDARD_IA", "GLACIER", "DEEP_ARCHIVE"]).default("STANDARD").describe("Storage Class for uploaded files"), + storageClass: z.enum(["STANDARD", "STANDARD_IA", "GLACIER", "DEEP_ARCHIVE"]).default("STANDARD").describe("Storage Class for uploaded files. Warning: GLACIER and DEEP_ARCHIVE are archived storage classes. Backups stored with these classes cannot be downloaded or restored directly through DBackup."), }); export const S3R2Schema = z.object({ diff --git a/src/lib/adapters/notification/email.tsx b/src/lib/adapters/notification/email.tsx index dd5225d6..4d721f21 100644 --- a/src/lib/adapters/notification/email.tsx +++ b/src/lib/adapters/notification/email.tsx @@ -31,7 +31,7 @@ export const EmailAdapter: NotificationAdapter = { type: "notification", name: "Email (SMTP)", configSchema: EmailSchema, - credentials: { primary: "SMTP" }, + credentials: { primary: "SMTP", primaryOptional: true }, async test(config: any): Promise<{ success: boolean; message: string }> { try { diff --git a/src/lib/adapters/storage/dropbox.ts b/src/lib/adapters/storage/dropbox.ts index 5119a9de..59fe4720 100644 --- a/src/lib/adapters/storage/dropbox.ts +++ b/src/lib/adapters/storage/dropbox.ts @@ -347,14 +347,21 @@ export const DropboxAdapter: StorageAdapter = { const testPath = basePath ? `${basePath}/.dbackup-test-${Date.now()}` : `/.dbackup-test-${Date.now()}`; + let testFileUploaded = false; - await dbx.filesUpload({ - path: testPath, - contents: Buffer.from("Connection Test"), - mode: { ".tag": "overwrite" }, - }); + try { + await dbx.filesUpload({ + path: testPath, + contents: Buffer.from("Connection Test"), + mode: { ".tag": "overwrite" }, + }); + testFileUploaded = true; - await dbx.filesDeleteV2({ path: testPath }); + await dbx.filesDeleteV2({ path: testPath }); + testFileUploaded = false; + } finally { + if (testFileUploaded) await dbx.filesDeleteV2({ path: testPath }).catch(() => {}); + } return { success: true, diff --git a/src/lib/adapters/storage/ftp.ts b/src/lib/adapters/storage/ftp.ts index 54b7e1dd..7f12bbb1 100644 --- a/src/lib/adapters/storage/ftp.ts +++ b/src/lib/adapters/storage/ftp.ts @@ -178,7 +178,10 @@ export const FTPAdapter: StorageAdapter = { let items: FTPFileInfo[]; try { items = await client!.list(currentDir); - } catch { + } catch (error: unknown) { + // Root directory listing failure: propagate so the stats cache uses DB fallback. + if (currentDir === startDir) throw error; + // Sub-directory listing failure: skip silently and continue the walk. return; } @@ -237,12 +240,12 @@ export const FTPAdapter: StorageAdapter = { async test(config: FTPConfig): Promise<{ success: boolean; message: string }> { const testFileName = `.connection-test-${Date.now()}`; const tmpPath = path.join(os.tmpdir(), testFileName); + const destination = resolvePath(config, testFileName); let client: Client | null = null; + let remoteFileCreated = false; try { client = await connectFTP(config); - const destination = resolvePath(config, testFileName); - // Ensure pathPrefix directory exists if set if (config.pathPrefix) { await client.ensureDir(config.pathPrefix); @@ -254,15 +257,18 @@ export const FTPAdapter: StorageAdapter = { // 1. Write Test await client.uploadFrom(createReadStream(tmpPath), destination); + remoteFileCreated = true; // 2. Delete Test await client.remove(destination); + remoteFileCreated = false; return { success: true, message: "Connection successful (Write/Delete verified)" }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { success: false, message: `FTP Connection failed: ${message}` }; } finally { + if (remoteFileCreated) await client?.remove(destination).catch(() => {}); if (client) client.close(); await fs.unlink(tmpPath).catch(() => {}); } diff --git a/src/lib/adapters/storage/google-drive.ts b/src/lib/adapters/storage/google-drive.ts index 06397ac9..b0baac5d 100644 --- a/src/lib/adapters/storage/google-drive.ts +++ b/src/lib/adapters/storage/google-drive.ts @@ -370,9 +370,15 @@ export const GoogleDriveAdapter: StorageAdapter = { }, fields: "id", }); + let testFileId: string | undefined = testFile.data.id ?? undefined; // Test 3: Delete the test file - await drive.files.delete({ fileId: testFile.data.id! }); + try { + await drive.files.delete({ fileId: testFileId! }); + testFileId = undefined; + } finally { + if (testFileId) await drive.files.delete({ fileId: testFileId }).catch(() => {}); + } return { success: true, message: "Connection successful (Write/Delete verified)" }; } catch (error: unknown) { diff --git a/src/lib/adapters/storage/local.ts b/src/lib/adapters/storage/local.ts index 0d0eddd6..8f121287 100644 --- a/src/lib/adapters/storage/local.ts +++ b/src/lib/adapters/storage/local.ts @@ -136,11 +136,7 @@ export const LocalFileSystemAdapter: StorageAdapter = { async list(config: { basePath: string }, remotePath: string = ""): Promise { try { const dirPath = resolveSafePath(config.basePath, remotePath); - try { - await fs.access(dirPath); - } catch { - throw new AdapterError("local-filesystem", "list", `Storage path not accessible: ${dirPath}`); - } + await fs.access(dirPath); const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive: true }); @@ -163,9 +159,14 @@ export const LocalFileSystemAdapter: StorageAdapter = { } return files; } catch (error) { - if (error instanceof Error && error.message.includes("Access denied")) throw error; + if (error instanceof Error && error.message.includes("Access denied")) throw error; + // A subfolder that does not exist yet is a legitimate empty-result case (no backups for this job yet). + const nodeErr = error as NodeJS.ErrnoException; + if (remotePath !== "" && nodeErr.code === "ENOENT") return []; + // Any other error on a root listing or non-ENOENT failures: throw so the stats cache + // triggers its DB fallback and sets scanError=true, preventing a false 0-byte snapshot. log.error("Local list failed", { remotePath }, wrapError(error)); - return []; + throw error; } }, @@ -189,19 +190,24 @@ export const LocalFileSystemAdapter: StorageAdapter = { async test(config: { basePath: string }): Promise<{ success: boolean; message: string }> { const testFile = path.join(config.basePath, `.connection-test-${Date.now()}`); + let written = false; try { await fs.mkdir(config.basePath, { recursive: true }); // 1. Write await fs.writeFile(testFile, "Connection Test"); + written = true; // 2. Delete await fs.unlink(testFile); + written = false; return { success: true, message: `Access to ${config.basePath} verified (Read/Write)` }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { success: false, message: `Access failed: ${message}` }; + } finally { + if (written) await fs.unlink(testFile).catch(() => {}); } } }; diff --git a/src/lib/adapters/storage/onedrive.ts b/src/lib/adapters/storage/onedrive.ts index 7bff97a5..98ffdbfa 100644 --- a/src/lib/adapters/storage/onedrive.ts +++ b/src/lib/adapters/storage/onedrive.ts @@ -418,12 +418,19 @@ export const OneDriveAdapter: StorageAdapter = { // Test 3: Write and delete a test file const testFileName = `.dbackup-test-${Date.now()}.txt`; const testPath = basePath ? `${basePath}/${testFileName}` : testFileName; + let testFileUploaded = false; - await client - .api(`${driveItemPath(testPath)}/content`) - .put(Buffer.from("Connection Test")); - - await client.api(driveItemPath(testPath)).delete(); + try { + await client + .api(`${driveItemPath(testPath)}/content`) + .put(Buffer.from("Connection Test")); + testFileUploaded = true; + + await client.api(driveItemPath(testPath)).delete(); + testFileUploaded = false; + } finally { + if (testFileUploaded) await client.api(driveItemPath(testPath)).delete().catch(() => {}); + } return { success: true, diff --git a/src/lib/adapters/storage/rsync.ts b/src/lib/adapters/storage/rsync.ts index 10aef8d9..6c2af4b3 100644 --- a/src/lib/adapters/storage/rsync.ts +++ b/src/lib/adapters/storage/rsync.ts @@ -472,6 +472,7 @@ export const RsyncAdapter: StorageAdapter = { let keyFile: string | undefined; const testFileName = `.connection-test-${Date.now()}`; const tmpPath = path.join(os.tmpdir(), testFileName); + let remoteFileCreated = false; try { if (config.authType === "privateKey" && config.privateKey) { keyFile = await writeTempKey(config.privateKey); @@ -501,15 +502,21 @@ export const RsyncAdapter: StorageAdapter = { rsync.destination(destination); await executeRsync(rsync); + remoteFileCreated = true; // 2. Delete Test const fullPath = path.posix.join(config.pathPrefix, testFileName); await execSSH(config, `rm -f '${shellEscapeSingleQuote(fullPath)}'`, keyFile); + remoteFileCreated = false; return { success: true, message: "Connection successful (Write/Delete verified)" }; } catch (error: unknown) { return { success: false, message: `Rsync connection failed: ${sanitizeError(error)}` }; } finally { + if (remoteFileCreated) { + const fullPath = path.posix.join(config.pathPrefix, testFileName); + await execSSH(config, `rm -f '${shellEscapeSingleQuote(fullPath)}'`, keyFile).catch(() => {}); + } if (keyFile) await fs.unlink(keyFile).catch(() => {}); await fs.unlink(tmpPath).catch(() => {}); } diff --git a/src/lib/adapters/storage/s3.ts b/src/lib/adapters/storage/s3.ts index 2b3e9cfa..07381609 100644 --- a/src/lib/adapters/storage/s3.ts +++ b/src/lib/adapters/storage/s3.ts @@ -98,6 +98,7 @@ async function s3List(internalConfig: S3InternalConfig, dir: string = ""): Promi path: obj.Key || "", size: obj.Size || 0, lastModified: obj.LastModified || new Date(), + storageClass: obj.StorageClass || undefined, })).filter(f => f.name && f.size > 0); // Filter folders or empty keys } catch (error) { log.error("S3 list failed", { bucket: internalConfig.bucket, prefix: listPrefix }, wrapError(error)); @@ -143,6 +144,14 @@ async function s3Download( } return true; } catch (error) { + const err = error as any; + if (err?.name === "InvalidObjectState" || err?.Code === "InvalidObjectState") { + log.error("S3 download failed - object is archived", { bucket: internalConfig.bucket, targetKey }, wrapError(error)); + throw new Error( + `The backup "${path.basename(targetKey)}" is stored in S3 Glacier or Deep Archive and cannot be downloaded directly. ` + + "Please restore the object via the AWS Console first (S3 - select object - Actions - Initiate restore), then try again." + ); + } log.error("S3 download failed", { bucket: internalConfig.bucket, targetKey }, wrapError(error)); return false; } @@ -196,6 +205,7 @@ async function s3Test(internalConfig: S3InternalConfig): Promise<{ success: bool const testFile = `.backup-manager-test-${Date.now()}`; // Use target key logic to respect pathPrefix const targetKey = S3ClientFactory.getTargetKey(internalConfig, testFile); + let uploaded = false; try { // 1. Try to write @@ -204,17 +214,21 @@ async function s3Test(internalConfig: S3InternalConfig): Promise<{ success: bool Key: targetKey, Body: "Database Backup Manager - Connection Test" })); + uploaded = true; // 2. Try to delete await client.send(new DeleteObjectCommand({ Bucket: internalConfig.bucket, Key: targetKey })); + uploaded = false; return { success: true, message: "Connection successful (Write/Delete verified)" }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { success: false, message: message || "Connection failed" }; + } finally { + if (uploaded) await client.send(new DeleteObjectCommand({ Bucket: internalConfig.bucket, Key: targetKey })).catch(() => {}); } } diff --git a/src/lib/adapters/storage/sftp.ts b/src/lib/adapters/storage/sftp.ts index 8584ea7b..51599610 100644 --- a/src/lib/adapters/storage/sftp.ts +++ b/src/lib/adapters/storage/sftp.ts @@ -245,13 +245,13 @@ export const SFTPAdapter: StorageAdapter = { async test(config: SFTPConfig): Promise<{ success: boolean; message: string }> { let sftp: Client | null = null; const testFileName = `.connection-test-${Date.now()}`; + const destination = config.pathPrefix + ? path.posix.join(config.pathPrefix, testFileName) + : testFileName; + let remoteFileCreated = false; try { sftp = await connectSFTP(config); - const destination = config.pathPrefix - ? path.posix.join(config.pathPrefix, testFileName) - : testFileName; - // Ensure directory exists if needed - though createWriteStream/put usually needs dir exist if (config.pathPrefix) { const remoteDir = config.pathPrefix; @@ -262,15 +262,18 @@ export const SFTPAdapter: StorageAdapter = { // 1. Write Test await sftp.put(Buffer.from("Connection Test"), destination); + remoteFileCreated = true; // 2. Delete Test await sftp.delete(destination); + remoteFileCreated = false; return { success: true, message: "Connection successful (Write/Delete verified)" }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { success: false, message: `SFTP Connection failed: ${message}` }; } finally { + if (remoteFileCreated) await sftp?.delete(destination).catch(() => {}); if (sftp) await sftp.end(); } } diff --git a/src/lib/adapters/storage/smb.ts b/src/lib/adapters/storage/smb.ts index 842a8562..9c89755d 100644 --- a/src/lib/adapters/storage/smb.ts +++ b/src/lib/adapters/storage/smb.ts @@ -142,7 +142,11 @@ export const SMBAdapter: StorageAdapter = { // For root listing (empty currentDir), "*" lists everything in the share root. const listPath = currentDir ? currentDir + "/*" : "*"; items = await client.list(listPath); - } catch { + } catch (error: unknown) { + // Root directory listing failure means the share is unreachable or inaccessible. + // Propagate to trigger the DB fallback in the stats cache. + if (currentDir === startDir) throw error; + // Sub-directory listing failure (e.g. permission denied on one folder): skip silently. return; } @@ -214,15 +218,19 @@ export const SMBAdapter: StorageAdapter = { const tmpPath = path.join(os.tmpdir(), testFileName); await fs.writeFile(tmpPath, "Connection Test"); + let remoteFileCreated = false; try { // 1. Write Test await client.sendFile(tmpPath, destination); + remoteFileCreated = true; // 2. Delete Test await client.deleteFile(destination); + remoteFileCreated = false; return { success: true, message: "Connection successful (Write/Delete verified)" }; } finally { + if (remoteFileCreated) await client.deleteFile(destination).catch(() => {}); await fs.unlink(tmpPath).catch(() => {}); } } catch (error: unknown) { diff --git a/src/lib/adapters/storage/webdav.ts b/src/lib/adapters/storage/webdav.ts index b3d8da20..abedc368 100644 --- a/src/lib/adapters/storage/webdav.ts +++ b/src/lib/adapters/storage/webdav.ts @@ -186,10 +186,10 @@ export const WebDAVAdapter: StorageAdapter = { async test(config: WebDAVConfig): Promise<{ success: boolean; message: string }> { const testFileName = `.connection-test-${Date.now()}`; + const client = getClient(config); + const destination = resolvePath(config, testFileName); + let remoteFileCreated = false; try { - const client = getClient(config); - const destination = resolvePath(config, testFileName); - // Ensure pathPrefix directory exists if set if (config.pathPrefix) { const prefixDir = path.posix.join("/", config.pathPrefix); @@ -200,14 +200,18 @@ export const WebDAVAdapter: StorageAdapter = { // 1. Write Test await client.putFileContents(destination, "Connection Test"); + remoteFileCreated = true; // 2. Delete Test await client.deleteFile(destination); + remoteFileCreated = false; return { success: true, message: "Connection successful (Write/Delete verified)" }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { success: false, message: `WebDAV Connection failed: ${message}` }; + } finally { + if (remoteFileCreated) await client.deleteFile(destination).catch(() => {}); } }, }; diff --git a/src/lib/core/credentials.ts b/src/lib/core/credentials.ts index dc8564f0..3c03fb17 100644 --- a/src/lib/core/credentials.ts +++ b/src/lib/core/credentials.ts @@ -113,6 +113,12 @@ export interface AdapterCredentialRequirements { primary?: CredentialType; /** Credential type for the optional SSH tunnel. */ ssh?: CredentialType; + /** + * When true, the adapter can operate without a primary credential profile. + * The config resolver skips the "missing credential" error if no profile is + * assigned and falls back to the structural config values. + */ + primaryOptional?: boolean; } /** diff --git a/src/lib/core/interfaces.ts b/src/lib/core/interfaces.ts index 5e4f212a..cdb62131 100644 --- a/src/lib/core/interfaces.ts +++ b/src/lib/core/interfaces.ts @@ -147,6 +147,7 @@ export type FileInfo = { size: number; lastModified: Date; locked?: boolean; + storageClass?: string; }; export interface StorageAdapter extends BaseAdapter { diff --git a/tests/unit/adapters/config-resolver.test.ts b/tests/unit/adapters/config-resolver.test.ts index c5516437..73c6f4ea 100644 --- a/tests/unit/adapters/config-resolver.test.ts +++ b/tests/unit/adapters/config-resolver.test.ts @@ -254,7 +254,7 @@ describe("Adapter credential declarations", () => { expect(registry.get("s3-aws")?.credentials).toEqual({ primary: "ACCESS_KEY" }); expect(registry.get("sftp")?.credentials).toEqual({ primary: "SSH_KEY" }); expect(registry.get("gotify")?.credentials).toEqual({ primary: "TOKEN" }); - expect(registry.get("email")?.credentials).toEqual({ primary: "SMTP" }); + expect(registry.get("email")?.credentials).toEqual({ primary: "SMTP", primaryOptional: true }); expect(registry.get("sqlite")?.credentials).toEqual({ ssh: "SSH_KEY" }); expect(registry.get("local-filesystem")?.credentials).toBeUndefined(); expect(registry.get("discord")?.credentials).toBeUndefined(); diff --git a/tests/unit/adapters/database/mssql/connection.test.ts b/tests/unit/adapters/database/mssql/connection.test.ts index ec2b3a08..ef7f4936 100644 --- a/tests/unit/adapters/database/mssql/connection.test.ts +++ b/tests/unit/adapters/database/mssql/connection.test.ts @@ -354,10 +354,9 @@ describe("getDatabasesWithStats()", () => { expect(result[0].sizeInBytes).toBe(0); }); - it("returns empty array when main query throws", async () => { + it("throws when connection fails", async () => { mockConnect.mockRejectedValue(new Error("Connection refused")); - const result = await getDatabasesWithStats(buildConfig()); - expect(result).toEqual([]); + await expect(getDatabasesWithStats(buildConfig())).rejects.toThrow("Connection refused"); }); }); diff --git a/tests/unit/adapters/storage/ftp.test.ts b/tests/unit/adapters/storage/ftp.test.ts index 8ed252cd..084edfe5 100644 --- a/tests/unit/adapters/storage/ftp.test.ts +++ b/tests/unit/adapters/storage/ftp.test.ts @@ -215,6 +215,12 @@ describe("FTPAdapter", () => { await expect(FTPAdapter.list(config, "Job")).rejects.toThrow("Auth failed"); }); + + it("throws when initial directory listing fails after connection", async () => { + mockList.mockRejectedValue(new Error("550 Permission denied")); + + await expect(FTPAdapter.list(config, "Job")).rejects.toThrow("550 Permission denied"); + }); }); // ===== delete() ===== diff --git a/tests/unit/adapters/storage/local.test.ts b/tests/unit/adapters/storage/local.test.ts index f949ae7f..0334ee76 100644 --- a/tests/unit/adapters/storage/local.test.ts +++ b/tests/unit/adapters/storage/local.test.ts @@ -270,13 +270,23 @@ describe("LocalFileSystemAdapter", () => { expect(result[0].name).toBe("backup.sql"); }); - it("returns empty array on unexpected error", async () => { + it("throws on unexpected readdir error (not ENOENT)", async () => { mockFsAccess.mockResolvedValue(undefined); mockFsReaddir.mockRejectedValue(new Error("Permission denied")); - const result = await LocalFileSystemAdapter.list!(config, "Job"); + await expect(LocalFileSystemAdapter.list!(config, "Job")).rejects.toThrow(); + }); - expect(result).toEqual([]); + it("throws on root listing error (remotePath is empty)", async () => { + mockFsAccess.mockRejectedValue(Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })); + + await expect(LocalFileSystemAdapter.list!(config, "")).rejects.toThrow(); + }); + + it("throws on non-ENOENT access error on subfolder", async () => { + mockFsAccess.mockRejectedValue(Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })); + + await expect(LocalFileSystemAdapter.list!(config, "Job")).rejects.toThrow(); }); it("uses default empty string for remotePath when not provided", async () => { diff --git a/tests/unit/adapters/storage/smb.test.ts b/tests/unit/adapters/storage/smb.test.ts index 675664ea..2b8c72a1 100644 --- a/tests/unit/adapters/storage/smb.test.ts +++ b/tests/unit/adapters/storage/smb.test.ts @@ -213,12 +213,25 @@ describe("SMBAdapter", () => { expect(result[0].path).not.toContain("backups/"); }); - it("returns empty array on list error", async () => { - mockList.mockRejectedValue(new Error("SMB error")); + it("throws when root directory listing fails", async () => { + mockList.mockRejectedValue(new Error("SMB connection error")); + + await expect(SMBAdapter.list(config, "Job")).rejects.toThrow("SMB connection error"); + }); + + it("continues when a subdirectory listing fails", async () => { + mockList + .mockResolvedValueOnce([ + { name: "subdir", type: "D", size: 0, modifyTime: new Date() }, + { name: "backup.sql", type: "F", size: 512, modifyTime: new Date() }, + ]) + .mockRejectedValueOnce(new Error("Permission denied on subdir")); const result = await SMBAdapter.list(config, "Job"); - expect(result).toEqual([]); + // The file at the root level is returned; the failed subdirectory is silently skipped. + expect(result).toHaveLength(1); + expect(result[0].name).toBe("backup.sql"); }); });