test(mounts): admin CRUD hurl coverage + frontend mounts-tab tests
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).
This commit is contained in:
@@ -36,6 +36,7 @@
|
|||||||
# API/WebDAV functional tests + the DB-readiness probe in tests/common/spawn-db.sh
|
# API/WebDAV functional tests + the DB-readiness probe in tests/common/spawn-db.sh
|
||||||
hurl
|
hurl
|
||||||
netcat-gnu # provides `nc`
|
netcat-gnu # provides `nc`
|
||||||
|
b3sum # storage-integrity check in tests/api/run.sh
|
||||||
];
|
];
|
||||||
|
|
||||||
# Dev Postgres on :5432, matching DATABASE_URL in example.env
|
# Dev Postgres on :5432, matching DATABASE_URL in example.env
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
|||||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||||
vi.mock('$lib/api/endpoints/admin', () => ({
|
vi.mock('$lib/api/endpoints/admin', () => ({
|
||||||
clearPluginLogs: vi.fn(),
|
clearPluginLogs: vi.fn(),
|
||||||
|
createExternalMount: vi.fn(),
|
||||||
createUser: vi.fn(),
|
createUser: vi.fn(),
|
||||||
|
deleteExternalMount: vi.fn(),
|
||||||
deletePlugin: vi.fn(),
|
deletePlugin: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
getDashboard: vi.fn(),
|
getDashboard: vi.fn(),
|
||||||
|
listExternalMounts: vi.fn(),
|
||||||
getMigration: vi.fn(),
|
getMigration: vi.fn(),
|
||||||
getOidcSettings: vi.fn(),
|
getOidcSettings: vi.fn(),
|
||||||
getPluginLogs: vi.fn(),
|
getPluginLogs: vi.fn(),
|
||||||
@@ -71,6 +74,17 @@ const user = {
|
|||||||
is_external: false
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
m(admin.getDashboard).mockResolvedValue(dashboard);
|
m(admin.getDashboard).mockResolvedValue(dashboard);
|
||||||
@@ -104,6 +118,7 @@ beforeEach(() => {
|
|||||||
from: 'a@x.test',
|
from: 'a@x.test',
|
||||||
user_state: 'unset'
|
user_state: 'unset'
|
||||||
});
|
});
|
||||||
|
m(admin.listExternalMounts).mockResolvedValue([mount]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the dashboard on mount', async () => {
|
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());
|
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 () => {
|
it("toggles a user's role through the confirm modal", async () => {
|
||||||
m(admin.setUserRole).mockResolvedValue(undefined);
|
m(admin.setUserRole).mockResolvedValue(undefined);
|
||||||
render(AdminPage);
|
render(AdminPage);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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_second_user_setup.hurl" \
|
||||||
"$API_DIR/nc_admin_views_other_user.hurl" \
|
"$API_DIR/nc_admin_views_other_user.hurl" \
|
||||||
"$API_DIR/admin_user_ops.hurl" \
|
"$API_DIR/admin_user_ops.hurl" \
|
||||||
|
"$API_DIR/external_mounts.hurl" \
|
||||||
"$API_DIR/chunked_upload_cap.hurl" \
|
"$API_DIR/chunked_upload_cap.hurl" \
|
||||||
"$API_DIR/nc_auth_failures.hurl" \
|
"$API_DIR/nc_auth_failures.hurl" \
|
||||||
"$API_DIR/dedup_create.hurl" \
|
"$API_DIR/dedup_create.hurl" \
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ OXICLOUD_ENABLE_TRASH=true
|
|||||||
OXICLOUD_ENABLE_SEARCH=true
|
OXICLOUD_ENABLE_SEARCH=true
|
||||||
OXICLOUD_ENABLE_FILE_SHARING=true
|
OXICLOUD_ENABLE_FILE_SHARING=true
|
||||||
OXICLOUD_ENABLE_MUSIC=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_EXPOSE_SYSTEM_USERS=true
|
||||||
OXICLOUD_WOPI_ENABLED=true
|
OXICLOUD_WOPI_ENABLED=true
|
||||||
# Fixed secret so the Hurl WOPI test can hand-craft valid access
|
# Fixed secret so the Hurl WOPI test can hand-craft valid access
|
||||||
|
|||||||
Reference in New Issue
Block a user