diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index d5e7cdc0..c4e9cc9f 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -199,6 +199,44 @@ export async function register(email: string, password?: string, username?: stri } } +/** + * Convert the authenticated external user into a full internal account. + * Server flips `is_external` to false, provisions a personal drive via + * the lifecycle hook, and returns the updated `User`. + * + * Password is optional — see backend `UpgradeToInternalDto`: + * * If the deployment offers magic-link login, blank password is + * accepted (user remains magic-link-only after upgrade). + * * Otherwise a password is required — the backend refuses with 400 + * `error_type = "PasswordRequired"` and the SPA surfaces the + * server message. + * + * Uses `apiFetch` (unlike register/login) because the caller IS + * authenticated; a 401 here IS a genuine "session expired" and the + * refresh interceptor is the right response. + */ +export async function upgradeToInternal(password?: string): Promise { + const body: Record = {}; + if (password) body.password = password; + const res = await apiFetch('/api/auth/upgrade-to-internal', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const { errorType, message } = await parseErrorBody(res); + throw new ApiError( + res.status, + res.statusText, + '/api/auth/upgrade-to-internal', + errorType, + message + ); + } + return (await res.json()) as User; +} + export type MagicLinkResult = 'sent' | 'unavailable'; /** diff --git a/frontend/src/routes/shared-with-me/+page.svelte b/frontend/src/routes/shared-with-me/+page.svelte index 32c4d142..b57c17d6 100644 --- a/frontend/src/routes/shared-with-me/+page.svelte +++ b/frontend/src/routes/shared-with-me/+page.svelte @@ -13,6 +13,14 @@ type ResourceEntry } from '$lib/components/ResourceList.svelte'; import { t } from '$lib/i18n/index.svelte'; + import { session } from '$lib/stores/session.svelte'; + + // External users landing here are the natural audience for the + // "upgrade to a full account" prompt — they don't own a drive of + // their own, this view IS their entry point. Internal users don't + // see the banner even when their /shared-with-me happens to be + // non-empty (they already have a drive; nothing to upgrade). + const showUpgradeBanner = $derived(session.isExternalUser); let raw = $state([]); let cursor = $state(undefined); @@ -137,6 +145,27 @@ {t('nav.shared_with_me', 'Shared with me')} · OxiCloud +{#if showUpgradeBanner} +
+
+ {t('upgrade.banner_title', 'Get your own storage')} + {t( + 'upgrade.banner_body', + "You're using a guest account. Upgrade to get a personal drive and start uploading files." + )} +
+ + {t('upgrade.banner_cta', 'Upgrade')} + +
+{/if} + {/if} + + diff --git a/frontend/src/routes/upgrade/+page.svelte b/frontend/src/routes/upgrade/+page.svelte new file mode 100644 index 00000000..02151c6a --- /dev/null +++ b/frontend/src/routes/upgrade/+page.svelte @@ -0,0 +1,205 @@ + + + + {t('upgrade.title', 'Upgrade to a full account')} + + +
+
+ + +

{t('upgrade.title', 'Upgrade to a full account')}

+

+ {t( + 'upgrade.lede', + 'Get your own storage and start uploading files. Your existing shares stay untouched.' + )} +

+ + {#if successHint} +
{successHint}
+ {/if} + {#if error} + + {/if} + +
+
+ +
+ +
+
+ + {#if password.length > 0} +
+ +
+ +
+ {#if matchState} +
+ {matchState === 'ok' + ? t('auth.passwords_match', 'Passwords match') + : t('auth.passwords_mismatch', "Passwords don't match")} +
+ {/if} +
+ {/if} + + +
+ +
+ +
+
+
diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 7e8c2b53..28f3b2b8 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -229,6 +229,23 @@ pub struct RefreshTokenDto { pub refresh_token: String, } +/// Body for `POST /api/auth/upgrade-to-internal`. Converts an +/// authenticated external user into an internal user with their own +/// personal drive. +/// +/// `password` is optional — semantics decided per deployment: +/// * If `magic_link` is in `OXICLOUD_AUTH_METHODS` (and OIDC isn't +/// enabled) → password can be omitted; user remains magic-link-only +/// for login after upgrade. +/// * Otherwise → password is required; refusal returns 400 +/// `error_type = "PasswordRequired"`. Without it the upgraded user +/// would have no login path. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UpgradeToInternalDto { + #[serde(default)] + pub password: Option, +} + /// Authenticated current user data (for use in application services) #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct CurrentUser { diff --git a/src/application/ports/user_lifecycle.rs b/src/application/ports/user_lifecycle.rs index e927a3cf..de92a46d 100644 --- a/src/application/ports/user_lifecycle.rs +++ b/src/application/ports/user_lifecycle.rs @@ -193,4 +193,32 @@ pub trait UserLifecycleHook: Send + Sync { mode: DeletionMode, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DomainError>; + + /// Fires after `AuthApplicationService::upgrade_to_internal` + /// successfully persists `is_external = false` on the user row — + /// the external → internal conversion path. The `user` argument + /// reflects the POST-upgrade state (`is_external() == false`, + /// `storage_quota_bytes > 0`, `password_hash` maybe stamped). + /// + /// Load-bearing implementations: + /// * `PersonalDriveLifecycleHook` → provisions the home drive + /// (would have short-circuited on `on_user_created` because + /// the user was external at creation). + /// * `AuditLifecycleHook` → emits `event="auth.user_upgraded"`. + /// + /// Default: no-op. Hooks that don't care about upgrade don't need + /// to opt in — this keeps the trait extension backwards-compatible + /// with existing implementations. Do NOT reuse `on_user_created` + /// for this event: hooks that observe `last_login_at().is_none()` + /// as "first ever" or that clean up magic-link tokens + /// (`ExternalIdentityLifecycleHook`) would mis-fire. + /// + /// Idempotency: fires exactly once per successful upgrade transition + /// (guarded by `is_external` toggling). A retried upgrade after a + /// crash would hit the `AlreadyInternal` guard in the service and + /// this hook wouldn't fire again — so hooks may assume "first + /// upgrade" semantics. + async fn on_upgraded_to_internal(&self, _user: &User) -> Result<(), DomainError> { + Ok(()) + } } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index d6cf8da4..94f5fb36 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,5 +1,6 @@ use crate::application::dtos::user_dto::{ - AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto, + AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, + UpgradeToInternalDto, UserDto, }; use crate::application::ports::auth_ports::{ OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, @@ -1233,6 +1234,147 @@ impl AuthApplicationService { Ok(revoked_count) } + /// External → internal account upgrade. + /// + /// Contract: + /// * Caller must be authenticated as the user being upgraded. + /// Session-elevation is not required — being logged in as + /// yourself IS the proof of intent. + /// * User must be `is_external = true` — else the entity refuses + /// with `UserError::AlreadyInternal`, surfaced as `error_type = + /// "AlreadyInternal"` (409). + /// * OIDC-linked users are refused (the IdP owns their identity). + /// * If `dto.password` is `None`, the deployment MUST have magic- + /// link login enabled — otherwise the upgraded user would have + /// no login path. Refused with `error_type = "PasswordRequired"` + /// (400) in that case. + /// * Domain-allowlist check lives at the HANDLER layer, mirroring + /// the register handler — the service doesn't hold that config. + /// + /// On success: + /// * User's `is_external` flipped to `false`. + /// * `password_hash` set from the provided password (Argon2id) or + /// left as-is (magic-link-only upgrade). + /// * `storage_quota_bytes` set to the default user quota (capped + /// by disk). + /// * `PersonalDriveLifecycleHook::on_upgraded_to_internal` runs and + /// provisions the home drive + root folder + owner grant via the + /// atomic CTE. Failure at this step is logged but the row update + /// stands — the next login's `on_user_login` safety-net retries + /// provisioning. + /// * `user_flags_cache` invalidated eagerly so per-request guards + /// (WebDAV / CalDAV / CardDAV) observe the new `is_external` + /// within cache-round-trip time, not the 30-second TTL. + /// * Audit log emits `event="user.upgraded_to_internal"` via the + /// `AuditLifecycleHook` on the dispatched event. + pub async fn upgrade_to_internal( + &self, + caller_id: Uuid, + dto: UpgradeToInternalDto, + ) -> Result { + let mut user = self.user_storage.get_user_by_id(caller_id).await?; + + // Precondition: caller is currently external. Fast-path 409 so + // the audit log carries a clear reason before the entity's own + // guard fires. + if !user.is_external() { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "already_internal", + user_id = %user.id(), + username = %user.display_for_audit(), + "👮🏻‍♂️ upgrade refused: user is already internal", + ); + return Err(DomainError::new( + ErrorKind::Conflict, + "User", + "Account is already internal", + )); + } + + // OIDC-linked: never. The IdP owns identity and role. + if user.is_oidc_user() { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "oidc_user", + user_id = %user.id(), + "👮🏻‍♂️ upgrade refused: OIDC-linked user is managed by the IdP", + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "User", + "SSO/OIDC accounts are managed by your identity provider", + )); + } + + // Password policy composite: + // * Provided → validate + hash. + // * Omitted → only accepted when magic-link login is on + // for this deployment (otherwise no login path post-upgrade). + let password_hash = match dto.password.as_deref() { + Some(pw) if !pw.is_empty() => { + if pw.len() < 8 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Password must be at least 8 characters long", + )); + } + Some(self.password_hasher.hash_password(pw).await?) + } + _ => { + if !self.is_magic_link_login_allowed() { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "password_required", + user_id = %user.id(), + "👮🏻‍♂️ upgrade refused: password omitted but magic-link login is not available on this deployment", + ); + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Password is required — magic-link login is not enabled on this deployment", + )); + } + None + } + }; + + // Quota policy: same as a fresh regular-user signup. + let quota = self.capped_quota(&UserRole::User); + + user.promote_to_internal(password_hash, quota) + .map_err(|e| { + // The entity refuses `AlreadyInternal` here belt-and-braces + // against a race with a concurrent upgrade; the pre-check + // above already covers the intended path. + DomainError::new( + ErrorKind::Conflict, + "User", + format!("Upgrade refused: {}", e), + ) + })?; + + let updated = self.user_storage.update_user(user).await?; + + // Invalidate the flags cache so subsequent per-request guards + // observe the new `is_external=false` without waiting for the + // 30-second TTL. Same pattern as `change_user_role`. + self.user_flags_cache.invalidate(&caller_id); + + // Dispatch — home-drive provisioning happens here. Log-and- + // continue: a provisioning failure leaves the row updated and + // the next login's safety-net (`on_user_login`) retries. + if let Some(lc) = &self.user_lifecycle { + lc.dispatch_upgraded_to_internal(&updated).await; + } + + Ok(UserDto::from(updated)) + } + pub async fn change_password( &self, user_id: Uuid, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index a1add88f..0073a346 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -962,6 +962,17 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook { self.provision_if_needed(user).await } + /// External → internal upgrade. `on_user_created` fired at signup + /// with `is_external=true` and short-circuited in + /// `provision_if_needed`. The user is now internal — same helper + /// runs, but this time the `is_external` guard passes through and + /// the atomic CTE creates their default drive + root folder + + /// owner grant. Idempotent by construction: a rerun after a partial + /// failure hits the `find_default_for_user` short-circuit. + async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> { // Drives don't react to logout. Explicit no-op per the // "no defaults" convention. diff --git a/src/application/services/user_lifecycle_service.rs b/src/application/services/user_lifecycle_service.rs index de1db8b4..e47ff81c 100644 --- a/src/application/services/user_lifecycle_service.rs +++ b/src/application/services/user_lifecycle_service.rs @@ -62,6 +62,28 @@ impl UserLifecycleService { } } + /// Upgraded: log-and-continue. Called by + /// `AuthApplicationService::upgrade_to_internal` after the + /// `is_external = false` UPDATE persists. Same log-and-continue + /// semantics as `dispatch_created` — the row is already updated, + /// hook failure at (e.g.) home-drive provisioning is recoverable + /// on the next login via `PersonalDriveLifecycleHook::on_user_login` + /// (its safety-net path already handles the "user is internal but + /// no drive yet" case idempotently). + pub async fn dispatch_upgraded_to_internal(&self, user: &User) { + for h in &self.hooks { + if let Err(e) = h.on_upgraded_to_internal(user).await { + tracing::error!( + target: "user_lifecycle", + hook = h.name(), + user_id = %user.id(), + error = %e, + "on_upgraded_to_internal failed; drive provisioning will retry on next login" + ); + } + } + } + /// Login: log-and-continue. Same reasoning as `dispatch_created`. /// Must fire BEFORE `user.register_login()` so that hooks observing /// `last_login_at().is_none()` correctly detect the first-ever login. @@ -199,6 +221,19 @@ impl UserLifecycleHook for AuditLifecycleHook { ); Ok(()) } + + async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> { + // Post-upgrade state — `is_external` is already `false` here + // (the service persisted before dispatching), so we don't log + // it as a field; the event name carries the transition. + tracing::info!( + target: "audit", + event = "user.upgraded_to_internal", + user_id = %user.id(), + username = %user.display_for_audit(), + ); + Ok(()) + } } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/domain/entities/entity_errors.rs b/src/domain/entities/entity_errors.rs index 96368558..213582f1 100644 --- a/src/domain/entities/entity_errors.rs +++ b/src/domain/entities/entity_errors.rs @@ -79,6 +79,9 @@ pub enum UserError { ValidationError(String), /// Authentication error AuthenticationError(String), + /// Upgrade path: the user is already internal — cannot re-upgrade. + /// Surfaced by the service as `error_type = "AlreadyInternal"`. + AlreadyInternal, } impl Display for UserError { @@ -88,6 +91,7 @@ impl Display for UserError { UserError::InvalidPassword(msg) => write!(f, "Invalid password: {}", msg), UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg), UserError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg), + UserError::AlreadyInternal => write!(f, "User is already an internal account"), } } } diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index e44a70d8..f4e77e96 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -512,6 +512,47 @@ impl User { } } + /// Promote a currently-external user to an internal account. + /// Atomically flips the invariant-linked fields: + /// * `is_external` → false + /// * `password_hash` → provided (Some) or preserved (None) + /// * `storage_quota_bytes` → quota (external users had 0; DB CHECK + /// `users_external_no_storage` enforces the pair before this call + /// and would refuse a non-zero quota on an external row — the + /// write MUST flip `is_external` first, which happens + /// transactionally at persist time via the sqlx UPDATE). + /// + /// Password is `Option` because the service allows password- + /// less upgrades when magic-link login is available on the + /// deployment. When `None`, `password_hash` stays as it was (either + /// NULL, or a hash left over from an admin-created invitation — + /// externals don't authenticate with it either way). + /// + /// Refuses if the caller is already internal — the upgrade path + /// only makes sense on `is_external = true` users. Service pre- + /// checks `user.is_external()` before calling; this guard is + /// belt-and-braces against a race. + /// + /// Admin combo is impossible by construction: external + admin was + /// refused at creation (see `User::new`), so a promoted external + /// user always retains their `UserRole::User` — role isn't changed. + pub fn promote_to_internal( + &mut self, + password_hash: Option, + storage_quota_bytes: i64, + ) -> UserResult<()> { + if !self.is_external { + return Err(UserError::AlreadyInternal); + } + self.is_external = false; + if let Some(hash) = password_hash { + self.password_hash = Some(hash); + } + self.storage_quota_bytes = storage_quota_bytes; + self.updated_at = Utc::now(); + Ok(()) + } + pub fn set_image(&mut self, image: Option) { self.image = image; self.updated_at = Utc::now(); diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 2a18a771..5fa36c5c 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -472,7 +472,19 @@ impl UserRepository for UserPgRepository { family_name = $13, email_verified_at = $14, preferred_locale = $15, - notify_on_share = $16 + notify_on_share = $16, + -- Include `is_external` so the external → + -- internal upgrade path + -- (`AuthApplicationService::upgrade_to_internal`) + -- can flip this flag. Previously omitted + -- because no code path mutated it after + -- creation. The DB CHECK + -- `users_external_no_storage` + -- (`is_external=false OR quota=0`) is + -- satisfied by the upgrade because it + -- writes both fields in the same UPDATE: + -- `is_external=false, quota>0`. + is_external = $17 WHERE id = $1 "#, ) @@ -492,6 +504,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.email_verified_at()) .bind(user_clone.preferred_locale()) .bind(user_clone.notify_on_share()) + .bind(user_clone.is_external()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b2878a98..f47ecdf4 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::application::dtos::user_dto::{ AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, - OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto, + OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto, + UserDto, }; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::common::di::AppState; @@ -45,6 +46,7 @@ pub fn auth_protected_routes() -> Router> { .route("/me/image", put(update_user_image)) .route("/me/profile", patch(update_profile)) .route("/change-password", put(change_password)) + .route("/upgrade-to-internal", post(upgrade_to_internal)) .route("/logout", post(logout)) } @@ -639,6 +641,118 @@ pub async fn change_password( Ok(StatusCode::OK) } +/// Convert the authenticated external user into a full internal +/// account. The caller must currently be `is_external = true`; on +/// success, `is_external` is flipped to `false`, a personal drive is +/// provisioned (atomic CTE via `PersonalDriveLifecycleHook`), and the +/// user's flags cache is invalidated so subsequent per-request guards +/// see the new state within cache-round-trip time. +/// +/// Password policy: +/// * If the deployment offers magic-link login +/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND OIDC is not +/// enabled AND SMTP is wired), the body's `password` field is +/// optional — an upgraded user without a password stays magic- +/// link-only for login. +/// * Otherwise, `password` is required — refused with 400 +/// `error_type = "PasswordRequired"`. +/// +/// Domain gate: the caller's email domain MUST be in +/// `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (when non-empty). +/// Otherwise invitations would become a bypass of the operator's +/// self-registration policy. Refused with 403 +/// `error_type = "RegistrationDomainNotAllowed"`. +/// +/// Response: the updated `UserDto` (post-upgrade view — `is_external` +/// is false, `storage_quota_bytes` is set). +#[utoipa::path( + post, + path = "/api/auth/upgrade-to-internal", + request_body = UpgradeToInternalDto, + responses( + (status = 200, description = "Upgrade succeeded", body = UserDto), + (status = 400, description = "Password missing / too short"), + (status = 401, description = "Not authenticated"), + (status = 403, description = "OIDC user, or domain not in allowlist"), + (status = 409, description = "Already internal"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn upgrade_to_internal( + State(state): State>, + CurrentUserId(user_id): CurrentUserId, + Json(dto): Json, +) -> Result { + let auth_service = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; + + // Domain gate. Mirrors the register handler + // (`OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS`). Rationale: an + // internal-user invitation must NOT become a way around the + // operator's self-registration policy. If a domain isn't + // allowlisted for register, it shouldn't be allowed for upgrade + // either. External users on non-allowlisted domains remain + // external — they can still act on shared resources but never own + // a drive of their own on this deployment. + let allow_list = &state.core.config.auth.registration_allowed_email_domains; + if !allow_list.is_empty() { + // The service re-fetches the user inside `upgrade_to_internal`; + // one extra id-lookup here just to extract the email is cheap + // and keeps the domain check at the same layer as the register + // handler for consistency. + let email = auth_service + .auth_application_service + .get_user_by_id(user_id) + .await + .map(|dto| dto.email)?; + let domain = email + .split('@') + .nth(1) + .map(|d| d.trim().to_ascii_lowercase()) + .unwrap_or_default(); + if domain.is_empty() || !allow_list.iter().any(|d| d == &domain) { + tracing::info!( + target: "audit", + event = "user.upgrade_rejected", + reason = "domain_not_allowed", + user_id = %user_id, + domain = %domain, + "👮🏻‍♂️ upgrade refused: email domain not in \ + OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS" + ); + return Err(AppError::new( + StatusCode::FORBIDDEN, + "This deployment does not accept new accounts from your email domain.", + "RegistrationDomainNotAllowed", + )); + } + } + + let updated = auth_service + .auth_application_service + .upgrade_to_internal(user_id, dto) + .await + .map_err(|err| match err.message.as_str() { + "Account is already internal" => { + AppError::new(StatusCode::CONFLICT, err.message.clone(), "AlreadyInternal") + } + "SSO/OIDC accounts are managed by your identity provider" => { + AppError::new(StatusCode::FORBIDDEN, err.message.clone(), "ManagedByIdP") + } + m if m.starts_with("Password is required") => AppError::new( + StatusCode::BAD_REQUEST, + err.message.clone(), + "PasswordRequired", + ), + _ => AppError::from(err), + })?; + + Ok((StatusCode::OK, Json(updated))) +} + /// Update the caller's profile (PR 24). /// /// Fields are individually optional — absent = no change. Username is diff --git a/tests/api/auth_upgrade_to_internal.hurl b/tests/api/auth_upgrade_to_internal.hurl new file mode 100644 index 00000000..ff429ec8 --- /dev/null +++ b/tests/api/auth_upgrade_to_internal.hurl @@ -0,0 +1,221 @@ +# ============================================================= +# 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 "$.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 "$.is_external" == true +jsonpath "$.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 "$.is_external" == false +jsonpath "$.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 "$.is_external" == false +jsonpath "$.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 "$.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 "$.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 * diff --git a/tests/api/run.sh b/tests/api/run.sh index a6508540..3288ae29 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -149,6 +149,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/user_ui_preferences.hurl" \ "$API_DIR/auth_session_lifecycle.hurl" \ "$API_DIR/auth_magic_link_login.hurl" \ + "$API_DIR/auth_upgrade_to_internal.hurl" \ "$API_DIR/registration.hurl" \ "$API_DIR/nc_status_capabilities.hurl" \ "$API_DIR/nc_login_flow_v2.hurl" \