diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index c00b8538..586b0b4b 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -12,7 +12,7 @@ import type { DriveMember, DriveMemberSubject, DriveRole, - User + FullUser } from '$lib/api/types'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; @@ -287,9 +287,12 @@ export function listUsers(limit: number, offset: number): Promise>(); +const adminUserCache = new Map>(); -export function getUserAdmin(id: string): Promise { +export function getUserAdmin(id: string): Promise { const hit = adminUserCache.get(id); if (hit) return hit; - const pending = (async (): Promise => { + const pending = (async (): Promise => { try { - return await apiJson(`/api/admin/users/${encodeURIComponent(id)}`, { + return await apiJson(`/api/admin/users/${encodeURIComponent(id)}`, { credentials: 'same-origin' }); } catch { diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 36c8090f..d34ff312 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1359,6 +1359,31 @@ impl AuthApplicationService { &self, user_id: Uuid, session: &crate::domain::entities::session::Session, + ) -> Result { + // Session-context flavour — delegates to the shared builder + // with the DPoP-bound flag derived from the session row's + // thumbprint. See [`build_self_user_dto_for_id`] for the + // handler-context flavour. + self.build_self_user_dto_for_id(user_id, session.dpop_jkt().is_some()) + .await + } + + /// Handler-context variant of [`build_self_user_dto`]. Called by + /// every endpoint that returns a `SelfUserDto` from a REST handler + /// (`GET /me`, `PATCH /me/profile`, `POST /upgrade-to-internal`) + /// so the wire shape is byte-for-byte identical across them — + /// avoids a "quiet lie" where a client PATCHes one shape and + /// reads another on the very next `/me`. + /// + /// `is_dpop_bound` is passed in by the handler because the JWT + /// `cnf.jkt` claim is where handler-scope code learns the caller's + /// binding state (via `auth_user.dpop_jkt.is_some()`). Session- + /// mint paths use [`build_self_user_dto`] and derive the flag from + /// the freshly-created `Session` row instead. + pub async fn build_self_user_dto_for_id( + &self, + user_id: Uuid, + is_dpop_bound: bool, ) -> Result { let (user, flags) = UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?; @@ -1366,7 +1391,6 @@ impl AuthApplicationService { let ui_preferences = user.ui_preferences().clone(); let notify_on_share = user.notify_on_share(); let force_password_change = self.read_force_password_change(user_id).await; - let is_dpop_bound = session.dpop_jkt().is_some(); let full = FullUserDto::build(user, flags); Ok(SelfUserDto::build( full, @@ -3414,7 +3438,7 @@ impl AuthApplicationService { pub async fn admin_create_user( &self, dto: crate::application::dtos::settings_dto::AdminCreateUserDto, - ) -> Result { + ) -> Result { // Validate username length if dto.username.len() < 3 || dto.username.len() > 254 { return Err(DomainError::new( @@ -3572,7 +3596,16 @@ impl AuthApplicationService { created.id(), created.is_external() ); - Ok(PublicUserDto::new(created, false)) + // Return `FullUserDto` — same shape as `GET /api/admin/users/{id}` + // and one row of the admin list. Admin surfaces uniformly return + // FullUserDto so the SPA / test asserts don't need to know which + // admin endpoint they came from. Fresh user has no session yet + // (`is_online = false`) and no OPAQUE registration; `has_password` + // reflects whatever the admin passed in the DTO. + let created_id = created.id(); + let (user, flags) = + UserStoragePort::get_user_with_derived_flags(&*self.user_storage, created_id).await?; + Ok(FullUserDto::build(user, flags)) } /// Admin-only: reset a user's password. @@ -3668,10 +3701,20 @@ impl AuthApplicationService { Ok(()) } - /// Get a single user by ID (for admin panel) - pub async fn get_user_admin(&self, user_id: Uuid) -> Result { - let user = self.user_storage.get_user_by_id(user_id).await?; - Ok(PublicUserDto::new(user, false)) + /// Get a single user by ID (for admin panel). + /// + /// Returns `FullUserDto` — same shape as one row of + /// `/api/admin/users` — so admin single-user views (detail modal, + /// per-user edit page) render the same fields the list surfaces. + /// The single-row admin view is the canonical observation surface + /// for admin-visible signals like `email_verified_at` / + /// `has_password` / `opaque_registered` / `last_login_at` — none + /// of which live on the peer-view `PublicUserDto`. See + /// `docs/plan/userdto-refactor.md`. + pub async fn get_user_admin(&self, user_id: Uuid) -> Result { + let (user, flags) = + UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?; + Ok(FullUserDto::build(user, flags)) } /// Delete a user by ID (admin only). diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 18d4f129..236721b4 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -11,9 +11,9 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::application::dtos::user_dto::{ - AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, OidcCallbackQueryDto, - OidcExchangeDto, OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, - SetupAdminDto, UpgradeToInternalDto, + AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, + OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto, + UpgradeToInternalDto, }; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::common::di::AppState; @@ -652,59 +652,20 @@ pub async fn get_current_user( // Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM // of `used_bytes` across the user's personal drives only. Shared drives // never count against this envelope — collaborating in a team drive - // costs no personal bytes. The matching cap is - // `storage_quota_bytes` (admin-only mutation). + // costs no personal bytes. // - // Single-query fetch: `get_user_with_derived_flags` returns the full - // `User` entity + `UserDerivedFlags` (has_password / OPAQUE flags / - // is_online) in one round-trip. That collapses what used to be a - // `get_user_by_id` + separate credential lookups into one wire trip, - // AND populates the OPAQUE flags on `/me` which the fat-PublicUserDto path - // never did (it left them at false — the "quiet lie" that motivated - // this refactor, see `docs/plan/userdto-refactor.md`). - let (user, flags) = auth_service + // Delegate to the shared `build_self_user_dto_for_id` — same code + // path `PATCH /me/profile` and `POST /upgrade-to-internal` use so + // all three self endpoints ship byte-for-byte identical shapes. + // The DPoP-bound signal comes from the JWT `cnf.jkt` claim + // (surfaced by the auth middleware into `AuthUser.dpop_jkt`); + // when present the session that minted this JWT is bound and + // the SPA can skip a redundant `/dpop/bind` call. + let self_dto = auth_service .auth_application_service - .get_user_with_derived_flags(user_id) + .build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some()) .await?; - // Read the fields we need before moving `user` into FullUserDto below. - // Ordering matters: `can_edit_image` and the self-only bag fields - // must be captured while `user` is still borrowable; the - // `FullUserDto::build` call downstream consumes the entity. - let can_edit_image = !user.is_oidc_user(); - let ui_preferences = user.ui_preferences().clone(); - let notify_on_share = user.notify_on_share(); - - // Overlay the cached `force_password_change` flag (see UserFlags). - // Using the cached path (`get_user_flags` → `user_flags_cache`) - // avoids a second DB round-trip on this hot endpoint. - let force_password_change = auth_service - .auth_application_service - .get_user_flags(user_id) - .await - .map(|f| f.force_password_change) - .unwrap_or(false); - - // Session-binding state — read from the JWT `cnf.jkt` claim - // (surfaced by the auth middleware into `CurrentUser.dpop_jkt`). - // Present ⇒ the session that minted this JWT was bound; absent ⇒ - // the session is unbound and the SPA should call `/dpop/bind` to - // attach the browser's keypair (OIDC / magic-link redirect flow). - // Skips an otherwise-redundant `POST /dpop/bind` on every page load - // which would return 409 `already_bound` and litter the audit - // stream. - let is_dpop_bound = auth_user.dpop_jkt.is_some(); - - let full = FullUserDto::build(user, flags); - let self_dto = SelfUserDto::build( - full, - ui_preferences, - notify_on_share, - is_dpop_bound, - force_password_change, - can_edit_image, - ); - Ok((StatusCode::OK, Json(self_dto))) } @@ -860,14 +821,16 @@ pub async fn change_password( /// self-registration policy. Refused with 403 /// `error_type = "RegistrationDomainNotAllowed"`. /// -/// Response: the updated `PublicUserDto` (post-upgrade view — `is_external` -/// is false, `storage_quota_bytes` is set). +/// Response: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA +/// absorbs the post-upgrade state — new `storage_quota_bytes`, +/// `is_external = false`, updated OPAQUE / auth capability flags — in one +/// round trip without a follow-up `/me` fetch. #[utoipa::path( post, path = "/api/auth/upgrade-to-internal", request_body = UpgradeToInternalDto, responses( - (status = 200, description = "Upgrade succeeded", body = PublicUserDto), + (status = 200, description = "Upgrade succeeded — returns SelfUserDto (same shape as GET /me)", body = SelfUserDto), (status = 400, description = "Password missing / too short"), (status = 401, description = "Not authenticated"), (status = 403, description = "OIDC user, or domain not in allowlist"), @@ -878,9 +841,10 @@ pub async fn change_password( )] pub async fn upgrade_to_internal( State(state): State>, - CurrentUserId(user_id): CurrentUserId, + auth_user: AuthUser, Json(dto): Json, ) -> Result { + let user_id = auth_user.id; let auth_service = state .auth_service .as_ref() @@ -928,7 +892,11 @@ pub async fn upgrade_to_internal( } } - let updated = auth_service + // Apply the upgrade. Service returns the updated `PublicUserDto`; + // we discard it and rebuild the full self view via the shared + // `build_self_user_dto_for_id` helper so the wire shape matches + // `GET /me` and `PATCH /me/profile` byte-for-byte. + let _ = auth_service .auth_application_service .upgrade_to_internal(user_id, dto) .await @@ -947,7 +915,11 @@ pub async fn upgrade_to_internal( _ => AppError::from(err), })?; - Ok((StatusCode::OK, Json(updated))) + let self_dto = auth_service + .auth_application_service + .build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some()) + .await?; + Ok((StatusCode::OK, Json(self_dto))) } /// Update the caller's profile (PR 24). @@ -965,7 +937,7 @@ pub async fn upgrade_to_internal( path = "/api/auth/me/profile", request_body = crate::application::dtos::user_dto::UpdateProfileDto, responses( - (status = 200, description = "Updated profile (PublicUserDto)", body = PublicUserDto), + (status = 200, description = "Updated profile (SelfUserDto) — same shape as GET /me so the SPA sees the just-written state without a follow-up fetch", body = SelfUserDto), (status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"), (status = 401, description = "Not authenticated"), (status = 403, description = "OIDC-managed profile — edit at the IdP"), @@ -976,20 +948,39 @@ pub async fn upgrade_to_internal( )] pub async fn update_profile( State(state): State>, - CurrentUserId(user_id): CurrentUserId, + auth_user: AuthUser, Json(dto): Json, ) -> Result { + let user_id = auth_user.id; let auth_service = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - let updated = auth_service + // Apply the patch. The service returns the updated `PublicUserDto` + // internally; we discard it and re-fetch the full self view below + // so the response matches `GET /me`'s `SelfUserDto` shape. + // + // Why SelfUserDto instead of PublicUserDto: a self-write endpoint + // whose response mirrors GET /me lets the SPA update its session + // store in one round trip. Returning a slim PublicUserDto would + // force the SPA to follow up with GET /me anyway to observe the + // just-written `ui_preferences` / `notify_on_share` / etc — those + // fields live on SelfUserDto only, not on the public identity + // slice. Same shape for both endpoints avoids "quiet lie" reads + // where a client PATCHes and then reads a stale local value. + let _ = auth_service .auth_application_service .update_profile_with_perms(user_id, dto, &state.locale_registry) .await?; - Ok((StatusCode::OK, Json(updated))) + // Rebuild via the shared helper so the wire shape matches + // `GET /me` and `POST /upgrade-to-internal` byte-for-byte. + let self_dto = auth_service + .auth_application_service + .build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some()) + .await?; + Ok((StatusCode::OK, Json(self_dto))) } // TODO: add utoipa diff --git a/tests/api/admin_user_ops.hurl b/tests/api/admin_user_ops.hurl index 0abbe1bf..2f779695 100644 --- a/tests/api/admin_user_ops.hurl +++ b/tests/api/admin_user_ops.hurl @@ -56,7 +56,7 @@ Content-Type: application/json HTTP 201 [Captures] -charlie_id: jsonpath "$.id" +charlie_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -89,7 +89,7 @@ Authorization: Bearer {{charlie_token_v1}} HTTP 200 [Asserts] -jsonpath "$.storage_quota_bytes" == 209715200 +jsonpath "$.full.storage_quota_bytes" == 209715200 # ───────────────────────────────────────────────────────────── @@ -108,7 +108,7 @@ Authorization: Bearer {{charlie_token_v1}} HTTP 200 [Asserts] -jsonpath "$.role" == "admin" +jsonpath "$.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_login.hurl b/tests/api/auth_login.hurl index c553613a..86f14651 100644 --- a/tests/api/auth_login.hurl +++ b/tests/api/auth_login.hurl @@ -19,7 +19,7 @@ Content-Type: application/json HTTP 200 [Asserts] jsonpath "$.access_token" exists -jsonpath "$.user.email" == "{{email}}" +jsonpath "$.user.full.user.email" == "{{email}}" # ───────────────────────────────────────────────────────────── @@ -34,7 +34,7 @@ Content-Type: application/json HTTP 200 [Asserts] jsonpath "$.access_token" exists -jsonpath "$.user.email" == "{{email}}" +jsonpath "$.user.full.user.email" == "{{email}}" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_magic_link_login.hurl b/tests/api/auth_magic_link_login.hurl index da3e7197..e927c37d 100644 --- a/tests/api/auth_magic_link_login.hurl +++ b/tests/api/auth_magic_link_login.hurl @@ -142,10 +142,10 @@ Authorization: Bearer {{alice_magic_access_token}} HTTP 200 [Asserts] -jsonpath "$.email" == "{{email}}" -jsonpath "$.username" == "{{username}}" +jsonpath "$.full.user.email" == "{{email}}" +jsonpath "$.full.user.username" == "{{username}}" [Captures] -admin_user_id: jsonpath "$.id" +admin_user_id: jsonpath "$.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_session_lifecycle.hurl b/tests/api/auth_session_lifecycle.hurl index df61b796..621afe88 100644 --- a/tests/api/auth_session_lifecycle.hurl +++ b/tests/api/auth_session_lifecycle.hurl @@ -78,7 +78,7 @@ Authorization: Bearer {{access_v2}} HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" +jsonpath "$.full.user.username" == "{{username}}" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/auth_upgrade_to_internal.hurl b/tests/api/auth_upgrade_to_internal.hurl index ff429ec8..2e59aaa7 100644 --- a/tests/api/auth_upgrade_to_internal.hurl +++ b/tests/api/auth_upgrade_to_internal.hurl @@ -52,7 +52,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -73,8 +73,8 @@ Authorization: Bearer {{bob_token}} HTTP 200 [Asserts] -jsonpath "$.is_external" == true -jsonpath "$.storage_quota_bytes" == 0 +jsonpath "$.full.user.is_external" == true +jsonpath "$.full.storage_quota_bytes" == 0 # ───────────────────────────────────────────────────────────── @@ -88,8 +88,8 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.is_external" == false -jsonpath "$.storage_quota_bytes" > 0 +jsonpath "$.full.user.is_external" == false +jsonpath "$.full.storage_quota_bytes" > 0 # ───────────────────────────────────────────────────────────── @@ -100,8 +100,8 @@ Authorization: Bearer {{bob_token}} HTTP 200 [Asserts] -jsonpath "$.is_external" == false -jsonpath "$.storage_quota_bytes" > 0 +jsonpath "$.full.user.is_external" == false +jsonpath "$.full.storage_quota_bytes" > 0 # ───────────────────────────────────────────────────────────── @@ -179,7 +179,7 @@ Content-Type: application/json HTTP 201 [Captures] -carol_user_id: jsonpath "$.id" +carol_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -204,7 +204,7 @@ Authorization: Bearer {{carol_token}} HTTP 200 [Asserts] -jsonpath "$.is_external" == true +jsonpath "$.full.user.is_external" == true # ───────────────────────────────────────────────────────────── diff --git a/tests/api/calendar.hurl b/tests/api/calendar.hurl index e53c192e..108330e4 100644 --- a/tests/api/calendar.hurl +++ b/tests/api/calendar.hurl @@ -41,7 +41,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -120,7 +120,7 @@ Content-Type: application/json HTTP 200 [Captures] bob_token: jsonpath "$.access_token" -bob_user_id: jsonpath "$.user.id" +bob_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/contacts.hurl b/tests/api/contacts.hurl index 7057b488..a02021d5 100644 --- a/tests/api/contacts.hurl +++ b/tests/api/contacts.hurl @@ -24,7 +24,7 @@ Content-Type: application/json HTTP 200 [Captures] token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" [Asserts] jsonpath "$.access_token" isString jsonpath "$.token_type" == "Bearer" @@ -344,7 +344,7 @@ Content-Type: application/json HTTP 200 [Captures] bob_token: jsonpath "$.access_token" -bob_user_id: jsonpath "$.user.id" +bob_user_id: jsonpath "$.user.full.user.id" # Step 17 — Bob's book listing does NOT include Alice's book. diff --git a/tests/api/cross_drive_copy.hurl b/tests/api/cross_drive_copy.hurl index b317705a..a804dd9e 100644 --- a/tests/api/cross_drive_copy.hurl +++ b/tests/api/cross_drive_copy.hurl @@ -71,7 +71,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/cross_drive_move.hurl b/tests/api/cross_drive_move.hurl index c23b4eca..40b0bdd6 100644 --- a/tests/api/cross_drive_move.hurl +++ b/tests/api/cross_drive_move.hurl @@ -67,7 +67,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/dav_error_mapping.hurl b/tests/api/dav_error_mapping.hurl index 1815397f..49d7ef20 100644 --- a/tests/api/dav_error_mapping.hurl +++ b/tests/api/dav_error_mapping.hurl @@ -249,7 +249,7 @@ Content-Type: application/json HTTP * [Captures] -alice_id: jsonpath "$.id" +alice_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/default_caldav_carddav.hurl b/tests/api/default_caldav_carddav.hurl index f67cb071..d849f6f4 100644 --- a/tests/api/default_caldav_carddav.hurl +++ b/tests/api/default_caldav_carddav.hurl @@ -103,7 +103,7 @@ Content-Type: application/json HTTP * [Captures] -fresh_user_id: jsonpath "$.id" +fresh_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drive_policies.hurl b/tests/api/drive_policies.hurl index 98f388eb..2aabcf0f 100644 --- a/tests/api/drive_policies.hurl +++ b/tests/api/drive_policies.hurl @@ -65,7 +65,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # Provision `dp_intruder` — a second internal user used only to @@ -91,7 +91,7 @@ Content-Type: application/json HTTP 200 [Captures] intruder_token: jsonpath "$.access_token" -intruder_user_id: jsonpath "$.user.id" +intruder_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drive_quota.hurl b/tests/api/drive_quota.hurl index e15e5ee1..c1358cfd 100644 --- a/tests/api/drive_quota.hurl +++ b/tests/api/drive_quota.hurl @@ -60,7 +60,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drive_read_only.hurl b/tests/api/drive_read_only.hurl index 6f94540f..4045fc2a 100644 --- a/tests/api/drive_read_only.hurl +++ b/tests/api/drive_read_only.hurl @@ -82,7 +82,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -107,7 +107,7 @@ Content-Type: application/json HTTP 200 [Captures] -target_user_id: jsonpath "$.user.id" +target_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/drives_foundation.hurl b/tests/api/drives_foundation.hurl index 55202224..2ba30b40 100644 --- a/tests/api/drives_foundation.hurl +++ b/tests/api/drives_foundation.hurl @@ -31,7 +31,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -70,7 +70,7 @@ Content-Type: application/json HTTP 201 [Captures] -alice_user_id: jsonpath "$.id" +alice_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_token}} @@ -79,7 +79,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" # Alice's first login fires `PersonalDriveLifecycleHook::on_user_login` diff --git a/tests/api/drives_membership.hurl b/tests/api/drives_membership.hurl index 5d454a5c..05cc3f8b 100644 --- a/tests/api/drives_membership.hurl +++ b/tests/api/drives_membership.hurl @@ -64,7 +64,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -113,7 +113,7 @@ Content-Type: application/json HTTP 201 [Captures] -alice_user_id: jsonpath "$.id" +alice_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -470,7 +470,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -657,7 +657,7 @@ Content-Type: application/json HTTP 201 [Captures] -carol_user_id: jsonpath "$.id" +carol_user_id: jsonpath "$.user.id" # 24a — Owner grants Carol Owner role (Owner-creates-Owner). @@ -836,7 +836,7 @@ Content-Type: application/json HTTP 201 [Captures] -dave_user_id: jsonpath "$.id" +dave_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl index ab10d5b2..51fde096 100644 --- a/tests/api/external_users.hurl +++ b/tests/api/external_users.hurl @@ -23,7 +23,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -226,13 +226,16 @@ Authorization: Bearer {{bob_access_token}} HTTP 200 [Asserts] +# `/api/users/{id}` returns the slim `PublicUserDto` (9 fields) — +# id / email / username / role / image / is_external / given_name / +# family_name / is_online. Admin-visible fields like +# `email_verified_at` moved to `FullUserDto` under the three-layer +# refactor (docs/plan/userdto-refactor.md) and are checked below +# via `/api/admin/users`. 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 + @@ -244,14 +247,37 @@ HTTP 200 [Asserts] jsonpath "$.id" == "{{alice_user_id}}" jsonpath "$.is_external" == false -# Setup admin is auto-verified at creation. `setup_create_admin` stamps + +# 11c/d/verify — admin (alice) observes email_verified_at on both +# users via `GET /api/admin/users/{id}` — returns `FullUserDto` +# (public identity in `.user` + admin-visible extras at top level). +# +# `email_verified_at` lives on `FullUserDto` (admin+self-visible), +# not on `PublicUserDto` — peer views via `/api/users/{id}` never +# expose it. The admin single-user endpoint is the correct +# observation surface. See `docs/plan/userdto-refactor.md` for the +# three-layer split. +# +# Setup admin auto-verified rationale: `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 +# matching the OIDC-JIT convention. An operator running the first-run +# wizard is authoritative by construction. 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. +# would lock the sole admin out of their own instance. +GET {{base_url}}/api/admin/users/{{bob_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.user.id" == "{{bob_user_id}}" +jsonpath "$.email_verified_at" exists + +GET {{base_url}}/api/admin/users/{{alice_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.user.id" == "{{alice_user_id}}" jsonpath "$.email_verified_at" exists # 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404 diff --git a/tests/api/files-folders.hurl b/tests/api/files-folders.hurl index 13505130..d1dcaf26 100644 --- a/tests/api/files-folders.hurl +++ b/tests/api/files-folders.hurl @@ -19,11 +19,11 @@ Content-Type: application/json HTTP 200 [Captures] token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" [Asserts] jsonpath "$.access_token" isString jsonpath "$.token_type" == "Bearer" -jsonpath "$.user.id" isString +jsonpath "$.user.full.user.id" isString # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grant_cleanup.hurl b/tests/api/grant_cleanup.hurl index 01d211e2..4353bd1a 100644 --- a/tests/api/grant_cleanup.hurl +++ b/tests/api/grant_cleanup.hurl @@ -29,7 +29,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders @@ -57,7 +57,7 @@ Content-Type: application/json HTTP 201 [Captures] -mallory_user_id: jsonpath "$.id" +mallory_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index b02d4d58..6cb3ddcc 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -23,7 +23,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 201 [Captures] -dave_user_id: jsonpath "$.id" +dave_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{alice_token}} @@ -54,7 +54,7 @@ Content-Type: application/json HTTP 201 [Captures] -eve_user_id: jsonpath "$.id" +eve_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -371,7 +371,7 @@ Content-Type: application/json HTTP 201 [Captures] -adam_user_id: jsonpath "$.id" +adam_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -1044,7 +1044,7 @@ Content-Type: application/json HTTP 201 [Captures] -frank_user_id: jsonpath "$.id" +frank_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/grants_nested_groups.hurl b/tests/api/grants_nested_groups.hurl index 480edbb3..579d234d 100644 --- a/tests/api/grants_nested_groups.hurl +++ b/tests/api/grants_nested_groups.hurl @@ -44,7 +44,7 @@ Content-Type: application/json HTTP 201 [Captures] -henry_user_id: jsonpath "$.id" +henry_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/groups_effective_members.hurl b/tests/api/groups_effective_members.hurl index 21f3dcb1..078f1a97 100644 --- a/tests/api/groups_effective_members.hurl +++ b/tests/api/groups_effective_members.hurl @@ -76,7 +76,7 @@ Content-Type: application/json HTTP 201 [Captures] -dora_id: jsonpath "$.id" +dora_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/nc_login_flow_v2_drive_picker.hurl b/tests/api/nc_login_flow_v2_drive_picker.hurl index a3e6bc94..0fcd7d1f 100644 --- a/tests/api/nc_login_flow_v2_drive_picker.hurl +++ b/tests/api/nc_login_flow_v2_drive_picker.hurl @@ -48,7 +48,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/nc_multidrive_move_regression.hurl b/tests/api/nc_multidrive_move_regression.hurl index 9c826370..3868ff91 100644 --- a/tests/api/nc_multidrive_move_regression.hurl +++ b/tests/api/nc_multidrive_move_regression.hurl @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 200 [Captures] jwt: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/nc_second_user_setup.hurl b/tests/api/nc_second_user_setup.hurl index 05fd69f6..d1f5a88e 100644 --- a/tests/api/nc_second_user_setup.hurl +++ b/tests/api/nc_second_user_setup.hurl @@ -50,5 +50,5 @@ Content-Type: application/json HTTP 200 [Asserts] jsonpath "$.access_token" exists -jsonpath "$.user.username" == "bob" -jsonpath "$.user.email" == "bob@example.com" +jsonpath "$.user.full.user.username" == "bob" +jsonpath "$.user.full.user.email" == "bob@example.com" diff --git a/tests/api/nc_webdav_patch_consistency.hurl b/tests/api/nc_webdav_patch_consistency.hurl index e23f1d78..cc108e67 100644 --- a/tests/api/nc_webdav_patch_consistency.hurl +++ b/tests/api/nc_webdav_patch_consistency.hurl @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_jwt: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ═════════════════════════════════════════════════════════════ @@ -71,7 +71,7 @@ Content-Type: application/json HTTP 201 [Captures] -editor_user_id: jsonpath "$.id" +editor_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_jwt}} @@ -85,7 +85,7 @@ Content-Type: application/json HTTP 201 [Captures] -viewer_user_id: jsonpath "$.id" +viewer_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_jwt}} @@ -99,7 +99,7 @@ Content-Type: application/json HTTP 201 [Captures] -outsider_user_id: jsonpath "$.id" +outsider_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -434,7 +434,7 @@ Content-Type: application/json HTTP 201 [Captures] -quota_owner_id: jsonpath "$.id" +quota_owner_id: jsonpath "$.user.id" PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota diff --git a/tests/api/nc_webdav_put_gaps.hurl b/tests/api/nc_webdav_put_gaps.hurl index d6d0657c..f8829b30 100644 --- a/tests/api/nc_webdav_put_gaps.hurl +++ b/tests/api/nc_webdav_put_gaps.hurl @@ -40,7 +40,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_jwt: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ═════════════════════════════════════════════════════════════ @@ -65,7 +65,7 @@ Content-Type: application/json HTTP 201 [Captures] -editor_user_id: jsonpath "$.id" +editor_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_jwt}} @@ -79,7 +79,7 @@ Content-Type: application/json HTTP 201 [Captures] -viewer_user_id: jsonpath "$.id" +viewer_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── @@ -351,7 +351,7 @@ Content-Type: application/json HTTP 201 [Captures] -quota_owner_id: jsonpath "$.id" +quota_owner_id: jsonpath "$.user.id" PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota diff --git a/tests/api/nc_webdav_quota_properties.hurl b/tests/api/nc_webdav_quota_properties.hurl index 99775a07..6db91de5 100644 --- a/tests/api/nc_webdav_quota_properties.hurl +++ b/tests/api/nc_webdav_quota_properties.hurl @@ -85,7 +85,7 @@ Content-Type: application/json HTTP 200 [Captures] ncq_owner_jwt: jsonpath "$.access_token" -ncq_owner_id: jsonpath "$.user.id" +ncq_owner_id: jsonpath "$.user.full.user.id" POST {{base_url}}/api/auth/app-passwords Authorization: Bearer {{ncq_owner_jwt}} diff --git a/tests/api/playlists.hurl b/tests/api/playlists.hurl index d8956818..664f9345 100644 --- a/tests/api/playlists.hurl +++ b/tests/api/playlists.hurl @@ -45,7 +45,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -126,7 +126,7 @@ Content-Type: application/json HTTP 200 [Captures] bob_token: jsonpath "$.access_token" -bob_user_id: jsonpath "$.user.id" +bob_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/registration.hurl b/tests/api/registration.hurl index c50a2c37..1a78984a 100644 --- a/tests/api/registration.hurl +++ b/tests/api/registration.hurl @@ -55,7 +55,7 @@ Content-Type: application/json HTTP 200 [Captures] charlie_token: jsonpath "$.access_token" -charlie_user_id: jsonpath "$.user.id" +charlie_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -139,16 +139,16 @@ Authorization: Bearer {{pr18_access_token}} HTTP 200 [Asserts] -jsonpath "$.email" == "pr18-emailonly@example.com" -jsonpath "$.is_external" == false -jsonpath "$.username" not exists +jsonpath "$.full.user.email" == "pr18-emailonly@example.com" +jsonpath "$.full.user.is_external" == false +jsonpath "$.full.user.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 +jsonpath "$.full.email_verified_at" exists [Captures] -pr18_user_id: jsonpath "$.id" +pr18_user_id: jsonpath "$.full.user.id" # ───────────────────────────────────────────────────────────── @@ -162,9 +162,9 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.id" == "{{pr18_user_id}}" -jsonpath "$.username" not exists -jsonpath "$.given_name" not exists +jsonpath "$.full.user.id" == "{{pr18_user_id}}" +jsonpath "$.full.user.username" not exists +jsonpath "$.full.user.given_name" not exists # ───────────────────────────────────────────────────────────── @@ -178,9 +178,9 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.given_name" == "Pee Are" -jsonpath "$.family_name" == "Eighteen" -jsonpath "$.username" not exists +jsonpath "$.full.user.given_name" == "Pee Are" +jsonpath "$.full.user.family_name" == "Eighteen" +jsonpath "$.full.user.username" not exists # ───────────────────────────────────────────────────────────── @@ -220,7 +220,7 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.username" == "pr18handle" +jsonpath "$.full.user.username" == "pr18handle" # ───────────────────────────────────────────────────────────── @@ -263,7 +263,7 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.given_name" == "Pr18@Handle" +jsonpath "$.full.user.given_name" == "Pr18@Handle" # ───────────────────────────────────────────────────────────── @@ -276,10 +276,10 @@ 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 +jsonpath "$.full.user.username" == "pr18handle" +jsonpath "$.full.user.given_name" == "Pr18@Handle" +jsonpath "$.full.user.family_name" == "Eighteen" +jsonpath "$.full.email_verified_at" exists # ───────────────────────────────────────────────────────────── diff --git a/tests/api/regression_595_unlimited_user_quota.hurl b/tests/api/regression_595_unlimited_user_quota.hurl index 928f75f1..f7a7fb35 100644 --- a/tests/api/regression_595_unlimited_user_quota.hurl +++ b/tests/api/regression_595_unlimited_user_quota.hurl @@ -80,7 +80,7 @@ Content-Type: application/json HTTP 200 [Captures] user_token: jsonpath "$.access_token" -user_user_id: jsonpath "$.user.id" +user_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/role_grants.hurl b/tests/api/role_grants.hurl index e5a218e1..b7742d8f 100644 --- a/tests/api/role_grants.hurl +++ b/tests/api/role_grants.hurl @@ -34,7 +34,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{admin_token}} @@ -56,7 +56,7 @@ Content-Type: application/json HTTP 201 [Captures] -renee_user_id: jsonpath "$.id" +renee_user_id: jsonpath "$.user.id" POST {{base_url}}/api/admin/users @@ -66,7 +66,7 @@ Content-Type: application/json HTTP 201 [Captures] -sam_user_id: jsonpath "$.id" +sam_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index 36f55219..378fdf60 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -75,7 +75,10 @@ log "Probe blob and thumbnail confirmed present on disk." # subsequent trash-empty triggers garbage_collect() to remove the # now-orphaned blob files from disk. -# /api/admin/users returns { users: [...], total, limit, offset } +# /api/admin/users returns { users: [PublicUserDto…], total, limit, offset } +# under the default `?summary=false` path — flat public-identity rows. The +# `?summary=true` path emits nested FullUserDto rows instead (used by the +# admin table); see `docs/plan/userdto-refactor.md`. USERS_JSON=$(curl -sf -H "$AUTH" "$base_url/api/admin/users?limit=500") ADMIN_USER_ID=$(echo "$USERS_JSON" \ diff --git a/tests/api/subject_groups.hurl b/tests/api/subject_groups.hurl index b0c2ca90..70b3dc68 100644 --- a/tests/api/subject_groups.hurl +++ b/tests/api/subject_groups.hurl @@ -35,7 +35,7 @@ Content-Type: application/json HTTP 201 [Captures] -grace_user_id: jsonpath "$.id" +grace_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -268,7 +268,7 @@ Content-Type: application/json HTTP 201 [Captures] -helper_user_id: jsonpath "$.id" +helper_user_id: jsonpath "$.user.id" POST {{base_url}}/api/groups/{{engineers_id}}/members diff --git a/tests/api/trash_per_drive.hurl b/tests/api/trash_per_drive.hurl index 4b0fbdeb..5011ed6c 100644 --- a/tests/api/trash_per_drive.hurl +++ b/tests/api/trash_per_drive.hurl @@ -51,7 +51,7 @@ Content-Type: application/json HTTP 201 [Captures] -owner_user_id: jsonpath "$.id" +owner_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json @@ -209,7 +209,7 @@ Content-Type: application/json HTTP 201 [Captures] -viewer_user_id: jsonpath "$.id" +viewer_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json diff --git a/tests/api/user_envelope_quota.hurl b/tests/api/user_envelope_quota.hurl index ea141db0..7ab4bc4b 100644 --- a/tests/api/user_envelope_quota.hurl +++ b/tests/api/user_envelope_quota.hurl @@ -64,7 +64,7 @@ Content-Type: application/json HTTP 200 [Captures] owner_token: jsonpath "$.access_token" -owner_user_id: jsonpath "$.user.id" +owner_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -92,7 +92,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 0 +jsonpath "$.full.storage_used_bytes" == 0 # ───────────────────────────────────────────────────────────── @@ -173,7 +173,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 0 +jsonpath "$.full.storage_used_bytes" == 0 # ───────────────────────────────────────────────────────────── @@ -202,7 +202,7 @@ retry-interval: 200ms HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 32 +jsonpath "$.full.storage_used_bytes" == 32 # Confirm the sweep agrees with the delta — both code paths must @@ -217,7 +217,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 32 +jsonpath "$.full.storage_used_bytes" == 32 # ───────────────────────────────────────────────────────────── @@ -247,7 +247,7 @@ Authorization: Bearer {{owner_token}} HTTP 200 [Asserts] -jsonpath "$.storage_used_bytes" == 0 +jsonpath "$.full.storage_used_bytes" == 0 # ───────────────────────────────────────────────────────────── diff --git a/tests/api/webdav_patch_consistency.hurl b/tests/api/webdav_patch_consistency.hurl index e3842c87..399b5819 100644 --- a/tests/api/webdav_patch_consistency.hurl +++ b/tests/api/webdav_patch_consistency.hurl @@ -174,7 +174,7 @@ Content-Type: application/json HTTP 201 [Captures] -quota_owner_id: jsonpath "$.id" +quota_owner_id: jsonpath "$.user.id" PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota diff --git a/tests/api/webdav_permissions.hurl b/tests/api/webdav_permissions.hurl index 57cf811e..558ca16a 100644 --- a/tests/api/webdav_permissions.hurl +++ b/tests/api/webdav_permissions.hurl @@ -35,7 +35,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── @@ -54,7 +54,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/webdav_quota_properties.hurl b/tests/api/webdav_quota_properties.hurl index d092578f..1b3381f1 100644 --- a/tests/api/webdav_quota_properties.hurl +++ b/tests/api/webdav_quota_properties.hurl @@ -151,7 +151,7 @@ Content-Type: application/json HTTP 200 [Captures] wq_owner_token: jsonpath "$.access_token" -wq_owner_id: jsonpath "$.user.id" +wq_owner_id: jsonpath "$.user.full.user.id" POST {{base_url}}/api/drives diff --git a/tests/api/wopi_authz.hurl b/tests/api/wopi_authz.hurl index 144e0df7..cf01f5e5 100644 --- a/tests/api/wopi_authz.hurl +++ b/tests/api/wopi_authz.hurl @@ -39,7 +39,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" -alice_user_id: jsonpath "$.user.id" +alice_user_id: jsonpath "$.user.full.user.id" GET {{base_url}}/api/folders @@ -65,7 +65,7 @@ Content-Type: application/json HTTP 201 [Captures] -bob_user_id: jsonpath "$.id" +bob_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login diff --git a/tests/api/wopi_shared_drive.hurl b/tests/api/wopi_shared_drive.hurl index 11c7ab36..695e256d 100644 --- a/tests/api/wopi_shared_drive.hurl +++ b/tests/api/wopi_shared_drive.hurl @@ -47,7 +47,7 @@ Content-Type: application/json HTTP 200 [Captures] admin_token: jsonpath "$.access_token" -admin_user_id: jsonpath "$.user.id" +admin_user_id: jsonpath "$.user.full.user.id" # ───────────────────────────────────────────────────────────── diff --git a/tests/oidc/link_unlink.hurl b/tests/oidc/link_unlink.hurl index b8e8b3e6..958b7efe 100644 --- a/tests/oidc/link_unlink.hurl +++ b/tests/oidc/link_unlink.hurl @@ -98,11 +98,11 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" +jsonpath "$.full.user.username" == "{{username}}" # federation_kind is skip_serializing_if=Option::is_none, so a # local user's response OMITS the field entirely. -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ───────────────────────────────────────────────────────────── @@ -202,8 +202,8 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ═════════════════════════════════════════════════════════════ @@ -252,8 +252,8 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ═════════════════════════════════════════════════════════════ @@ -307,9 +307,9 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.user.username" == "{{username}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" # Scenario 9 — unlink success (admin has a password, so the @@ -326,8 +326,8 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists -jsonpath "$.federation_issuer" not exists +jsonpath "$.full.federation_kind" not exists +jsonpath "$.full.federation_issuer" not exists # ═════════════════════════════════════════════════════════════ @@ -380,7 +380,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" == "oidc" +jsonpath "$.full.federation_kind" == "oidc" # Unlink to reset state before the auto-link scenarios. @@ -447,9 +447,9 @@ HTTP 200 [Asserts] # Auto-link resolved to the pre-existing admin, NOT a fresh # JIT-provisioned user. The load-bearing assertion. -jsonpath "$.user.username" == "{{username}}" -jsonpath "$.user.federation_kind" == "oidc" -jsonpath "$.user.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.user.full.user.username" == "{{username}}" +jsonpath "$.user.full.federation_kind" == "oidc" +jsonpath "$.user.full.federation_issuer" == "{{oidc_issuer}}" [Captures] # Fresh cookies replace the password session's; capture the # new CSRF for the unlink below. @@ -463,9 +463,9 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "{{username}}" -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.user.username" == "{{username}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" # Reset admin state before the next scenario (auto-link would @@ -535,7 +535,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists +jsonpath "$.full.federation_kind" not exists # Reset fake IdP state (email_verified back to true, sub back @@ -599,7 +599,7 @@ X-CSRF-Token: {{autolink_csrf_token}} HTTP 201 [Captures] -alias_user_id: jsonpath "$.id" +alias_user_id: jsonpath "$.user.id" # Point the fake IdP at a fresh sub with admin's email. Both @@ -636,7 +636,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" not exists +jsonpath "$.full.federation_kind" not exists # Cleanup — delete the collider so later scenarios see the same @@ -701,8 +701,8 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.user.username" == "oidc_user" -jsonpath "$.user.federation_kind" == "oidc" +jsonpath "$.user.full.user.username" == "oidc_user" +jsonpath "$.user.full.federation_kind" == "oidc" [Captures] # Fresh CSRF from the OIDC session cookies — the admin CSRFs # won't validate against these new cookies. @@ -730,5 +730,5 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl index 026523fb..2aa1802d 100644 --- a/tests/oidc/oidc.hurl +++ b/tests/oidc/oidc.hurl @@ -172,7 +172,7 @@ Content-Type: application/json HTTP 200 [Captures] -oidc_session_user: jsonpath "$.user.username" +oidc_session_user: jsonpath "$.user.full.user.username" # Snapshotted so Step 7's refresh can prove the tokens rotated # rather than being re-issued unchanged. The refresh handler in # auth_handler.rs always rotates all three cookies (access JWT, @@ -184,8 +184,8 @@ initial_access_token: jsonpath "$.access_token" initial_refresh_token: jsonpath "$.refresh_token" initial_csrf_token: cookie "oxicloud_csrf" [Asserts] -jsonpath "$.user.username" == "oidc_user" -jsonpath "$.user.email" == "oidc@example.com" +jsonpath "$.user.full.user.username" == "oidc_user" +jsonpath "$.user.full.user.email" == "oidc@example.com" jsonpath "$.access_token" isString # Multiple Set-Cookie headers come back as a list of values, so # `contains` only matches whole-element strings. Each cookie shows up @@ -211,10 +211,10 @@ HTTP 200 # Stash the user id for the re-login check in Step 10 below — a # second OIDC flow with the same `sub` must resolve back to this # exact user, not silently create a duplicate. -oidc_user_id: jsonpath "$.id" +oidc_user_id: jsonpath "$.full.user.id" [Asserts] -jsonpath "$.username" == "oidc_user" -jsonpath "$.email" == "oidc@example.com" +jsonpath "$.full.user.username" == "oidc_user" +jsonpath "$.full.user.email" == "oidc@example.com" # Post the federation-identity rename (docs/plan/ocm.md § Schema # rename) UserDto exposes federation_kind + federation_issuer as # separate nullable fields. Local users have both null; OIDC users @@ -222,24 +222,24 @@ jsonpath "$.email" == "oidc@example.com" # the fake IdP (tests/oidc/fake_idp/server.js) that URL is the # issuer published in its discovery document, which matches # `oidc_issuer` from test.env. -jsonpath "$.federation_kind" == "oidc" -jsonpath "$.federation_issuer" == "{{oidc_issuer}}" +jsonpath "$.full.federation_kind" == "oidc" +jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}" # Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js) # pins these values and OxiCloud must persist each one verbatim during # JIT provisioning (see auth_application_service.rs around line 2257). # A regression that drops, swaps, or truncates a claim trips here. # Note the field name flip on the API side: OIDC `picture` becomes # UserDto.image (a URL or data URI). -jsonpath "$.given_name" == "OIDC" -jsonpath "$.family_name" == "Test" -jsonpath "$.image" == "https://example.com/oidc-test-user.png" +jsonpath "$.full.user.given_name" == "OIDC" +jsonpath "$.full.user.family_name" == "Test" +jsonpath "$.full.user.image" == "https://example.com/oidc-test-user.png" # Group-to-role mapping. server-with-oidc.env sets # OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include # `groups: ["admin-users"]`. The JIT path intersects the claim against # the env and promotes the new user from `user` to `admin`. A # regression here would silently strip (or wrongly grant) admin rights # for every SSO deployment that uses group-based role mapping. -jsonpath "$.role" == "admin" +jsonpath "$.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── @@ -274,7 +274,7 @@ refreshed_refresh_token: jsonpath "$.refresh_token" # the (freshly-rotated) `oxicloud_csrf` cookie on the browser. refreshed_csrf_token: cookie "oxicloud_csrf" [Asserts] -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.username" == "oidc_user" jsonpath "$.access_token" isString jsonpath "$.refresh_token" isString # All three cookies must rotate. If any value were re-used, a @@ -296,7 +296,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "oidc_user" +jsonpath "$.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── @@ -412,8 +412,8 @@ HTTP 200 [Asserts] # Same local id — proves the existing-user resolver matched on `sub` # (or `oidc_provider + oidc_subject`) instead of minting a new row. -jsonpath "$.user.id" == "{{oidc_user_id}}" -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.id" == "{{oidc_user_id}}" +jsonpath "$.user.full.user.username" == "oidc_user" # Role from the prior JIT-provisioned admin survives the re-login. # Two regressions this catches: (a) the existing-user branch wiping # the role to a default `user`; (b) the existing-user branch @@ -421,7 +421,7 @@ jsonpath "$.user.username" == "oidc_user" # IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS # still resolves to "admin"). Either way, the role should remain # `admin` — otherwise we have a silent admin demotion on every login. -jsonpath "$.user.role" == "admin" +jsonpath "$.user.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── @@ -876,7 +876,7 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── @@ -889,7 +889,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "oidc_user" +jsonpath "$.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── diff --git a/tests/oidc/sso-only.hurl b/tests/oidc/sso-only.hurl index 87a33c0b..4f0b2230 100644 --- a/tests/oidc/sso-only.hurl +++ b/tests/oidc/sso-only.hurl @@ -123,10 +123,10 @@ Content-Type: application/json HTTP 200 [Asserts] -jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.full.user.username" == "oidc_user" # Group-to-role mapping worked — this is now the admin (and the only # user). -jsonpath "$.user.role" == "admin" +jsonpath "$.user.full.user.role" == "admin" # ───────────────────────────────────────────────────────────── @@ -138,7 +138,7 @@ GET {{base_url}}/api/auth/me HTTP 200 [Asserts] -jsonpath "$.username" == "oidc_user" +jsonpath "$.full.user.username" == "oidc_user" # ───────────────────────────────────────────────────────────── diff --git a/tests/webdav/test_nextcloud_chunked_upload_cap.sh b/tests/webdav/test_nextcloud_chunked_upload_cap.sh index a2e4dd96..0ed426ef 100755 --- a/tests/webdav/test_nextcloud_chunked_upload_cap.sh +++ b/tests/webdav/test_nextcloud_chunked_upload_cap.sh @@ -132,9 +132,9 @@ echo " app password minted (id=$APP_PASSWORD_ID)" # failure path. `storage_quota_bytes == 0` is the unlimited # sentinel (see `check_storage_quota`); we read it back here in # case a prior test set a real value. -ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.id') +ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.full.user.id') [[ -n "$ADMIN_ID" && "$ADMIN_ID" != "null" ]] || fail "Failed to read admin user id" -ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.storage_quota_bytes // 0') +ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.full.storage_quota_bytes // 0') # Single cleanup on exit: # - restore admin's original storage envelope (in case Case 3 @@ -247,7 +247,7 @@ echo "[3/3] QUOTA REJECTION — envelope tightened to (used + 100 B), PUT 200 B # `current + 100` — leaves enough headroom that MKCOL passes # (`used + 0 = used < used + 100`) while a 200 B chunk PUT # overflows by exactly 100 (`used + 0 + 200 > used + 100`). -CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.storage_used_bytes') +CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.full.storage_used_bytes') [[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes" TIGHT_QUOTA=$(( CURRENT_USED + 100 ))