222 lines
9.7 KiB
Plaintext
222 lines
9.7 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — external → internal account upgrade
|
|
# =============================================================
|
|
# Covers the `POST /api/auth/upgrade-to-internal` endpoint end-to-end:
|
|
# admin-creates an external user, external user logs in, calls upgrade,
|
|
# lands on an internal account with a personal drive.
|
|
#
|
|
# Cross-cutting invariants pinned:
|
|
# * `is_external` flip is persisted (not just returned).
|
|
# * `PersonalDriveLifecycleHook.on_upgraded_to_internal` runs — a new
|
|
# default personal drive appears via `/api/drives`.
|
|
# * Idempotency: a second upgrade returns 409 `AlreadyInternal`.
|
|
# * Domain gate mirrors register: an off-allowlist email is refused
|
|
# with 403 `RegistrationDomainNotAllowed`.
|
|
# * `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` is
|
|
# `example.com,example.test` in tests/common/server.env.
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 1 — Admin login (needed to admin-create users + reach
|
|
# the delete endpoint for cleanup at the end).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "{{username}}", "password": "{{password}}" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
alice_token: jsonpath "$.access_token"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 2 — Admin creates an external user `bob-upgrade` with a
|
|
# temp password so this test can log in as him without
|
|
# going through the magic-link invitation flow (that
|
|
# path is exercised elsewhere in external_users.hurl).
|
|
# The temp password is real — admin_create_user hashes
|
|
# it even for externals — but bob's `is_external=true`
|
|
# means he has no drive yet.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"username": "bob-upgrade",
|
|
"email": "bob-upgrade@example.com",
|
|
"password": "TempExtPass1!",
|
|
"role": "user",
|
|
"is_external": true
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
bob_user_id: jsonpath "$.user.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Bob logs in with the temp password. Baseline: he can
|
|
# authenticate. Assert `is_external: true` on the /me
|
|
# response so a later /me post-upgrade proves the flip.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "bob-upgrade", "password": "TempExtPass1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
bob_token: jsonpath "$.access_token"
|
|
|
|
GET {{base_url}}/api/auth/me
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.full.user.is_external" == true
|
|
jsonpath "$.full.storage_quota_bytes" == 0
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Bob calls upgrade with a NEW password. Response is
|
|
# the updated UserDto (is_external=false, quota set).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/upgrade-to-internal
|
|
Authorization: Bearer {{bob_token}}
|
|
Content-Type: application/json
|
|
{ "password": "NewInternalPass1!" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.full.user.is_external" == false
|
|
jsonpath "$.full.storage_quota_bytes" > 0
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — /me confirms the flip persisted (not just returned).
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/auth/me
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.full.user.is_external" == false
|
|
jsonpath "$.full.storage_quota_bytes" > 0
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — Bob's NEW password works. Proves the password hash
|
|
# was persisted (not just held in memory) and the old
|
|
# temp password no longer authenticates. Fetches a
|
|
# fresh token so the rest of the test uses a session
|
|
# whose JWT claims already reflect the upgrade.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "bob-upgrade", "password": "NewInternalPass1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
bob_token_after: jsonpath "$.access_token"
|
|
|
|
# Old password no longer works.
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "bob-upgrade", "password": "TempExtPass1!" }
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — Drive provisioning. Bob's default personal drive
|
|
# shows up on /api/drives. Before upgrade externals
|
|
# have none; after upgrade the lifecycle hook created
|
|
# exactly one via the atomic CTE.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{bob_token_after}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" isCollection
|
|
jsonpath "$[0].kind" == "personal"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — Idempotency: a second upgrade returns 409
|
|
# `AlreadyInternal`. The service pre-checks
|
|
# `is_external`; the entity's `promote_to_internal`
|
|
# has a matching guard.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/upgrade-to-internal
|
|
Authorization: Bearer {{bob_token_after}}
|
|
Content-Type: application/json
|
|
{ "password": "AnotherPass1!" }
|
|
|
|
HTTP 409
|
|
[Asserts]
|
|
jsonpath "$.error_type" == "AlreadyInternal"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 9 — Domain gate. Create an external user on a domain
|
|
# OUTSIDE the allowlist, log in, attempt upgrade, get
|
|
# 403 `RegistrationDomainNotAllowed`. Rationale
|
|
# documented in the handler: invitations must not
|
|
# become a bypass of the operator's registration
|
|
# policy.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"username": "carol-offdomain",
|
|
"email": "carol@offdomain.invalid",
|
|
"password": "TempExtPass1!",
|
|
"role": "user",
|
|
"is_external": true
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
carol_user_id: jsonpath "$.user.id"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "carol-offdomain", "password": "TempExtPass1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
carol_token: jsonpath "$.access_token"
|
|
|
|
POST {{base_url}}/api/auth/upgrade-to-internal
|
|
Authorization: Bearer {{carol_token}}
|
|
Content-Type: application/json
|
|
{ "password": "NewInternalPass1!" }
|
|
|
|
HTTP 403
|
|
[Asserts]
|
|
jsonpath "$.error_type" == "RegistrationDomainNotAllowed"
|
|
|
|
# Carol is still external — the refusal didn't half-flip anything.
|
|
GET {{base_url}}/api/auth/me
|
|
Authorization: Bearer {{carol_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.full.user.is_external" == true
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Cleanup — admin deletes both test users.
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/admin/users/{{bob_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP *
|
|
|
|
DELETE {{base_url}}/api/admin/users/{{carol_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP *
|