feat(passwordless): pass3: passwordless account (via emailed magic-link)
Backend
- RegisterDto — username and password both become Option<String> with #[serde(default)] so JSON can omit them entirely.
- AuthApplicationService::register — username uniqueness check skipped when None (multiple NULLs OK under the UNIQUE index); password hashing skipped when None; User::new called with the actual Options instead of forcing Some(...).
- auth_handler::register — branches on dto.password.is_none(). With password → existing 201 + UserDto. Without → triggers MagicLinkInviteService::send_login_link(&email) best-effort, then returns 200 + {"message": "Check your email…"}. The
OIDC-mode-disables-password-registration gate now only fires for the password path (email-only signup is still allowed even in OIDC-only mode, because it doesn't store a password).
- magic_link_handler::redirect_target — new 3-way decision tree:
- Resource target (folder invitation) → /#/files/folder/{id} (existing)
- NULL resource + is_external = false → /#/files (the welcome path for new internal users — they have a home folder)
- NULL resource + is_external = true → /#/sharedwithme (the existing external-user landing)
Tests
- New tests/api/registration.hurl with 9 requests covering: classic (with-password) register → 201 + UserDto, email-only register → 200 + uniform message + welcome magic-link captured, redemption → 302 to /#/files + cookies set, profile read → username
absent + is_external: false, resend magic-link works (eligible while passwordless), cleanup deletes both new users.
- Wired into tests/api/run.sh right after auth_login.hurl.
Plan additions
- auth-simplification.md gained PR 22 at the bottom of the PR sequence — device-bound magic-link redemption via challenge cookie + asymmetric TTLs (login: 10 min, invitation: 24 h). Full design recap, schema migration, config knobs
(OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES / _INVITE_TTL_HOURS), and Hurl coverage outline are in the plan. Slots in before PR 21's docs so the architecture page describes the final state from the start.
Checks — cargo fmt, cargo clippy --all-features --all-targets -- -D warnings, cargo test --lib (297 passed), biome, stylelint, tsc, full Hurl suite (16 files) all green.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
# =============================================================
|
||||
# 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 `/#/sharedwithme`, 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) still works.
|
||||
# Returns 201 + UserDto (existing behaviour, unchanged).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/register
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "charlie",
|
||||
"email": "charlie@example.com",
|
||||
"password": "TestPassword1!"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Asserts]
|
||||
jsonpath "$.username" == "charlie"
|
||||
jsonpath "$.email" == "charlie@example.com"
|
||||
jsonpath "$.is_external" == false
|
||||
[Captures]
|
||||
charlie_user_id: jsonpath "$.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
|
||||
[Asserts]
|
||||
jsonpath "$.message" contains "sign-in link"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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 5 — Redeem the welcome link. Internal user with no
|
||||
# resource target → lands on `/#/files` (NOT
|
||||
# `/#/sharedwithme`, which is the external-user
|
||||
# landing).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{pr18_magic_url}}
|
||||
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
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
|
||||
[Captures]
|
||||
pr18_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Dave can request another magic-link (he has 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"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — admin deletes charlie + dave so the DB-clean sweep
|
||||
# at run.sh end sees no stragglers.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
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 *
|
||||
@@ -91,6 +91,7 @@ log "Running Hurl tests..."
|
||||
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/files-folders.hurl" \
|
||||
"$API_DIR/favorites.hurl" \
|
||||
"$API_DIR/trash.hurl" \
|
||||
|
||||
Reference in New Issue
Block a user