From 9f10960621483114526438e546646a9cdf0c080a Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 17 Jan 2026 10:53:10 +0100 Subject: [PATCH] tests(backends): add basic integration tests to mount and unmount all volume types --- .../modules/backends/webdav/webdav-backend.ts | 10 +++- .../integration/backends/Dockerfile.tester | 19 +++++++ .../integration/backends/docker-compose.yml | 57 +++++++++++++++++++ app/test/integration/backends/nfs.test.ts | 42 ++++++++++++++ app/test/integration/backends/run.sh | 34 +++++++++++ app/test/integration/backends/sftp.test.ts | 48 ++++++++++++++++ app/test/integration/backends/smb.test.ts | 48 ++++++++++++++++ app/test/integration/backends/webdav.test.ts | 49 ++++++++++++++++ 8 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 app/test/integration/backends/Dockerfile.tester create mode 100644 app/test/integration/backends/docker-compose.yml create mode 100644 app/test/integration/backends/nfs.test.ts create mode 100755 app/test/integration/backends/run.sh create mode 100644 app/test/integration/backends/sftp.test.ts create mode 100644 app/test/integration/backends/smb.test.ts create mode 100644 app/test/integration/backends/webdav.test.ts diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts index 345a200b..932f46a8 100644 --- a/app/server/modules/backends/webdav/webdav-backend.ts +++ b/app/server/modules/backends/webdav/webdav-backend.ts @@ -9,6 +9,7 @@ import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; import { executeMount, executeUnmount } from "../utils/backend-utils"; import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; +import { exec } from "~/server/utils/spawn"; const mount = async (config: BackendConfig, path: string) => { logger.debug(`Mounting WebDAV volume ${path}...`); @@ -116,7 +117,14 @@ const unmount = async (path: string) => { return { status: BACKEND_STATUS.unmounted }; } - await executeUnmount(path); + // Try fusermount first (proper way to unmount FUSE), fallback to umount + try { + logger.debug(`Executing fusermount -uz ${path}`); + await exec({ command: "fusermount", args: ["-uz", path], timeout: 5000 }); + } catch (err) { + logger.debug(`fusermount failed, falling back to umount: ${toMessage(err)}`); + await executeUnmount(path); + } await fs.rmdir(path).catch(() => {}); diff --git a/app/test/integration/backends/Dockerfile.tester b/app/test/integration/backends/Dockerfile.tester new file mode 100644 index 00000000..fe0522b7 --- /dev/null +++ b/app/test/integration/backends/Dockerfile.tester @@ -0,0 +1,19 @@ +FROM oven/bun:latest + +RUN apt-get update && apt-get install -y \ + cifs-utils \ + nfs-common \ + davfs2 \ + sshfs \ + kmod \ + iputils-ping \ + netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m davuser + +# Enable user_allow_other for FUSE mounts +RUN echo "user_allow_other" >> /etc/fuse.conf + +WORKDIR /app + diff --git a/app/test/integration/backends/docker-compose.yml b/app/test/integration/backends/docker-compose.yml new file mode 100644 index 00000000..31ea3c24 --- /dev/null +++ b/app/test/integration/backends/docker-compose.yml @@ -0,0 +1,57 @@ +services: + # The Test Runner + tester: + build: + context: . + dockerfile: Dockerfile.tester + privileged: true # Required for mount operations + volumes: + - ../../../../:/app + - /lib/modules:/lib/modules:ro # Required for some kernel-level mount drivers (CIFS/NFS) + working_dir: /app + environment: + - NODE_ENV=test + - LOG_LEVEL=debug + command: tail -f /dev/null # Keep alive for exec + + # Mock SMB Server + smb-server: + image: dperson/samba + environment: + - USER=testuser;testpass + - SHARE=testshare;/tmp/samba;yes;no;no;testuser + tmpfs: + - /tmp/samba + + # Mock WebDAV Server + webdav-server: + image: bytemark/webdav + environment: + - AUTH_TYPE=Basic + - USERNAME=testuser + - PASSWORD=testpass + volumes: + - webdav-data:/var/lib/dav/data + + # Mock NFS Server + nfs-server: + image: erichough/nfs-server + privileged: true + environment: + - NFS_EXPORT_0=/exports *(rw,sync,no_subtree_check,no_root_squash,fsid=0) + - NFS_VERSION=4 + volumes: + - nfs-data:/exports + + # Mock SFTP Server + sftp-server: + image: atmoz/sftp + command: testuser:testpass:1001 + volumes: + - sftp-data:/home/testuser + +volumes: + webdav-data: + nfs-data: + sftp-data: + diff --git a/app/test/integration/backends/nfs.test.ts b/app/test/integration/backends/nfs.test.ts new file mode 100644 index 00000000..8367a901 --- /dev/null +++ b/app/test/integration/backends/nfs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { makeNfsBackend } from "../../../server/modules/backends/nfs/nfs-backend"; +import { BACKEND_STATUS } from "../../../schemas/volumes"; +import * as fs from "node:fs/promises"; + +describe("NFS Backend Integration", () => { + const mountPath = "/tmp/test-mount-nfs"; + + const config = { + backend: "nfs" as const, + server: "nfs-server", + exportPath: "/", + port: 2049, + version: "4" as const, + }; + + beforeAll(async () => { + await fs.rm(mountPath, { recursive: true, force: true }).catch(() => {}); + }); + + it("should mount, check health, and unmount successfully", async () => { + const backend = makeNfsBackend(config, mountPath); + + // 1. Mount + const mountResult = await backend.mount(); + expect(mountResult.status).toBe(BACKEND_STATUS.mounted); + + // 2. Health Check + const healthResult = await backend.checkHealth(); + expect(healthResult.status).toBe(BACKEND_STATUS.mounted); + + // 3. Write/Read test + const testFile = `${mountPath}/test-nfs-${Date.now()}.txt`; + await fs.writeFile(testFile, "hello from nfs integration"); + const content = await fs.readFile(testFile, "utf-8"); + expect(content).toBe("hello from nfs integration"); + + // 4. Unmount + const unmountResult = await backend.unmount(); + expect(unmountResult.status).toBe(BACKEND_STATUS.unmounted); + }, 60000); +}); diff --git a/app/test/integration/backends/run.sh b/app/test/integration/backends/run.sh new file mode 100755 index 00000000..dc21a1a3 --- /dev/null +++ b/app/test/integration/backends/run.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +echo "Starting integration sandbox..." +docker compose up -d --build + +while ! docker compose exec tester nc -z smb-server 445; do + echo "Waiting for SMB..." + sleep 2 +done + +while ! docker compose exec tester nc -z nfs-server 2049; do + echo "Waiting for NFS..." + sleep 2 +done + +while ! docker compose exec tester nc -z webdav-server 80; do + echo "Waiting for WebDAV..." + sleep 2 +done + +while ! docker compose exec tester nc -z sftp-server 22; do + echo "Waiting for SFTP..." + sleep 2 +done + +sleep 5 + +docker compose exec tester bun test --timeout 30000 ./app/test/integration/backends/ + +docker compose down diff --git a/app/test/integration/backends/sftp.test.ts b/app/test/integration/backends/sftp.test.ts new file mode 100644 index 00000000..c96f70f9 --- /dev/null +++ b/app/test/integration/backends/sftp.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { makeSftpBackend } from "../../../server/modules/backends/sftp/sftp-backend"; +import { BACKEND_STATUS } from "../../../schemas/volumes"; +import * as fs from "node:fs/promises"; + +describe("SFTP Backend Integration", () => { + const mountPath = "/tmp/test-mount-sftp"; + + const config = { + backend: "sftp" as const, + host: "sftp-server", + username: "testuser", + password: "testpass", + path: "", + port: 22, + skipHostKeyCheck: true, + }; + + beforeAll(async () => { + await fs.rm(mountPath, { recursive: true, force: true }).catch(() => {}); + }); + + it("should mount, check health, and unmount successfully", async () => { + // SKIPPED: The atmoz/sftp Docker image sets restrictive permissions on the home + // directory that prevent writes even when mounting as root. This is a test infrastructure + // limitation, not an issue with the SFTP backend code itself. In production, + // SFTP servers typically have properly configured writable directories. + const backend = makeSftpBackend(config, mountPath); + + // 1. Mount + const mountResult = await backend.mount(); + expect(mountResult.status).toBe(BACKEND_STATUS.mounted); + + // 2. Health Check + const healthResult = await backend.checkHealth(); + expect(healthResult.status).toBe(BACKEND_STATUS.mounted); + + // 3. Write/Read test - Write to writable directory + const testFile = `${mountPath}/test-sftp-${Date.now()}.txt`; + await fs.writeFile(testFile, "hello from sftp integration"); + const content = await fs.readFile(testFile, "utf-8"); + expect(content).toBe("hello from sftp integration"); + + // 4. Unmount + const unmountResult = await backend.unmount(); + expect(unmountResult.status).toBe(BACKEND_STATUS.unmounted); + }, 60000); +}); diff --git a/app/test/integration/backends/smb.test.ts b/app/test/integration/backends/smb.test.ts new file mode 100644 index 00000000..a5c380c9 --- /dev/null +++ b/app/test/integration/backends/smb.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { makeSmbBackend } from "../../../server/modules/backends/smb/smb-backend"; +import { BACKEND_STATUS } from "../../../schemas/volumes"; +import * as fs from "node:fs/promises"; + +describe("SMB Backend Integration", () => { + const mountPath = "/tmp/test-mount-smb"; + + const config = { + backend: "smb" as const, + server: "smb-server", + share: "testshare", + username: "testuser", + password: "testpass", + vers: "3.0" as const, + port: 445, + }; + + beforeAll(async () => { + await fs.rm(mountPath, { recursive: true, force: true }).catch(() => {}); + }); + + it("should mount, check health, and unmount successfully", async () => { + const backend = makeSmbBackend(config, mountPath); + + // 1. Mount + const mountResult = await backend.mount(); + expect(mountResult.status).toBe(BACKEND_STATUS.mounted); + + // 2. Health Check + const healthResult = await backend.checkHealth(); + expect(healthResult.status).toBe(BACKEND_STATUS.mounted); + + // 3. Write/Read test (Verify it's actually usable) + const testFile = `${mountPath}/test-file-${Date.now()}.txt`; + await fs.writeFile(testFile, "hello from integration test"); + const content = await fs.readFile(testFile, "utf-8"); + expect(content).toBe("hello from integration test"); + + // 4. Unmount + const unmountResult = await backend.unmount(); + expect(unmountResult.status).toBe(BACKEND_STATUS.unmounted); + + // 5. Verify unmounted + const postUnmountHealth = await backend.checkHealth(); + expect(postUnmountHealth.status).toBe(BACKEND_STATUS.error); + }); +}); diff --git a/app/test/integration/backends/webdav.test.ts b/app/test/integration/backends/webdav.test.ts new file mode 100644 index 00000000..c310e667 --- /dev/null +++ b/app/test/integration/backends/webdav.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { makeWebdavBackend } from "../../../server/modules/backends/webdav/webdav-backend"; +import { BACKEND_STATUS } from "../../../schemas/volumes"; +import * as fs from "node:fs/promises"; + +describe("WebDAV Backend Integration", () => { + const mountPath = "/tmp/test-mount-webdav"; + + const config = { + backend: "webdav" as const, + server: "webdav-server", + path: "/", + username: "testuser", + password: "testpass", + port: 80, + ssl: false, + }; + + beforeAll(async () => { + await fs.rm(mountPath, { recursive: true, force: true }).catch(() => {}); + + await fs.mkdir("/etc/davfs2", { recursive: true }).catch(() => {}); + + const davConfig = "trust_server_cert 1\nuse_locks 0\n"; + await fs.writeFile("/etc/davfs2/davfs2.conf", davConfig); + }); + + it("should mount, check health, and unmount successfully", async () => { + const backend = makeWebdavBackend(config, mountPath); + + // 1. Mount + const mountResult = await backend.mount(); + expect(mountResult.status).toBe(BACKEND_STATUS.mounted); + + // 2. Health Check + const healthResult = await backend.checkHealth(); + expect(healthResult.status).toBe(BACKEND_STATUS.mounted); + + // 3. Write/Read test + const testFile = `${mountPath}/test-webdav-${Date.now()}.txt`; + await fs.writeFile(testFile, "hello from webdav integration"); + const content = await fs.readFile(testFile, "utf-8"); + expect(content).toBe("hello from webdav integration"); + + // 4. Unmount + const unmountResult = await backend.unmount(); + expect(unmountResult.status).toBe(BACKEND_STATUS.unmounted); + }, 60000); +});