test(e2e): webdav + nextcloud full e2e test coverage

add a full coverage of Webdav and Nextcloud
    purpose: prepare move to Drives and ensure no regression at all

    test scenarios are in docs/plan/BASELINE_TESTS_NC_WEBDAV.md

    current existing bugs identified via these tests:

      ┌──────────┬─────────┬────────────────────────────────────────────────────────────────────────────────────────────────────┐
      │   Bug    │ Surface │                                            Pin location                                            │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ G4/G5/K5 │ NC      │ AlreadyExists → 500 instead of 412 (handle_move + trashbin restore)                                │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ G9       │ NC      │ Folder DELETE not row-recursive — orphan descendants stay live                                     │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ M5/M7    │ Native  │ resolve_path_for_user mismatch — PUT writes, GET reads via lenient lookup, MOVE/DELETE can't find  │
      │          │         │ via strict                                                                                         │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ M8       │ Native  │ COPY discards destination filename — collides with source                                          │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ N2       │ Native  │ LOCK creates the token, mutators don't check it — class-2 advertisement is aspirational            │
      └──────────┴─────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
This commit is contained in:
Edouard Vanbelle
2026-06-10 22:59:06 +02:00
parent dac299fea6
commit 595273277b
23 changed files with 4072 additions and 18 deletions
+107
View File
@@ -0,0 +1,107 @@
# =============================================================
# OxiCloud — Baseline: admin views another user's OCS profile
# =============================================================
# C4 from BASELINE_TESTS_NC_WEBDAV.md.
#
# Deferred from Batch 1 because it needed the bob fixture
# that `nc_second_user_setup.hurl` now provides. Pins the
# behaviour of the existing rule in
# `interfaces/nextcloud/ocs_handler.rs::user_provisioning_response`:
#
# if user.username != userid && user.role != "admin" {
# return Json(ocs_err(403, ...)).into_response();
# }
#
# i.e. you can read your own profile always; you can read
# anyone's profile if you're admin. Bob is not admin, so bob
# CANNOT read admin's profile (the symmetric assertion).
#
# Uses admin's app password for Basic Auth (same pattern as
# `nc_ocs_user_info.hurl`).
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup 1 — JWT login as admin + mint NC app password.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_jwt: jsonpath "$.access_token"
POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{admin_jwt}}
Content-Type: application/json
{ "label": "nc_admin_views_other_user hurl test" }
HTTP 200
[Captures]
admin_nc_user: jsonpath "$.username"
admin_nc_pw: jsonpath "$.password"
admin_nc_pw_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Setup 2 — JWT login as bob + mint NC app password.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "bob", "password": "BobPassword1!" }
HTTP 200
[Captures]
bob_jwt: jsonpath "$.access_token"
POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{bob_jwt}}
Content-Type: application/json
{ "label": "nc_admin_views_other_user hurl test (bob)" }
HTTP 200
[Captures]
bob_nc_user: jsonpath "$.username"
bob_nc_pw: jsonpath "$.password"
bob_nc_pw_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# C4-positive — admin CAN read bob's OCS provisioning profile
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/users/bob?format=json
[BasicAuth]
{{admin_nc_user}}: {{admin_nc_pw}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.statuscode" == 100
jsonpath "$.ocs.data.id" == "bob"
jsonpath "$.ocs.data.email" == "bob@example.com"
# ─────────────────────────────────────────────────────────────
# C4-symmetric — bob (non-admin) CANNOT read admin's profile
# (proves the admin-only branch isn't a no-op)
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json
[BasicAuth]
{{bob_nc_user}}: {{bob_nc_pw}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.statuscode" == 403
jsonpath "$.ocs.meta.status" == "failure"
# ─────────────────────────────────────────────────────────────
# Teardown — revoke both app passwords.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/auth/app-passwords/{{admin_nc_pw_id}}
Authorization: Bearer {{admin_jwt}}
HTTP 200
DELETE {{base_url}}/api/auth/app-passwords/{{bob_nc_pw_id}}
Authorization: Bearer {{bob_jwt}}
HTTP 200
+282
View File
@@ -0,0 +1,282 @@
# =============================================================
# OxiCloud — Baseline: NC Basic Auth failure modes
# =============================================================
# Group P from BASELINE_TESTS_NC_WEBDAV.md.
#
# Coverage:
# P1 — no Authorization header → 401 + WWW-Authenticate
# P2 — wrong password (real user) → 401
# P3 — N wrong attempts from same IP against a THROWAWAY
# username trip the per-(account,IP) lockout
# P4 — per-IP lockout scope (the #323 regression guard):
# 6 wrong attempts from spoofed X-Forwarded-For: IP1
# lock (admin, IP1), but the SAME correct credential
# from spoofed X-Forwarded-For: IP2 still succeeds.
# Depends on OXICLOUD_TRUST_PROXY_HEADERS=true in
# tests/common/server.env so the server honours the
# X-Forwarded-For header on localhost.
#
# Deliberately NOT covered here:
# P5 — External user attempts NC Basic Auth. Externals can't
# mint app passwords in the first place (the upstream gate
# is asserted in tests/api/external_users.hurl), so the
# in-middleware belt-and-braces check is unreachable via
# a black-box HTTP test. Verified by code inspection.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup 1 — JWT login (needed to mint the app password for P4's
# positive control).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
jwt: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Setup 2 — Mint admin's NC app password.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{jwt}}
Content-Type: application/json
{ "label": "nc_auth_failures P4 positive control" }
HTTP 200
[Captures]
nc_username: jsonpath "$.username"
nc_password: jsonpath "$.password"
ap_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# P1 — PROPFIND without any Authorization header
# → 401 with `WWW-Authenticate: Basic realm="OxiCloud"`.
# NC desktop relies on this challenge to know it should
# offer credentials at all.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
HTTP 401
[Asserts]
header "WWW-Authenticate" contains "Basic"
# ─────────────────────────────────────────────────────────────
# P2 — Wrong password against a real user
# → 401 with the same WWW-Authenticate challenge.
# Anti-enumeration: response shape identical whether the
# user exists or not.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
[BasicAuth]
{{username}}: definitely-wrong-password
HTTP 401
[Asserts]
header "WWW-Authenticate" contains "Basic"
# ─────────────────────────────────────────────────────────────
# P3 — Lockout trip
#
# Consecutive bad-credential attempts against a throwaway
# username trip the per-(account, IP) lockout. The default
# threshold is 5 (`OXICLOUD_LOCKOUT_MAX_FAILURES = 5`) and
# `tests/common/server.env` does NOT override it — the only
# raised values in that env are the broader login / refresh /
# register rate-limits, which prevent the rate-limiter from
# firing AHEAD of the lockout under sustained test traffic.
# The lockout itself still engages on the 6th attempt.
#
# Once engaged, every subsequent attempt for the same
# (account, IP) pair short-circuits to 401 from the lockout
# check, BEFORE app-password verification runs. The audit log
# fires `WARN account_temporarily_locked` from
# `login_lockout_service.rs` when the cap is hit — useful
# correlation signal during a real run.
#
# A throwaway username (`nc-lockout-probe-…`) is used because
# the lockout is keyed by (username, IP); locking a throwaway
# pair never poisons admin's auth path, so downstream Hurl
# tests in run.sh that authenticate as admin keep working.
#
# Limitation of a black-box HTTP probe: the wire response is
# 401 with the same WWW-Authenticate header whether the 401
# comes from "lockout engaged" or "still just rejecting bad
# creds" — both look the same on the wire. Verification of
# the lockout-engaged branch specifically lives in the unit
# tests (`login_lockout_service.rs::tests`). What this Hurl
# test guards is that 7 consecutive attempts keep returning
# the same 401 shape (no 500s, no header drift), and the
# server-side audit log confirms the lockout engaged at 5.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/
[BasicAuth]
nc-lockout-probe-001: bad-1
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/
[BasicAuth]
nc-lockout-probe-001: bad-2
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/
[BasicAuth]
nc-lockout-probe-001: bad-3
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/
[BasicAuth]
nc-lockout-probe-001: bad-4
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/
[BasicAuth]
nc-lockout-probe-001: bad-5
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/
[BasicAuth]
nc-lockout-probe-001: bad-6
HTTP 401
# 7th attempt: account+IP is now locked at the middleware level.
# Continues to return 401.
PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/
[BasicAuth]
nc-lockout-probe-001: bad-7
HTTP 401
[Asserts]
header "WWW-Authenticate" contains "Basic"
# ─────────────────────────────────────────────────────────────
# P4 — Per-IP lockout scope (#323 regression guard)
#
# Goal: prove that locking out (admin, IP1) does NOT lock out
# (admin, IP2) — the lockout is keyed by *both* parts, not by
# username alone. This was the gap the reporter demonstrated:
# an attacker spoofing X-Forwarded-For could lock a legitimate
# user out from their own IP. The fix scoped the key.
#
# Mechanic:
# 1. Pre-check: admin's app password works from the test's
# default client IP (127.0.0.1, no X-Forwarded-For).
# 2. 6 wrong attempts with X-Forwarded-For: 10.0.0.1 trip the
# lockout for (admin, 10.0.0.1).
# 3. CORRECT app password with X-Forwarded-For: 10.0.0.1 →
# 401 (still locked from THIS IP — positive demonstration
# that the lockout actually engaged, not just chance).
# 4. CORRECT app password with X-Forwarded-For: 10.0.0.2 →
# 207 (NOT locked from this IP — the load-bearing
# assertion of P4).
#
# Why this doesn't break the rest of the suite: every other
# test runs from the default client IP (127.0.0.1) without
# X-Forwarded-For, so (admin, 127.0.0.1) is untouched. The
# lockouts placed here are on (admin, 10.0.0.1) and
# (admin, 10.0.0.2 — released by the success), neither of
# which any other test touches.
#
# Requires: OXICLOUD_TRUST_PROXY_HEADERS=true in
# tests/common/server.env.
# ─────────────────────────────────────────────────────────────
# ── Pre-check: app password works with no X-Forwarded-For ───
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 207
# ── Step 1: burn the lockout for (admin, 10.0.0.1) ──────────
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.1
[BasicAuth]
{{username}}: p4-bad-1
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.1
[BasicAuth]
{{username}}: p4-bad-2
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.1
[BasicAuth]
{{username}}: p4-bad-3
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.1
[BasicAuth]
{{username}}: p4-bad-4
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.1
[BasicAuth]
{{username}}: p4-bad-5
HTTP 401
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.1
[BasicAuth]
{{username}}: p4-bad-6
HTTP 401
# ── Step 2: CORRECT app password from IP1 — still 401 ──────
# This is the positive demonstration that the lockout engaged.
# If this were 207, the lockout would not have fired and the
# subsequent IP2 success wouldn't prove anything.
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.1
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 401
# ── Step 3: CORRECT app password from IP2 — 207 ────────────
# The load-bearing assertion of P4: a successful auth from a
# DIFFERENT spoofed source IP proves the lockout was scoped
# to (admin, 10.0.0.1) and not to admin alone.
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/
X-Forwarded-For: 10.0.0.2
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 207
# ─────────────────────────────────────────────────────────────
# Teardown — Revoke the app password we minted. Keeps the
# app-passwords table clean across re-runs.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
Authorization: Bearer {{jwt}}
HTTP 200
+109
View File
@@ -0,0 +1,109 @@
# =============================================================
# OxiCloud — Baseline: NC avatar + preview
# =============================================================
# Group L from BASELINE_TESTS_NC_WEBDAV.md (3 scenarios).
#
# All NC routes (including avatars and previews) sit behind the
# `basic_auth_middleware` wired in `interfaces/nextcloud/
# routes.rs:174`. Even though the avatar payload is described as
# "decorative, not security-critical" in `avatar_handler.rs`,
# the request itself still requires a valid Basic Auth identity.
# Both L1 and L3 therefore mint an app password and pass it.
#
# L1 / L3 pin the avatar handler's actual contract: once
# authenticated, it ALWAYS returns 200 — stored profile image
# when present, SVG-with-initials otherwise (including for
# users that don't exist at all).
#
# L2 hits the authenticated preview endpoint with a fake file
# id and pins the not-found path. A positive-path preview test
# would require seeding an image file and resolving its NC
# numeric id, which is more setup than this baseline needs.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup 1 — JWT login (gives us a Bearer token to mint the
# app password used by the avatar Basic Auth + the
# preview JWT auth).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
jwt: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Setup 2 — Mint an app password for the L1/L3 Basic Auth.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{jwt}}
Content-Type: application/json
{ "label": "nc_avatar_preview hurl test" }
HTTP 200
[Captures]
nc_username: jsonpath "$.username"
nc_password: jsonpath "$.password"
ap_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# L1 — Avatar for an existing user (admin) always 200
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/index.php/avatar/{{username}}/64
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 200
[Asserts]
header "Content-Type" startsWith "image/"
# ─────────────────────────────────────────────────────────────
# L3 — Avatar for a nonexistent user
#
# Pinned current behaviour: 200 with the SVG-initials fallback.
# The handler explicitly comments "decorative, not security-
# critical" — it never returns 404 for an unknown name; it
# renders initials from whatever string the caller passed.
# RFC strictness would suggest 404 here, but NC desktop / Web
# UI happily render the SVG.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/index.php/avatar/nonexistent-user-deadbeef/64
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 200
[Asserts]
header "Content-Type" startsWith "image/"
# ─────────────────────────────────────────────────────────────
# L2 — Preview of a non-existent file id → 404
#
# fileId=99999999 is well below any real NC id we'd ever
# assign, so this exercises the "file not found" branch
# without depending on a seeded image fixture.
#
# Auth: same Basic Auth as L1/L3 — the NC `/index.php/*`
# surface is uniformly behind `basic_auth_middleware`, so Bearer
# JWT is rejected at the middleware boundary before the handler
# even sees the request.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/index.php/core/preview?fileId=99999999&x=128&y=128
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Teardown — revoke the app password.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
Authorization: Bearer {{jwt}}
HTTP 200
+74
View File
@@ -0,0 +1,74 @@
# =============================================================
# OxiCloud — Baseline: NC Login Flow v2
# =============================================================
# Group B from BASELINE_TESTS_NC_WEBDAV.md.
#
# Login Flow v2 is how NC desktop / iOS / Android bootstrap an
# app password without ever seeing the user's real password.
# Breaking the JSON shape returned by `/index.php/login/v2` or
# `…/poll` means no new desktop client can pair.
#
# Coverage:
# B1 — POST /index.php/login/v2 returns { login, poll: {…} }
# B2 — POST …/login/v2/poll before grant → 404
# B5 — POST …/login/v2/poll with unknown / expired token → 404
#
# Deliberately deferred:
# B3 — Simulate the browser-side grant. This requires a
# multi-step interaction with the grant page (HTML form
# POST) that is awkward in Hurl and tied to the device-
# auth-grant internals. Covered separately by
# tests/webdav/ once the bash tooling for browser
# simulation lands.
# B4 — Poll AFTER grant. Same dependency as B3.
# =============================================================
# ─────────────────────────────────────────────────────────────
# B1 — POST /index.php/login/v2 (no auth)
# → 200 with JSON `{ login: <url>, poll: { token, endpoint } }`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/index.php/login/v2
HTTP 200
[Captures]
poll_token: jsonpath "$.poll.token"
poll_endpoint: jsonpath "$.poll.endpoint"
login_url: jsonpath "$.login"
[Asserts]
jsonpath "$.poll.token" exists
jsonpath "$.poll.endpoint" exists
jsonpath "$.login" exists
# The login URL embeds the flow token as a PATH segment
# (`/login/v2/flow/<token>`), not a query param — that's what
# the NC desktop client follows after init.
jsonpath "$.login" contains "/login/v2/flow/"
# ─────────────────────────────────────────────────────────────
# B2 — POST /login/v2/poll BEFORE the user grants
# → 404 (NC convention: "not yet ready").
#
# The token here is the one captured in B1, so the server
# recognises the flow exists; it just hasn't been granted
# yet.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/login/v2/poll
[FormParams]
token: {{poll_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# B5 — POST /login/v2/poll with an unknown / never-issued token
# → 404. Same shape as B2 — server doesn't distinguish
# "wrong token" from "right token, not granted yet" on the
# wire (anti-enumeration: a probe can't tell which flows
# exist).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/login/v2/poll
[FormParams]
token: nonexistent-token-deadbeef-cafef00d
HTTP 404
+160
View File
@@ -0,0 +1,160 @@
# =============================================================
# OxiCloud — Baseline: OCS user-info + provisioning
# =============================================================
# Group C from BASELINE_TESTS_NC_WEBDAV.md.
#
# /ocs/v{1,2}.php/cloud/user is what NC desktop reads after
# Login Flow v2 to learn its `data.id` — and that exact string
# is then spliced into every subsequent DAV path
# (`/remote.php/dav/files/{id}/…`). A regression in this shape
# breaks 100% of subsequent syncs.
#
# Coverage:
# C1 — GET /ocs/v1.php/cloud/user → statuscode 100 + payload
# C2 — GET /ocs/v2.php/cloud/user → statuscode 200 + payload
# C3 — GET /ocs/v1.php/cloud/users/admin (self provisioning)
# C5 — GET /ocs/v2.php/apps/files_sharing/api/v1/sharees shape
#
# Deferred:
# C4 — admin reading another user's provisioning profile.
# Needs a second user fixture wired into setup.hurl.
# Tracked as a TODO in BASELINE_TESTS_NC_WEBDAV.md §7.
#
# Setup pattern: this file mints its own app password inline
# (steps 1–2) so it is self-contained and resilient to test
# ordering. The mint uses the JWT-authenticated REST API
# (`POST /api/auth/app-passwords`); the NC-side username
# returned by the response is exactly the value the NC client
# would use as the HTTP Basic Auth username.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup 1 — JWT login (gives us a Bearer token to mint the
# app password).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
jwt: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Setup 2 — Mint an app password for this test file. The
# response carries the plaintext password (shown
# exactly once) and the username to use in Basic Auth.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{jwt}}
Content-Type: application/json
{ "label": "nc_ocs_user_info hurl test" }
HTTP 200
[Captures]
nc_username: jsonpath "$.username"
nc_password: jsonpath "$.password"
ap_id: jsonpath "$.id"
[Asserts]
jsonpath "$.password" matches "^oxicloud-"
# ─────────────────────────────────────────────────────────────
# C1 — GET /ocs/v1.php/cloud/user
# data.id is what NC client splices into DAV URLs.
#
# Note: `handle_user_info` returns `statuscode: 200`
# regardless of /v1.php vs /v2.php (unlike capabilities,
# which switches on ocs_version). C1 and C2 therefore
# both assert 200 here — this is the actual server
# behaviour; if either diverges in future, this is the
# pin that catches it.
#
# Hurl JSONPath doesn't accept `-` in dotted form, so the
# assertion goes against `displayname` (the unhyphenated
# alias the handler also emits) rather than `display-name`.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/user?format=json
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.status" == "ok"
jsonpath "$.ocs.meta.statuscode" == 200
jsonpath "$.ocs.meta.message" == "OK"
jsonpath "$.ocs.data.enabled" == true
jsonpath "$.ocs.data.id" == "{{username}}"
jsonpath "$.ocs.data.email" == "{{email}}"
jsonpath "$.ocs.data.displayname" exists
jsonpath "$.ocs.data.quota.used" exists
jsonpath "$.ocs.data.quota.total" exists
jsonpath "$.ocs.data.quota.free" exists
jsonpath "$.ocs.data.quota.relative" exists
# ─────────────────────────────────────────────────────────────
# C2 — GET /ocs/v2.php/cloud/user
# Same payload shape; OCS v2 envelope reports statuscode 200.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v2.php/cloud/user?format=json
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.status" == "ok"
jsonpath "$.ocs.meta.statuscode" == 200
jsonpath "$.ocs.data.id" == "{{username}}"
jsonpath "$.ocs.data.email" == "{{email}}"
# ─────────────────────────────────────────────────────────────
# C3 — GET /ocs/v1.php/cloud/users/{userid} (self lookup)
# Full provisioning profile: groups, lastLogin, backend.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.statuscode" == 100
jsonpath "$.ocs.data.id" == "{{username}}"
jsonpath "$.ocs.data.email" == "{{email}}"
jsonpath "$.ocs.data.groups" exists
jsonpath "$.ocs.data.backend" exists
jsonpath "$.ocs.data.lastLogin" exists
# ─────────────────────────────────────────────────────────────
# C5 — GET /ocs/v2.php/apps/files_sharing/api/v1/sharees
# Sharees autocomplete shape — NC desktop uses this to
# populate the share-dialog. Even with no shares set up
# yet, the envelope + array slots must exist.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v2.php/apps/files_sharing/api/v1/sharees?format=json&search=ad&itemType=file
[BasicAuth]
{{nc_username}}: {{nc_password}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.status" == "ok"
jsonpath "$.ocs.meta.statuscode" == 200
jsonpath "$.ocs.data.exact" exists
jsonpath "$.ocs.data.exact.users" exists
jsonpath "$.ocs.data.exact.groups" exists
jsonpath "$.ocs.data.users" exists
# ─────────────────────────────────────────────────────────────
# Teardown — Revoke the app password we minted (captured `ap_id`
# from the create response, no list+lookup needed).
# Keeps the test surface clean across re-runs.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
Authorization: Bearer {{jwt}}
HTTP 200
+54
View File
@@ -0,0 +1,54 @@
# =============================================================
# OxiCloud — Baseline: second-user fixture (bob)
# =============================================================
# (Re)creates the `bob` user so the Group O cross-user
# isolation scenarios (and Group C's C4 admin-reads-another-
# user case) have a real second principal to test against.
#
# Run-order: this file runs AFTER `external_users.hurl`, which
# deletes bob at its end, and AFTER `permissions.hurl`, which
# creates+uses bob via the admin API. By the time this file
# runs, bob may or may not exist — the anti-enumeration
# registration endpoint returns 200 either way, so this is
# safe in both states. The login step that follows is the
# actual existence assertion: if bob can log in, downstream
# bob-dependent tests (nc_admin_views_other_user.hurl here
# and test_nc_cross_user_isolation.sh in the webdav suite)
# will work.
#
# Bob's credentials are hardcoded fixtures (not env-driven) so
# downstream test files don't need to coordinate via test.env.
# Bob's password is unique per fixture; no overlap with admin.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Register bob via the email-anti-enumeration flow.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "bob",
"email": "bob@example.com",
"password": "BobPassword1!"
}
HTTP 200
[Asserts]
jsonpath "$.message" contains "request received"
# ─────────────────────────────────────────────────────────────
# Step 2 — Confirm bob can log in (proves registration
# actually landed the account — the anti-enum response
# alone wouldn't tell us).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "bob", "password": "BobPassword1!" }
HTTP 200
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.user.username" == "bob"
jsonpath "$.user.email" == "bob@example.com"
+71
View File
@@ -0,0 +1,71 @@
# =============================================================
# OxiCloud — Baseline: NC status + capabilities
# =============================================================
# Group A from BASELINE_TESTS_NC_WEBDAV.md (4 scenarios).
#
# These four endpoints are what every NC client probes first.
# If any of them returns the wrong shape, NC desktop refuses to
# even attempt sync, so the regression signal is loud and early.
#
# Anonymous (no auth) — these endpoints must be public.
# =============================================================
# ─────────────────────────────────────────────────────────────
# A1 — GET /status.php
# Used by NC client for "is this server installed?".
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/status.php
HTTP 200
[Asserts]
jsonpath "$.installed" == true
jsonpath "$.maintenance" == false
jsonpath "$.version" exists
jsonpath "$.versionstring" exists
jsonpath "$.productname" == "OxiCloud"
# ─────────────────────────────────────────────────────────────
# A2 — GET /index.php/204
# NC mobile connectivity probe. Must be 204, empty body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/index.php/204
HTTP 204
[Asserts]
bytes count == 0
# ─────────────────────────────────────────────────────────────
# A3 — GET /ocs/v1.php/cloud/capabilities
# OCS v1 envelope must report statuscode 100.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/capabilities?format=json
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.status" == "ok"
jsonpath "$.ocs.meta.statuscode" == 100
jsonpath "$.ocs.meta.message" == "OK"
jsonpath "$.ocs.data.version.major" exists
jsonpath "$.ocs.data.capabilities" exists
jsonpath "$.ocs.data.capabilities.theming.name" == "OxiCloud"
# ─────────────────────────────────────────────────────────────
# A4 — GET /ocs/v2.php/cloud/capabilities
# OCS v2 envelope must report statuscode 200 (200 ≡ 100
# semantically — different status enum across versions).
# Payload shape otherwise identical to v1.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v2.php/cloud/capabilities?format=json
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.status" == "ok"
jsonpath "$.ocs.meta.statuscode" == 200
jsonpath "$.ocs.meta.message" == "OK"
jsonpath "$.ocs.data.version.major" exists
jsonpath "$.ocs.data.capabilities" exists
jsonpath "$.ocs.data.capabilities.theming.name" == "OxiCloud"
+40 -12
View File
@@ -62,24 +62,39 @@ OXICLOUD_SERVER_PORT=$SERVER_PORT
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage"
set +a
# ensure storage is empty before starting
echo "Wipe $OXICLOUD_STORAGE_PATH to ensure clean startup"
rm -rf "$OXICLOUD_STORAGE_PATH"
mkdir -p "$OXICLOUD_STORAGE_PATH"
# ensure storage is empty before starting (regex-gated rm -rf)
# shellcheck source=../common/wipe-storage.sh
source "$COMMON/wipe-storage.sh"
wipe_storage "$OXICLOUD_STORAGE_PATH"
# ── 3. Start OxiCloud server ──────────────────────────────────────────────────
BUILD_TARGET="${BUILD_TARGET:-debug}"
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
if [[ -x "$OXICLOUD_BIN" ]]; then
log "Starting pre-built OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..."
"$OXICLOUD_BIN" &
else
log "Building and starting OxiCloud server on port $SERVER_PORT..."
cd "$REPO_ROOT"
cargo run &
# Build synchronously (no time cap — clean builds take minutes) BEFORE
# starting the server, so the `/ready` poll below only times what we
# actually want it to time: server startup, not compilation. Earlier
# this ran `cargo run &` directly, which conflated the two and tripped
# the 120 s readiness timeout on any `cargo clean` run.
if [[ ! -x "$OXICLOUD_BIN" ]]; then
log "Building OxiCloud server ($BUILD_TARGET) — this can take a few minutes after \`cargo clean\`..."
# Cargo's debug profile is the implicit default (`cargo build` alone)
# — there is NO `--profile debug` flag (it would error). Only the
# release path needs an explicit flag.
case "$BUILD_TARGET" in
debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;;
release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;;
*) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;;
esac
fi
if [[ ! -x "$OXICLOUD_BIN" ]]; then
die "Build completed but $OXICLOUD_BIN is missing — wrong BUILD_TARGET?"
fi
log "Starting OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..."
"$OXICLOUD_BIN" &
SERVER_PID=$!
log "Waiting for server at $base_url..."
wait_for_http "$base_url/ready" 120
@@ -100,10 +115,20 @@ fi
# ── 4. Run Hurl tests ─────────────────────────────────────────────────────────
log "Running Hurl tests..."
# NC baseline tests (groups A + B + C from BASELINE_TESTS_NC_WEBDAV.md)
# are interleaved early because they use a separate code surface and
# their failures should not be masked by later test regressions.
# The auth-failure / lockout file (group P) runs LAST — it locks out
# a throwaway username so admin Basic Auth stays usable for everything
# above it.
hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \
"$API_DIR/setup.hurl" \
"$API_DIR/auth_login.hurl" \
"$API_DIR/registration.hurl" \
"$API_DIR/nc_status_capabilities.hurl" \
"$API_DIR/nc_login_flow_v2.hurl" \
"$API_DIR/nc_ocs_user_info.hurl" \
"$API_DIR/nc_avatar_preview.hurl" \
"$API_DIR/files-folders.hurl" \
"$API_DIR/favorites.hurl" \
"$API_DIR/trash.hurl" \
@@ -117,7 +142,10 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/subject_groups.hurl" \
"$API_DIR/grants_nested_groups.hurl" \
"$API_DIR/external_users.hurl" \
"$API_DIR/chunked_upload_cap.hurl"
"$API_DIR/nc_second_user_setup.hurl" \
"$API_DIR/nc_admin_views_other_user.hurl" \
"$API_DIR/chunked_upload_cap.hurl" \
"$API_DIR/nc_auth_failures.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"
+28 -1
View File
@@ -151,6 +151,26 @@ log "API confirms trash is empty."
THUMB_FILES=$(find "$STORAGE_PATH/.thumbnails" -type f 2>/dev/null || true)
BLOB_FILES=$(find "$STORAGE_PATH/.blobs" -type f 2>/dev/null || true)
# Chunked-upload spool. After every chunked-upload session is either
# completed (assembled + promoted) or aborted, this dir MUST be empty
# — a leftover chunk file means a session-cleanup path forgot its
# `remove_dir_all`, which under sustained sync workloads is the
# classic "disk fills up over the weekend" failure mode.
#
# `.uploads/` is the default chunked-upload root when
# `OXICLOUD_CHUNK_DIR` is unset (see `common/di.rs`). REST sessions
# land under `.uploads/<session_id>/`; NC sessions land under
# `.uploads/nextcloud/<user>/<session_id>/`.
#
# We deliberately do NOT check the direct-PUT spool dir here: when
# `OXICLOUD_UPLOAD_TEMP_DIR` is unset (the default in the test env)
# it falls back to the OS temp dir (`/tmp/…`) which is shared with
# the rest of the system and would produce false positives. To
# extend the check to direct-PUT, set OXICLOUD_UPLOAD_TEMP_DIR in
# tests/common/server.env to a path under $STORAGE_PATH and add it
# to the find list below.
UPLOAD_FILES=$(find "$STORAGE_PATH/.uploads" -type f 2>/dev/null || true)
if [[ -n "$THUMB_FILES" ]]; then
THUMB_COUNT=$(echo "$THUMB_FILES" | wc -l | tr -d ' ')
log "Leftover thumbnail files ($THUMB_COUNT):"
@@ -165,4 +185,11 @@ if [[ -n "$BLOB_FILES" ]]; then
fail "$BLOB_COUNT blob file(s) remain on disk after full cleanup"
fi
log "OK — no blobs or thumbnails remain on disk."
if [[ -n "$UPLOAD_FILES" ]]; then
UPLOAD_COUNT=$(echo "$UPLOAD_FILES" | wc -l | tr -d ' ')
log "Leftover chunked-upload files ($UPLOAD_COUNT):"
echo "$UPLOAD_FILES"
fail "$UPLOAD_COUNT chunked-upload file(s) remain in .uploads after full cleanup"
fi
log "OK — no blobs, thumbnails, or chunked-upload leftovers remain on disk."