Files
Oxicloud/tests/api/external_users.hurl
T
Edouard Vanbelle e94063d96a test(login/register): via password or magic-link
Password login

┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                        Case                        │         Where          │                                          Assertion                                          │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L1  │ Login by username                                  │ auth_login.hurl Case 1 │ 200 + access_token, user.email match                                                        │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L2  │ Login by email (dispatch on @)                     │ auth_login.hurl Case 2 │ 200, same session shape as L1                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L3  │ Bad password on username path                      │ auth_login.hurl Case 3 │ 403 anti-enum                                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L4  │ Bad password on email path                         │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L5  │ Unknown username                                   │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L6  │ Unknown email                                      │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L7  │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │
└─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

Password registration

┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐
│  #  │                       Case                        │            Where             │                        Assertion                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R1  │ Classic username + email + password → uniform 200 │ registration.hurl Step 2     │ anti-enum message contains "request received"           │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R2  │ Login after register works                        │ registration.hurl Step 2b    │ 200 + session for the new user                          │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R3  │ Email collision → uniform 200 (no rewrite)        │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R4  │ Username collision → uniform 200                  │ registration.hurl Step 11    │ same anti-enum shape                                    │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R5  │ Off-domain rejection                              │ registration.hurl Step 12    │ 403 RegistrationDomainNotAllowed                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R6  │ Case-insensitive domain match                     │ registration.hurl Step 12b   │ uniform 200 on charlie@EXAMPLE.COM                      │
└─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘

Magic-link registration (email-only signup)

┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│  #  │                                               Case                                               │             Where             │                   Assertion                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR1 │ Email-only signup → welcome mail queued                                                          │ registration.hurl Step 3      │ uniform 200 + browser-binding cookie set       │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR2 │ Welcome mail contains magic-link URL                                                             │ registration.hurl Step 4      │ captured from mock SMTP                        │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR3 │ PR 22 cross-browser confirmation page                                                            │ registration.hurl Step 5a     │ 200 HTML "different browser"                   │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR4 │ Cookie-bound redemption lands on SPA                                                             │ registration.hurl Step 5b     │ 302 → /files (SvelteKit route, post-migration) │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR5 │ email_verified_at stamped after redemption                                                       │ registration.hurl Step 6      │ field present on /api/auth/me                  │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR6 │ Second magic-link post-signup                                                                    │ registration.hurl Step 7      │ uniform 200                                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle                         │
└─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘

Magic-link login (existing account)

┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐
│  #  │                           Case                           │                Where                 │                             Assertion                              │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML1 │ Baseline password login still works                      │ auth_magic_link_login.hurl Steps 1-2 │ 200                                                                │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML2 │ magic-link/send with email identifier                    │ auth_magic_link_login.hurl Step 3    │ uniform 200 + cookie                                               │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4    │ uniform 200                                                        │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML4 │ Password-user policy: mail actually sent                 │ auth_magic_link_login.hurl Step 5    │ SMTP capture proves permit_magic_link_for_password_users in effect │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML5 │ Redemption creates a session                             │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user                   │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML6 │ Anti-enum on unknown identifier                          │ auth_magic_link_login.hurl Step 8    │ same uniform 200 shape as ML3                                      │
└─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘

OIDC

┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                                  Case                                  │       Where       │                                                        Assertion                                                        │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O1  │ Setup local admin (bootstrap)                                          │ oidc.hurl Step 1  │ 201                                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2  │ Providers endpoint — OIDC visible                                      │ oidc.hurl Step 2  │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2b │ Magic-link/send refused (endpoint layer)                               │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired                                       │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O3  │ Authorize redirect includes PKCE + state                               │ oidc.hurl Step 3  │ 307 to fake IdP                                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O4  │ IdP round-trip + JIT provisioning                                      │ oidc.hurl Step 4  │ Callback lands on /login?oidc_code=…                                                                                    │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O5  │ Code exchange → session cookies                                        │ oidc.hurl Step 5  │ 200 + all three cookies                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O6  │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6  │ every claim reflected on /api/auth/me                                                                                   │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O7  │ Refresh rotation on OIDC session                                       │ oidc.hurl Step 7  │ new access/refresh/CSRF cookies                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O8  │ Refreshed cookies authenticate                                         │ oidc.hurl Step 8  │ 200 on /api/auth/me                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O9  │ Repeat login = same local user (no dup)                                │ oidc.hurl Step 9  │ user_id stable                                                                                                          │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O10 │ Anti-takeover: unverified email → refused                              │ oidc.hurl Step 10 │ 401/403                                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O11 │ One-time code replay refused                                           │ oidc.hurl Step 11 │ second /exchange → 401                                                                                                  │
└─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

