From 3ad38ddde7bb4ac855a55437e02985673eb36d90 Mon Sep 17 00:00:00 2001 From: Bradley Nelson Date: Tue, 21 Jul 2026 18:19:03 -0600 Subject: [PATCH] test(mounts): admin CRUD hurl coverage + frontend mounts-tab tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the two coverage gaps in the external-mounts feature — the admin REST surface and the admin UI tab both previously had no automated tests. Backend (hurl): - tests/api/external_mounts.hurl — black-box coverage of the admin mount endpoints (list / create / delete): input validation (empty name, non-existent host path → 400), create → 201 with the full view, the mount appears in the list, delete → 204 then 404, and the /api/admin middleware denies a non-admin on every verb → 403. - server.env: enable OXICLOUD_ENABLE_EXTERNAL_MOUNTS for the test server; run.sh: register the new scenario. Frontend (vitest): - admin/page.test.ts — opening the mounts tab loads + lists mounts, the create form calls createExternalMount, and delete goes through the confirm modal to deleteExternalMount. Tooling: - devenv.nix: add b3sum (used by tests/api/run.sh's storage-integrity check). --- devenv.nix | 1 + frontend/src/routes/admin/page.test.ts | 60 ++++++++ tests/api/external_mounts.hurl | 183 +++++++++++++++++++++++++ tests/api/run.sh | 1 + tests/common/server.env | 3 + 5 files changed, 248 insertions(+) create mode 100644 tests/api/external_mounts.hurl diff --git a/devenv.nix b/devenv.nix index 4f837545..d3504f35 100644 --- a/devenv.nix +++ b/devenv.nix @@ -36,6 +36,7 @@ # API/WebDAV functional tests + the DB-readiness probe in tests/common/spawn-db.sh hurl netcat-gnu # provides `nc` + b3sum # storage-integrity check in tests/api/run.sh ]; # Dev Postgres on :5432, matching DATABASE_URL in example.env diff --git a/frontend/src/routes/admin/page.test.ts b/frontend/src/routes/admin/page.test.ts index 9ad39678..8f26dc44 100644 --- a/frontend/src/routes/admin/page.test.ts +++ b/frontend/src/routes/admin/page.test.ts @@ -9,10 +9,13 @@ vi.mock('$lib/stores/session.svelte', () => ({ session })); vi.mock('$lib/stores/ui.svelte', () => ({ ui })); vi.mock('$lib/api/endpoints/admin', () => ({ clearPluginLogs: vi.fn(), + createExternalMount: vi.fn(), createUser: vi.fn(), + deleteExternalMount: vi.fn(), deletePlugin: vi.fn(), deleteUser: vi.fn(), getDashboard: vi.fn(), + listExternalMounts: vi.fn(), getMigration: vi.fn(), getOidcSettings: vi.fn(), getPluginLogs: vi.fn(), @@ -71,6 +74,17 @@ const user = { is_external: false }; +const mount = { + mount_folder_id: 'mnt-1', + name: 'Media', + kind: 'local_fs', + owner_id: 'admin', + read_only: true, + drive_id: 'd1', + mount_path: 'Personal/Media', + config: { path: '/srv/media', read_only: true } +}; + beforeEach(() => { vi.clearAllMocks(); m(admin.getDashboard).mockResolvedValue(dashboard); @@ -104,6 +118,7 @@ beforeEach(() => { from: 'a@x.test', user_state: 'unset' }); + m(admin.listExternalMounts).mockResolvedValue([mount]); }); it('loads the dashboard on mount', async () => { @@ -160,6 +175,51 @@ it('loads plugins when the plugins tab is opened', async () => { await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled()); }); +it('loads external mounts when the mounts tab is opened and lists them', async () => { + render(AdminPage); + await fireEvent.click(await screen.findByTestId('admin-mounts-tab')); + await waitFor(() => expect(admin.listExternalMounts).toHaveBeenCalled()); + // The configured mount is rendered in the table. + expect(await screen.findByText('Media')).toBeTruthy(); +}); + +it('creates a mount from the mounts form', async () => { + // A distinct mount (new id) so the keyed {#each} doesn't collide with + // the one already loaded by listExternalMounts. + m(admin.createExternalMount).mockResolvedValue({ + ...mount, + mount_folder_id: 'mnt-2', + name: 'Photos', + read_only: false, + mount_path: 'Personal/Photos', + config: { path: '/srv/photos', read_only: false } + }); + render(AdminPage); + await fireEvent.click(await screen.findByTestId('admin-mounts-tab')); + await fireEvent.input(await screen.findByTestId('mount-name'), { + target: { value: 'Photos' } + }); + await fireEvent.input(screen.getByTestId('mount-path'), { + target: { value: '/srv/photos' } + }); + await fireEvent.click(screen.getByTestId('mount-create')); + await waitFor(() => + expect(admin.createExternalMount).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Photos', host_path: '/srv/photos' }) + ) + ); +}); + +it('deletes a mount through the confirm modal', async () => { + m(admin.deleteExternalMount).mockResolvedValue(undefined); + render(AdminPage); + await fireEvent.click(await screen.findByTestId('admin-mounts-tab')); + await fireEvent.click(await screen.findByTestId('mount-delete')); + // deleteMount() gates on the styled confirm modal. + await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn')); + await waitFor(() => expect(admin.deleteExternalMount).toHaveBeenCalledWith('mnt-1')); +}); + it("toggles a user's role through the confirm modal", async () => { m(admin.setUserRole).mockResolvedValue(undefined); render(AdminPage); diff --git a/tests/api/external_mounts.hurl b/tests/api/external_mounts.hurl new file mode 100644 index 00000000..3bc54756 --- /dev/null +++ b/tests/api/external_mounts.hurl @@ -0,0 +1,183 @@ +# ============================================================= +# OxiCloud — External file mounts: admin CRUD + authz gate +# ============================================================= +# Black-box HTTP coverage for the admin mount endpoints +# (`admin_external_mounts.rs`), which previously had no +# handler- or protocol-level tests — only the repository, +# factory and provider layers underneath were covered. +# +# Endpoints under test (all `/api/admin/*`, admin-gated by the +# middleware layer): +# * GET /api/admin/external-mounts — list +# * POST /api/admin/external-mounts — create +# * DELETE /api/admin/external-mounts/{id} — delete +# +# This test pins: +# * Input validation: empty name → 400; non-existent host +# path (invalid provider config) → 400. +# * Create returns 201 with the full mount view; the mount is +# then visible in the list with the config echoed back. +# * Delete returns 204 and removes it from the list; a second +# delete of the same id → 404 (anti-enum / idempotency). +# * Non-admin (bob) is denied on list / create / delete → 403 +# (the `/api/admin/*` middleware gate; the handler never runs). +# +# Requires OXICLOUD_ENABLE_EXTERNAL_MOUNTS=true (server.env). +# `/tmp` is used as the host path — always present on the CI +# runner and validated by the local_fs provider factory. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup — admin login + a non-admin (bob) for the deny checks. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +# Anti-enum registration (200 whether or not it already exists). +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "username": "mount_bob", + "email": "mount_bob@example.com", + "password": "MountBobPassword1!" +} + +HTTP 200 + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "mount_bob", "password": "MountBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Validation: empty name is rejected before any +# filesystem or DB work happens. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/external-mounts +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "", "host_path": "/tmp" } + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Validation: a host path that does not exist fails the +# provider-config build → 400 (never a 500). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/external-mounts +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "bad-mount", "host_path": "/no/such/path/oxicloud-does-not-exist" } + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Create a valid read-only mount → 201 with the full +# mount view. Capture the id for the list/delete steps. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/external-mounts +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "hurl-mount-test", "host_path": "/tmp", "read_only": true } + +HTTP 201 +[Captures] +mount_id: jsonpath "$.mount_folder_id" +[Asserts] +jsonpath "$.mount_folder_id" isString +jsonpath "$.name" == "hurl-mount-test" +jsonpath "$.kind" == "local_fs" +jsonpath "$.read_only" == true +jsonpath "$.owner_id" isString +jsonpath "$.drive_id" isString +jsonpath "$.mount_path" isString +jsonpath "$.config.path" == "/tmp" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — The mount is now listed, with the config echoed back. +# This test is the only mount-creator in the suite and +# runs against a fresh DB, so the list holds exactly the +# one mount just created. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/admin/external-mounts +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$" count == 1 +jsonpath "$[0].mount_folder_id" == "{{mount_id}}" +jsonpath "$[0].name" == "hurl-mount-test" +jsonpath "$[0].read_only" == true +jsonpath "$[0].kind" == "local_fs" +jsonpath "$[0].config.path" == "/tmp" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Non-admin (bob) is denied on every verb. The +# `/api/admin/*` middleware layer returns 403 before +# the handler runs — no hand-rolled check in the handler. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/admin/external-mounts +Authorization: Bearer {{bob_token}} + +HTTP 403 + + +POST {{base_url}}/api/admin/external-mounts +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "name": "bob-mount", "host_path": "/tmp" } + +HTTP 403 + + +DELETE {{base_url}}/api/admin/external-mounts/{{mount_id}} +Authorization: Bearer {{bob_token}} + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Admin deletes the mount → 204. The host filesystem +# content is untouched; only the row + root folder go. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/external-mounts/{{mount_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — It is gone from the list. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/admin/external-mounts +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Deleting the same id again → 404 (already removed). +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/external-mounts/{{mount_id}} +Authorization: Bearer {{admin_token}} + +HTTP 404 diff --git a/tests/api/run.sh b/tests/api/run.sh index 4a41ef0b..521d0275 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -188,6 +188,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/nc_second_user_setup.hurl" \ "$API_DIR/nc_admin_views_other_user.hurl" \ "$API_DIR/admin_user_ops.hurl" \ + "$API_DIR/external_mounts.hurl" \ "$API_DIR/chunked_upload_cap.hurl" \ "$API_DIR/nc_auth_failures.hurl" \ "$API_DIR/dedup_create.hurl" \ diff --git a/tests/common/server.env b/tests/common/server.env index 254e6c12..15d63285 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -12,6 +12,9 @@ OXICLOUD_ENABLE_TRASH=true OXICLOUD_ENABLE_SEARCH=true OXICLOUD_ENABLE_FILE_SHARING=true OXICLOUD_ENABLE_MUSIC=true +# External file mounts — enabled so external_mounts.hurl can exercise the +# admin CRUD endpoints against a live mount registry. +OXICLOUD_ENABLE_EXTERNAL_MOUNTS=true OXICLOUD_EXPOSE_SYSTEM_USERS=true OXICLOUD_WOPI_ENABLED=true # Fixed secret so the Hurl WOPI test can hand-craft valid access