Files
Oxicloud/tests/api/registration.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

425 lines
20 KiB
Plaintext

# =============================================================
# OxiCloud — email-only registration (PR 18)
# =============================================================
# PR 18 makes `password` (and `username`) optional in
# `POST /api/auth/register`. Email-only signup:
# - returns a uniform 200 message (no JWT, no UserDto)
# - mints a welcome magic-link mailed to `email`
# - redemption lands the new internal user on `/files`
# (not `/shared-with-me`, which is for externals)
#
# Requires `OXICLOUD_SMTP_MOCK=true` (set in tests/common/server.env).
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — admin login (cleanup ops at the end need her token).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 — Classic registration (with password). PR 20 anti-
# enumeration mode (SMTP wired) returns a uniform 200
# regardless of success or collision. No UserDto in
# the response — the frontend logs the user in
# separately to get a session.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "charlie",
"email": "charlie@example.com",
"password": "TestPassword1!"
}
HTTP 200
[Asserts]
jsonpath "$.message" contains "request received"
# ─────────────────────────────────────────────────────────────
# Step 2b — Log in as charlie to confirm registration succeeded
# AND to capture her user_id for cleanup.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie", "password": "TestPassword1!" }
HTTP 200
[Captures]
charlie_token: jsonpath "$.access_token"
charlie_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Email-only registration. No username, no password.
# Returns 200 + uniform message; welcome magic-link
# is captured by the MockEmailSender.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"email": "pr18-emailonly@example.com"
}
HTTP 200
[Captures]
# PR 22 — capture the browser-binding cookie so the redemption can
# replay it. Hurl's automatic cookie jar doesn't reliably attach
# Path-scoped cookies in this test setup, so we wire it through
# explicitly via the Set-Cookie header.
pr18_magic_cookie: header "set-cookie" regex "oxicloud_magic_request=([^;]+)"
[Asserts]
jsonpath "$.message" contains "request received"
# ─────────────────────────────────────────────────────────────
# Step 4 — Capture the welcome mail + extract the magic-link.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/smtp/test/captured?to=pr18-emailonly@example.com
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.to" == "pr18-emailonly@example.com"
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
[Captures]
pr18_magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
# ─────────────────────────────────────────────────────────────
# Step 5a — Redeem the welcome link WITHOUT the browser-binding
# cookie. PR 22 shows the cross-browser confirmation
# page (HTTP 200, HTML) rather than redeeming.
# ─────────────────────────────────────────────────────────────
GET {{pr18_magic_url}}
HTTP 200
[Asserts]
header "content-type" startsWith "text/html"
body contains "different browser"
# ─────────────────────────────────────────────────────────────
# Step 5b — Same link, this time with the matching cookie.
# PR 22 binds the magic-link to the requesting browser;
# a matching cookie redeems instantly. Internal user
# with no resource target → lands on `/files`.
# ─────────────────────────────────────────────────────────────
GET {{pr18_magic_url}}
Cookie: oxicloud_magic_request={{pr18_magic_cookie}}
HTTP 302
[Asserts]
# SPA route (SvelteKit path-based). Historical value pre-migration was
# `/#/files` (legacy vanilla frontend hash-routing). Changed alongside
# the migration off the legacy shell — landing on the hash route now
# serves the legacy `static/index.html` with its meta-CSP inline
# scripts, which the SPA CSP blocks.
header "Location" == "/files"
[Captures]
pr18_access_token: cookie "oxicloud_access"
# ─────────────────────────────────────────────────────────────
# Step 6 — The new user can read their own profile. After PR 18
# the username field is omitted (no handle claimed yet),
# and `is_external` is false (they're an internal user
# who signed up directly, not via invitation).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
Authorization: Bearer {{pr18_access_token}}
HTTP 200
[Asserts]
jsonpath "$.email" == "pr18-emailonly@example.com"
jsonpath "$.is_external" == false
jsonpath "$.username" not exists
# PR 23 — the user redeemed the welcome magic-link in Step 5b, so
# email_verified_at is stamped (the click IS the proof of inbox
# control, regardless of whether the redemption went through the
# direct or cross-browser-confirm path).
jsonpath "$.email_verified_at" exists
[Captures]
pr18_user_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 6a — PR 24: empty PATCH body is a no-op, returns the
# current UserDto unchanged.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{pr18_user_id}}"
jsonpath "$.username" not exists
jsonpath "$.given_name" not exists
# ─────────────────────────────────────────────────────────────
# Step 6b — PR 24: set given_name and family_name. Username
# stays unclaimed.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{ "given_name": "Pee Are", "family_name": "Eighteen" }
HTTP 200
[Asserts]
jsonpath "$.given_name" == "Pee Are"
jsonpath "$.family_name" == "Eighteen"
jsonpath "$.username" not exists
# ─────────────────────────────────────────────────────────────
# Step 6c — PR 24: empty string given_name is rejected (use the
# field's ABSENCE for "no change"; null-clearing is
# out of scope for v1).
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{ "given_name": "" }
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 6d — PR 24: attempting to claim a username taken by
# another user (admin) → 409 with `username_taken`
# audit reason.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{ "username": "{{username}}" }
HTTP 409
# ─────────────────────────────────────────────────────────────
# Step 6e — PR 24: claim a fresh handle. Username is None →
# Some, allowed.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{ "username": "pr18handle" }
HTTP 200
[Asserts]
jsonpath "$.username" == "pr18handle"
# ─────────────────────────────────────────────────────────────
# Step 6f — PR 24: claim-once enforcement. Username is already
# set; second PATCH with a different handle → 409
# UsernameImmutable. The NC client surface depends on
# usernames being stable; admin override is the only
# escape hatch (out of scope for this endpoint).
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{ "username": "different-handle" }
HTTP 409
# ─────────────────────────────────────────────────────────────
# Step 6g — PR 24: PATCH with the SAME existing username — also
# 409 immutable, since "no-op username" semantically
# differs from "no field" (the latter is the actual
# no-op).
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{ "username": "pr18handle" }
HTTP 409
# ─────────────────────────────────────────────────────────────
# Step 6h — PR 24: invalid format (contains '@' — reserved for
# the email namespace) → 400.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/auth/me/profile
Authorization: Bearer {{pr18_access_token}}
Content-Type: application/json
{ "given_name": "Pr18@Handle" }
HTTP 200
[Asserts]
jsonpath "$.given_name" == "Pr18@Handle"
# ─────────────────────────────────────────────────────────────
# Step 6i — PR 24: final state check. Username is pr18handle,
# given/family are set. PR 23 email_verified_at still
# present.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
Authorization: Bearer {{pr18_access_token}}
HTTP 200
[Asserts]
jsonpath "$.username" == "pr18handle"
jsonpath "$.given_name" == "Pr18@Handle"
jsonpath "$.family_name" == "Eighteen"
jsonpath "$.email_verified_at" exists
# ─────────────────────────────────────────────────────────────
# Step 7 — The new user can request another magic-link (no
# password configured → eligible). Anti-enumeration
# 200 either way.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/magic-link/send
Content-Type: application/json
{ "email": "pr18-emailonly@example.com" }
HTTP 200
[Asserts]
jsonpath "$.message" contains "sign-in link"
# ─────────────────────────────────────────────────────────────
# Step 8 — PR 20 anti-enumeration: register with charlie's
# email AGAIN (different password). Response is the
# same uniform 200 — attacker can't tell from the
# HTTP shape whether the email was already taken.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "charlie-imposter",
"email": "charlie@example.com",
"password": "AttackerPassword99!"
}
HTTP 200
[Asserts]
jsonpath "$.message" contains "request received"
# ─────────────────────────────────────────────────────────────
# Step 9 — Verify the collision was silently suppressed: the
# attacker's password does NOT work (the original
# row is intact, no rewrite happened).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie@example.com", "password": "AttackerPassword99!" }
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 10 — Charlie's original password still works — the
# collision didn't touch her account.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie", "password": "TestPassword1!" }
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 11 — Username collision (different email): same uniform
# 200, no new user, audit `username_taken`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "charlie",
"email": "charlie-other@example.com",
"password": "AttackerPassword99!"
}
HTTP 200
[Asserts]
jsonpath "$.message" contains "request received"
# ─────────────────────────────────────────────────────────────
# Step 12 — OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS gate.
#
# `tests/common/server.env` pins the allowlist to
# `example.com,example.test`. Every legitimate signup above stayed
# inside that set. Now attempt an off-domain address and assert:
#
# * HTTP 403 (NOT the anti-enumeration 200 — instance-wide policy
# is not a per-user oracle; a rejected domain hasn't
# established whether a specific address exists).
# * `RegistrationDomainNotAllowed` error code so operators and
# frontends can distinguish this from other 403 shapes
# (`RegistrationDisabled`, `PasswordRegistrationDisabled`).
#
# The gate is CASE-INSENSITIVE on the post-`@` part — extra
# request with mixed case pins that behaviour so a future refactor
# can't silently regress a lowercase-only match.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "off-domain",
"email": "someone@nowhere.invalid",
"password": "TestPassword1!"
}
HTTP 403
[Asserts]
# `$.error` carries the human-readable message; the stable
# machine-readable code lives at `$.error_type` (see
# `interfaces/errors.rs::ErrorResponse`). Pin `error_type` so a
# future copy-edit of the message doesn't break the test.
jsonpath "$.error_type" == "RegistrationDomainNotAllowed"
# Case-insensitive matching regression pin: `EXAMPLE.COM` in the
# post-`@` part is normalised to `example.com` and accepted. Reuse
# charlie's already-taken email so the request lands on the
# anti-enum-200 collision path — this way we exercise the domain
# gate (must pass) without creating a new user that would need
# cleanup, and pin the "case-insensitive normalization" invariant
# in one step.
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "case-check",
"email": "charlie@EXAMPLE.COM",
"password": "TestPassword1!"
}
HTTP 200
[Asserts]
jsonpath "$.message" contains "request received"
# ─────────────────────────────────────────────────────────────
# Cleanup — admin deletes both test users.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/admin/users/{{charlie_user_id}}
Authorization: Bearer {{alice_token}}
HTTP *
DELETE {{base_url}}/api/admin/users/{{pr18_user_id}}
Authorization: Bearer {{alice_token}}
HTTP *