test
2026-07-14 03:16:25 +02:00

555 lines
24 KiB
Plaintext

# =============================================================
# OxiCloud — invite-by-email + magic-link redemption (PR 9)
# =============================================================
# End-to-end: Alice shares a folder with bob@externalcompany.com,
# the server lazily provisions bob as an external user, sends the
# invitation through MockEmailSender, and bob redeems the magic
# link to land authenticated on the resource.
#
# Requires `OXICLOUD_SMTP_MOCK=true` in tests/common/server.env so
# the in-process capture endpoint at /api/admin/smtp/test/captured
# is mounted. The .hurl file would error on a real SMTP setup
# because the magic link wouldn't be retrievable.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Alice logs in (admin) and grabs her home folder id.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
HTTP 200
[Captures]
alice_home_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Alice creates a folder she's about to share by email.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "name": "ext-share", "parent_id": "{{alice_home_id}}" }
HTTP 201
[Captures]
ext_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Alice shares with bob@externalcompany.com via the new
# subject.type=email payload. Server lazily provisions
# bob as an external user.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "email", "email": "bob@externalcompany.com" },
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
"role": "viewer"
}
HTTP 201
# The response carries the resolved subject as a regular user UUID —
# externals never surface as a distinct subject_type post-PR-9.3a.
# PR N1: POST /api/grants now wraps the array in
# `CreateGrantResponseDto { grants, notification }`.
[Asserts]
jsonpath "$.grants[0].subject.type" == "user"
jsonpath "$.grants[0].resource.id" == "{{ext_folder_id}}"
[Captures]
bob_user_id: jsonpath "$.grants[0].subject.id"
# ─────────────────────────────────────────────────────────────
# Step 4 — Alice's /grants/outgoing lists bob as a grantee.
# The endpoint groups by resource and exposes the
# subject display string (here: bob's email-as-username).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants/outgoing/resources
Authorization: Bearer {{alice_token}}
HTTP 200
# Hurl's `includes` predicate type-mismatches when JSONPath returns a
# scalar (single-grantee case) instead of an array, so we assert on
# the raw body — robust regardless of result count + ordering.
[Asserts]
body contains "bob@externalcompany.com"
body contains "{{bob_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 5 — Defense gap #2: bob must NOT appear in the system
# address book. The contacts handler filters externals
# via `include_external = false` (PR 6).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/system/contacts
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
body not contains "bob@externalcompany.com"
body not contains "{{bob_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 6 — Retrieve the invitation email captured by the mock
# sender BEFORE issuing any further mail (the mock only
# remembers the latest message per recipient), then
# extract the magic-link URL out of the plain-text body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.to" == "bob@externalcompany.com"
jsonpath "$.subject" contains "shared a folder with you"
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
[Captures]
magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
# ─────────────────────────────────────────────────────────────
# Step 7 — Re-sharing the same email reuses bob — no second
# external user gets created. The response carries the
# same user_id captured in Step 3.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "name": "ext-share-2", "parent_id": "{{alice_home_id}}" }
HTTP 201
[Captures]
ext_folder_id_2: jsonpath "$.id"
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "email", "email": "bob@externalcompany.com" },
"resource": { "type": "folder", "id": "{{ext_folder_id_2}}" },
"role": "viewer"
}
HTTP 201
[Asserts]
jsonpath "$.grants[0].subject.id" == "{{bob_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 8 — Redeem the magic link. The handler 302s to the SPA
# hash-route for the shared folder and sets the auth
# cookies. Hurl follows-mode is OFF by default; we want
# to inspect the Location header AND the Set-Cookie.
# ─────────────────────────────────────────────────────────────
GET {{magic_url}}
HTTP 302
[Asserts]
# SvelteKit `files/[...path]` accepts a folder ID as a path segment.
# Historical value pre-migration was `/#/files/folder/{id}` (legacy
# vanilla-frontend hash-routing). Kept in sync with the redemption
# handler in src/interfaces/api/handlers/magic_link_handler.rs.
header "Location" == "/files/{{ext_folder_id}}"
[Captures]
bob_access_token: cookie "oxicloud_access"
# ─────────────────────────────────────────────────────────────
# Step 9 — Bob (now carrying the cookie-issued JWT as bearer)
# can read the shared folder. Without the magic-link
# grant this would be 404 anti-enumeration.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{ext_folder_id}}
Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{ext_folder_id}}"
jsonpath "$.name" == "ext-share"
# ─────────────────────────────────────────────────────────────
# Step 10 — Bob sees the shared folder in his /grants/incoming.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants/incoming/resources
Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
body contains "{{ext_folder_id}}"
# ─────────────────────────────────────────────────────────────
# Step 11 — External-user lockouts (PR 11.1 + ContactsHandler).
# Bob (external) must NOT reach the system address book
# or the per-user profile endpoint. Defense-in-depth on
# top of the PR 6 service-level filter.
# ─────────────────────────────────────────────────────────────
# 11a — system address book: visible at the catalog level
# (`GET /api/address-books`) for bob? It must NOT list the system entry.
GET {{base_url}}/api/address-books
Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
body not contains "OxiCloud Users"
body not contains "\"id\":\"system\""
# 11b — system contacts listing: 403 for bob.
GET {{base_url}}/api/address-books/system/contacts
Authorization: Bearer {{bob_access_token}}
HTTP 403
# 11c — /api/users/{id}: bob CAN look up his own profile (self-lookup
# is the first allow rule) so the SharedWithMe view can show
# his own avatar in the user menu. After PR 16 externals have
# NULL username (the field is omitted from JSON when None) —
# the email field is the identity.
GET {{base_url}}/api/users/{{bob_user_id}}
Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{bob_user_id}}"
jsonpath "$.is_external" == true
jsonpath "$.email" == "bob@externalcompany.com"
jsonpath "$.username" not exists
# PR 23 — bob redeemed his invitation magic-link in Step 8, so his
# email_verified_at was stamped at that time and stays set.
jsonpath "$.email_verified_at" exists
# 11d — bob CAN look up Alice (his granter) — shared-grant relationship
# lets the external recipient resolve the sharer's display name +
# photo for the SharedWithMe view's owner column.
GET {{base_url}}/api/users/{{alice_user_id}}
Authorization: Bearer {{bob_access_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{alice_user_id}}"
jsonpath "$.is_external" == false
# Setup admin is auto-verified at creation. `setup_create_admin` stamps
# `email_verified_at = NOW()` — admin fiat counts as verification,
# matching the OIDC-JIT convention. Rationale: an operator running the
# first-run wizard is authoritative by construction (they set the
# password at the console on a fresh install). Without this, flipping
# `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment
# would lock the sole admin out of their own instance. The admin login
# exemption is a second layer of defense; this stamp is the primary.
jsonpath "$.email_verified_at" exists
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
# (anti-enumeration; same response as "user doesn't exist").
GET {{base_url}}/api/users/00000000-0000-0000-0000-baadbeef1234
Authorization: Bearer {{bob_access_token}}
HTTP 404
# 11f — bob CANNOT create an app password. Externals are
# magic-link-only; an app password would be a persistent
# credential bypassing has_login_credential().
POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{bob_access_token}}
Content-Type: application/json
{ "label": "rogue" }
HTTP 403
# 11g — bob CANNOT enumerate groups via the share-dialog endpoint.
# Defence-in-depth on top of the ReBAC layer (externals can't
# be group members today anyway).
GET {{base_url}}/api/groups/search?q=any
Authorization: Bearer {{bob_access_token}}
HTTP 403
# 11h — bob CANNOT reach the WebDAV protocol surface. He has no home
# folder, so the protocol has no semantic meaning for him.
# Layered before the handler so even malformed PROPFIND is
# rejected upfront.
PROPFIND {{base_url}}/webdav/
Authorization: Bearer {{bob_access_token}}
Depth: 0
HTTP 403
# 11i — bob CANNOT reach the CalDAV surface. No calendar.
PROPFIND {{base_url}}/caldav/
Authorization: Bearer {{bob_access_token}}
Depth: 0
HTTP 403
# 11j — bob CANNOT reach the CardDAV surface. No personal address book.
PROPFIND {{base_url}}/carddav/
Authorization: Bearer {{bob_access_token}}
Depth: 0
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 12 — /api/users/{id} happy path (Alice → Bob).
# Visibility rule: they share a grant, so Alice sees
# Bob's profile (with is_external=true). Bob's username
# is NULL post PR 16 (externals don't carry a handle).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/users/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{bob_user_id}}"
jsonpath "$.is_external" == true
jsonpath "$.email" == "bob@externalcompany.com"
jsonpath "$.username" not exists
# ─────────────────────────────────────────────────────────────
# Step 13 — /api/users/{id} 404 anti-enumeration for an
# unrelated UUID (random Uuid that doesn't exist).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/users/00000000-0000-0000-0000-deadbeefcafe
Authorization: Bearer {{alice_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 14 — Second redemption of the same magic-link token is
# rejected — single-use is enforced by the SQL UPDATE in
# magic_link_token_pg_repository::mark_used.
# ─────────────────────────────────────────────────────────────
GET {{magic_url}}
HTTP 410
# ─────────────────────────────────────────────────────────────
# Step 15 — Login-via-email (PR 10). Bob has no password (he was
# lazily provisioned via the invite flow), so he is
# magic-link-eligible. He requests a fresh sign-in link.
# Anti-enumeration: the API always returns 200 with the
# same body regardless of whether an account exists.
# ─────────────────────────────────────────────────────────────
# 15a — bob requests a sign-in link.
POST {{base_url}}/api/auth/magic-link/send
Content-Type: application/json
{ "email": "bob@externalcompany.com" }
HTTP 200
[Asserts]
jsonpath "$.message" contains "sign-in link"
# 15b — Capture the fresh email; extract the NEW magic-link URL.
# This is a NULL-resource token (login flow), so redemption
# will land on /shared-with-me rather than a deep-link.
GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.subject" contains "Sign in"
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
[Captures]
login_magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
# 15c-i — PR 22: the token is browser-bound. Hitting the
# redemption URL without the matching cookie shows the
# cross-browser confirmation page (200 + HTML) rather
# than redeeming. Audit-logs `magic_link.cross_browser_prompt`.
# The token is NOT marked used on this branch.
GET {{login_magic_url}}
HTTP 200
[Asserts]
header "content-type" startsWith "text/html"
body contains "different browser"
# 15c-ii — Same token, with `?confirm=1` to acknowledge the
# cross-browser redemption. PR 22 audit-logs
# `cross_browser_confirmed=true` on the success line.
# Lands on /shared-with-me since the token has no
# resource target (external user, NULL resource_kind).
GET {{login_magic_url}}?confirm=1
HTTP 302
[Asserts]
# SvelteKit route (path-based). Historical value pre-migration was
# `/#/sharedwithme` (legacy vanilla-frontend hash-routing). Kept in
# sync with `redirect_target()` in
# src/interfaces/api/handlers/magic_link_handler.rs.
header "Location" == "/shared-with-me"
[Captures]
bob_relogin_token: cookie "oxicloud_access"
# 15d — Bob's new session works: he can read his incoming grants.
GET {{base_url}}/api/grants/incoming/resources
Authorization: Bearer {{bob_relogin_token}}
HTTP 200
# 15e — Unknown email → same uniform 200 (anti-enumeration). No
# mail is captured under that address.
POST {{base_url}}/api/auth/magic-link/send
Content-Type: application/json
{ "email": "nobody-here@externalcompany.com" }
HTTP 200
[Asserts]
jsonpath "$.message" contains "sign-in link"
GET {{base_url}}/api/admin/smtp/test/captured?to=nobody-here@externalcompany.com
Authorization: Bearer {{alice_token}}
HTTP 404
# 15f — Email maps to an existing internal user with a password
# (Alice the admin). The test env has
# `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`
# set globally in `tests/common/server.env`, so the `has_password`
# eligibility check is bypassed and the link IS minted. Under
# the STRICT default (policy absent), the eligibility ladder
# would refuse with `reason="has_password"` and no mail would
# ship — that path is covered by a Rust unit test on
# `magic_link_eligibility()` because it needs the opposite env
# which we can't hot-swap mid-run.
POST {{base_url}}/api/auth/magic-link/send
Content-Type: application/json
{ "email": "{{email}}" }
HTTP 200
[Asserts]
jsonpath "$.message" contains "sign-in link"
# With the permit policy, a mail WAS captured. Rate-limit slot burned
# either way (increment fires before eligibility) — Step 16's math
# still holds.
GET {{base_url}}/api/admin/smtp/test/captured?to={{email}}
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.to" == "{{email}}"
# ─────────────────────────────────────────────────────────────
# Step 16 — Rate-limit caps (PR 12). Test-only thresholds come
# from tests/common/server.env:
# OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3
# OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2
# Alice already burned 2 invite slots earlier (bob's
# folder + ext-share-2) and 1 send slot in Step 15a.
# ─────────────────────────────────────────────────────────────
# 16a — Alice's 3rd email-invite (3/3) succeeds — right at the
# cap. Fresh email so resolve_or_create_recipient mints a
# new external user we'll clean up below.
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "email", "email": "ratelimit-test-1@externalcompany.com" },
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
"role": "viewer"
}
HTTP 201
[Captures]
rl_user_1_id: jsonpath "$.grants[0].subject.id"
# 16b — 4th invite (4/3) is rejected with 429 + Retry-After. The
# cap is visible because Alice is authenticated and her own
# rate-limit state leaks nothing about other accounts.
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "email", "email": "ratelimit-test-2@externalcompany.com" },
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
"role": "viewer"
}
HTTP 429
[Asserts]
header "retry-after" exists
jsonpath "$.retry_after_secs" >= 1
# 16c — Anonymous /magic-link/send to bob (2/2 — at cap). Returns
# the same uniform 200 a successful issuance would; the
# audit log distinguishes the two.
POST {{base_url}}/api/auth/magic-link/send
Content-Type: application/json
{ "email": "bob@externalcompany.com" }
HTTP 200
[Asserts]
jsonpath "$.message" contains "sign-in link"
# 16d — 3rd anonymous send to bob (3/2 — over cap). Anti-enumeration:
# must NOT return 429, must NOT change the response shape.
POST {{base_url}}/api/auth/magic-link/send
Content-Type: application/json
{ "email": "bob@externalcompany.com" }
HTTP 200
[Asserts]
jsonpath "$.message" contains "sign-in link"
# 16e — Authenticated callers bypass both anti-flood caps. Alice
# resends to bob with her Bearer token; the per-email and
# per-IP counters are not consulted (a logged-in user
# resending should never be throttled). Still returns 200.
POST {{base_url}}/api/auth/magic-link/send
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "email": "bob@externalcompany.com" }
HTTP 200
[Asserts]
jsonpath "$.message" contains "sign-in link"
# ─────────────────────────────────────────────────────────────
# Step 12 — Cleanup. Alice trashes the two test folders and
# deletes bob + the two rate-limit-test externals via
# the admin API so the suite's storage-check sweep at
# run.sh end sees a clean DB.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{ext_folder_id_2}}
Authorization: Bearer {{alice_token}}
HTTP 204
DELETE {{base_url}}/api/folders/{{ext_folder_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
DELETE {{base_url}}/api/admin/users/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
HTTP *
DELETE {{base_url}}/api/admin/users/{{rl_user_1_id}}
Authorization: Bearer {{alice_token}}
HTTP *