refactor(user): apply chanoges to hurl tests

This commit is contained in:
Edouard Vanbelle
2026-08-21 23:19:00 +02:00
parent c583b26355
commit a8fa281a02
48 changed files with 310 additions and 244 deletions
+11 -8
View File
@@ -12,7 +12,7 @@ import type {
DriveMember, DriveMember,
DriveMemberSubject, DriveMemberSubject,
DriveRole, DriveRole,
User FullUser
} from '$lib/api/types'; } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' }; const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -287,9 +287,12 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
/** /**
* Admin-scoped single-user lookup — `GET /api/admin/users/{id}`. * Admin-scoped single-user lookup — `GET /api/admin/users/{id}`.
* Returns the full `User` DTO including `storage_quota_bytes` + * Returns the full `FullUser` DTO (public identity in `.user` +
* `storage_used_bytes` which the non-admin `/api/users/{id}` * admin-visible extras like `email_verified_at` / `has_password` /
* response omits for privacy. * `opaque_registered` / `last_login_at` / quotas at top level) —
* same shape as one row of `/api/admin/users` list. The peer-view
* `/api/users/{id}` returns the slim `PublicUser` which omits those
* admin-only signals.
* *
* Result promises are cached per id at module scope so multiple * Result promises are cached per id at module scope so multiple
* callers for the same user (e.g. the admin drives table with N * callers for the same user (e.g. the admin drives table with N
@@ -301,14 +304,14 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
* still sees the cached value. Callers that need to refresh (e.g. * still sees the cached value. Callers that need to refresh (e.g.
* after `setUserQuota`) should call `invalidateAdminUserCache`. * after `setUserQuota`) should call `invalidateAdminUserCache`.
*/ */
const adminUserCache = new Map<string, Promise<User | null>>(); const adminUserCache = new Map<string, Promise<FullUser | null>>();
export function getUserAdmin(id: string): Promise<User | null> { export function getUserAdmin(id: string): Promise<FullUser | null> {
const hit = adminUserCache.get(id); const hit = adminUserCache.get(id);
if (hit) return hit; if (hit) return hit;
const pending = (async (): Promise<User | null> => { const pending = (async (): Promise<FullUser | null> => {
try { try {
return await apiJson<User>(`/api/admin/users/${encodeURIComponent(id)}`, { return await apiJson<FullUser>(`/api/admin/users/${encodeURIComponent(id)}`, {
credentials: 'same-origin' credentials: 'same-origin'
}); });
} catch { } catch {
@@ -1359,6 +1359,31 @@ impl AuthApplicationService {
&self, &self,
user_id: Uuid, user_id: Uuid,
session: &crate::domain::entities::session::Session, session: &crate::domain::entities::session::Session,
) -> Result<SelfUserDto, DomainError> {
// 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<SelfUserDto, DomainError> { ) -> Result<SelfUserDto, DomainError> {
let (user, flags) = let (user, flags) =
UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?; 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 ui_preferences = user.ui_preferences().clone();
let notify_on_share = user.notify_on_share(); let notify_on_share = user.notify_on_share();
let force_password_change = self.read_force_password_change(user_id).await; 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); let full = FullUserDto::build(user, flags);
Ok(SelfUserDto::build( Ok(SelfUserDto::build(
full, full,
@@ -3414,7 +3438,7 @@ impl AuthApplicationService {
pub async fn admin_create_user( pub async fn admin_create_user(
&self, &self,
dto: crate::application::dtos::settings_dto::AdminCreateUserDto, dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
) -> Result<PublicUserDto, DomainError> { ) -> Result<FullUserDto, DomainError> {
// Validate username length // Validate username length
if dto.username.len() < 3 || dto.username.len() > 254 { if dto.username.len() < 3 || dto.username.len() > 254 {
return Err(DomainError::new( return Err(DomainError::new(
@@ -3572,7 +3596,16 @@ impl AuthApplicationService {
created.id(), created.id(),
created.is_external() 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. /// Admin-only: reset a user's password.
@@ -3668,10 +3701,20 @@ impl AuthApplicationService {
Ok(()) Ok(())
} }
/// Get a single user by ID (for admin panel) /// Get a single user by ID (for admin panel).
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> { ///
let user = self.user_storage.get_user_by_id(user_id).await?; /// Returns `FullUserDto` — same shape as one row of
Ok(PublicUserDto::new(user, false)) /// `/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<FullUserDto, DomainError> {
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). /// Delete a user by ID (admin only).
+53 -62
View File
@@ -11,9 +11,9 @@ use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::application::dtos::user_dto::{ use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, OidcCallbackQueryDto, AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
OidcExchangeDto, OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto,
SetupAdminDto, UpgradeToInternalDto, UpgradeToInternalDto,
}; };
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult}; use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
use crate::common::di::AppState; 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 // Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM
// of `used_bytes` across the user's personal drives only. Shared drives // of `used_bytes` across the user's personal drives only. Shared drives
// never count against this envelope — collaborating in a team drive // never count against this envelope — collaborating in a team drive
// costs no personal bytes. The matching cap is // costs no personal bytes.
// `storage_quota_bytes` (admin-only mutation).
// //
// Single-query fetch: `get_user_with_derived_flags` returns the full // Delegate to the shared `build_self_user_dto_for_id` — same code
// `User` entity + `UserDerivedFlags` (has_password / OPAQUE flags / // path `PATCH /me/profile` and `POST /upgrade-to-internal` use so
// is_online) in one round-trip. That collapses what used to be a // all three self endpoints ship byte-for-byte identical shapes.
// `get_user_by_id` + separate credential lookups into one wire trip, // The DPoP-bound signal comes from the JWT `cnf.jkt` claim
// AND populates the OPAQUE flags on `/me` which the fat-PublicUserDto path // (surfaced by the auth middleware into `AuthUser.dpop_jkt`);
// never did (it left them at false — the "quiet lie" that motivated // when present the session that minted this JWT is bound and
// this refactor, see `docs/plan/userdto-refactor.md`). // the SPA can skip a redundant `/dpop/bind` call.
let (user, flags) = auth_service let self_dto = auth_service
.auth_application_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?; .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))) Ok((StatusCode::OK, Json(self_dto)))
} }
@@ -860,14 +821,16 @@ pub async fn change_password(
/// self-registration policy. Refused with 403 /// self-registration policy. Refused with 403
/// `error_type = "RegistrationDomainNotAllowed"`. /// `error_type = "RegistrationDomainNotAllowed"`.
/// ///
/// Response: the updated `PublicUserDto` (post-upgrade view — `is_external` /// Response: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA
/// is false, `storage_quota_bytes` is set). /// 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( #[utoipa::path(
post, post,
path = "/api/auth/upgrade-to-internal", path = "/api/auth/upgrade-to-internal",
request_body = UpgradeToInternalDto, request_body = UpgradeToInternalDto,
responses( 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 = 400, description = "Password missing / too short"),
(status = 401, description = "Not authenticated"), (status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC user, or domain not in allowlist"), (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( pub async fn upgrade_to_internal(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId, auth_user: AuthUser,
Json(dto): Json<UpgradeToInternalDto>, Json(dto): Json<UpgradeToInternalDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state let auth_service = state
.auth_service .auth_service
.as_ref() .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 .auth_application_service
.upgrade_to_internal(user_id, dto) .upgrade_to_internal(user_id, dto)
.await .await
@@ -947,7 +915,11 @@ pub async fn upgrade_to_internal(
_ => AppError::from(err), _ => 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). /// Update the caller's profile (PR 24).
@@ -965,7 +937,7 @@ pub async fn upgrade_to_internal(
path = "/api/auth/me/profile", path = "/api/auth/me/profile",
request_body = crate::application::dtos::user_dto::UpdateProfileDto, request_body = crate::application::dtos::user_dto::UpdateProfileDto,
responses( 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 = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"),
(status = 401, description = "Not authenticated"), (status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC-managed profile — edit at the IdP"), (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( pub async fn update_profile(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId, auth_user: AuthUser,
Json(dto): Json<crate::application::dtos::user_dto::UpdateProfileDto>, Json(dto): Json<crate::application::dtos::user_dto::UpdateProfileDto>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state let auth_service = state
.auth_service .auth_service
.as_ref() .as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; .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 .auth_application_service
.update_profile_with_perms(user_id, dto, &state.locale_registry) .update_profile_with_perms(user_id, dto, &state.locale_registry)
.await?; .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 // TODO: add utoipa
+3 -3
View File
@@ -56,7 +56,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
charlie_id: jsonpath "$.id" charlie_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -89,7 +89,7 @@ Authorization: Bearer {{charlie_token_v1}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_quota_bytes" == 209715200 jsonpath "$.full.storage_quota_bytes" == 209715200
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -108,7 +108,7 @@ Authorization: Bearer {{charlie_token_v1}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.role" == "admin" jsonpath "$.full.user.role" == "admin"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -19,7 +19,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.access_token" exists jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}" jsonpath "$.user.full.user.email" == "{{email}}"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.access_token" exists jsonpath "$.access_token" exists
jsonpath "$.user.email" == "{{email}}" jsonpath "$.user.full.user.email" == "{{email}}"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -142,10 +142,10 @@ Authorization: Bearer {{alice_magic_access_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.email" == "{{email}}" jsonpath "$.full.user.email" == "{{email}}"
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
[Captures] [Captures]
admin_user_id: jsonpath "$.id" admin_user_id: jsonpath "$.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -78,7 +78,7 @@ Authorization: Bearer {{access_v2}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+9 -9
View File
@@ -52,7 +52,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -73,8 +73,8 @@ Authorization: Bearer {{bob_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == true jsonpath "$.full.user.is_external" == true
jsonpath "$.storage_quota_bytes" == 0 jsonpath "$.full.storage_quota_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -88,8 +88,8 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == false jsonpath "$.full.user.is_external" == false
jsonpath "$.storage_quota_bytes" > 0 jsonpath "$.full.storage_quota_bytes" > 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -100,8 +100,8 @@ Authorization: Bearer {{bob_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == false jsonpath "$.full.user.is_external" == false
jsonpath "$.storage_quota_bytes" > 0 jsonpath "$.full.storage_quota_bytes" > 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -179,7 +179,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
carol_user_id: jsonpath "$.id" carol_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -204,7 +204,7 @@ Authorization: Bearer {{carol_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.is_external" == true jsonpath "$.full.user.is_external" == true
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -41,7 +41,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" 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 HTTP 200
[Captures] [Captures]
bob_token: jsonpath "$.access_token" bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id" bob_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -24,7 +24,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
token: jsonpath "$.access_token" token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
[Asserts] [Asserts]
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer" jsonpath "$.token_type" == "Bearer"
@@ -344,7 +344,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
bob_token: jsonpath "$.access_token" 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. # Step 17 — Bob's book listing does NOT include Alice's book.
+1 -1
View File
@@ -71,7 +71,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id" owner_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -67,7 +67,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id" owner_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -249,7 +249,7 @@ Content-Type: application/json
HTTP * HTTP *
[Captures] [Captures]
alice_id: jsonpath "$.id" alice_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -103,7 +103,7 @@ Content-Type: application/json
HTTP * HTTP *
[Captures] [Captures]
fresh_user_id: jsonpath "$.id" fresh_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -65,7 +65,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" 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 # Provision `dp_intruder` — a second internal user used only to
@@ -91,7 +91,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
intruder_token: jsonpath "$.access_token" intruder_token: jsonpath "$.access_token"
intruder_user_id: jsonpath "$.user.id" intruder_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -60,7 +60,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id" owner_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -82,7 +82,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" 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 HTTP 200
[Captures] [Captures]
target_user_id: jsonpath "$.user.id" target_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -31,7 +31,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
alice_user_id: jsonpath "$.id" alice_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}} Authorization: Bearer {{admin_token}}
@@ -79,7 +79,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
# Alice's first login fires `PersonalDriveLifecycleHook::on_user_login` # Alice's first login fires `PersonalDriveLifecycleHook::on_user_login`
+5 -5
View File
@@ -64,7 +64,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
alice_user_id: jsonpath "$.id" alice_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -470,7 +470,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -657,7 +657,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
carol_user_id: jsonpath "$.id" carol_user_id: jsonpath "$.user.id"
# 24a — Owner grants Carol Owner role (Owner-creates-Owner). # 24a — Owner grants Carol Owner role (Owner-creates-Owner).
@@ -836,7 +836,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
dave_user_id: jsonpath "$.id" dave_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+36 -10
View File
@@ -23,7 +23,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}} Authorization: Bearer {{alice_token}}
@@ -226,13 +226,16 @@ Authorization: Bearer {{bob_access_token}}
HTTP 200 HTTP 200
[Asserts] [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 "$.id" == "{{bob_user_id}}"
jsonpath "$.is_external" == true jsonpath "$.is_external" == true
jsonpath "$.email" == "bob@externalcompany.com" jsonpath "$.email" == "bob@externalcompany.com"
jsonpath "$.username" not exists 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 # 11d — bob CAN look up Alice (his granter) — shared-grant relationship
# lets the external recipient resolve the sharer's display name + # lets the external recipient resolve the sharer's display name +
@@ -244,14 +247,37 @@ HTTP 200
[Asserts] [Asserts]
jsonpath "$.id" == "{{alice_user_id}}" jsonpath "$.id" == "{{alice_user_id}}"
jsonpath "$.is_external" == false 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, # `email_verified_at = NOW()` — admin fiat counts as verification,
# matching the OIDC-JIT convention. Rationale: an operator running the # matching the OIDC-JIT convention. An operator running the first-run
# first-run wizard is authoritative by construction (they set the # wizard is authoritative by construction. Without this, flipping
# password at the console on a fresh install). Without this, flipping
# `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment # `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment
# would lock the sole admin out of their own instance. The admin login # would lock the sole admin out of their own instance.
# exemption is a second layer of defense; this stamp is the primary. 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 jsonpath "$.email_verified_at" exists
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404 # 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
+2 -2
View File
@@ -19,11 +19,11 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
token: jsonpath "$.access_token" token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
[Asserts] [Asserts]
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer" jsonpath "$.token_type" == "Bearer"
jsonpath "$.user.id" isString jsonpath "$.user.full.user.id" isString
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -29,7 +29,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
@@ -57,7 +57,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
mallory_user_id: jsonpath "$.id" mallory_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+5 -5
View File
@@ -23,7 +23,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}} Authorization: Bearer {{alice_token}}
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
dave_user_id: jsonpath "$.id" dave_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}} Authorization: Bearer {{alice_token}}
@@ -54,7 +54,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
eve_user_id: jsonpath "$.id" eve_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -371,7 +371,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
adam_user_id: jsonpath "$.id" adam_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -1044,7 +1044,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
frank_user_id: jsonpath "$.id" frank_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+1 -1
View File
@@ -44,7 +44,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
henry_user_id: jsonpath "$.id" henry_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+1 -1
View File
@@ -76,7 +76,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
dora_id: jsonpath "$.id" dora_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -48,7 +48,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
jwt: jsonpath "$.access_token" jwt: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -50,5 +50,5 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.access_token" exists jsonpath "$.access_token" exists
jsonpath "$.user.username" == "bob" jsonpath "$.user.full.user.username" == "bob"
jsonpath "$.user.email" == "bob@example.com" jsonpath "$.user.full.user.email" == "bob@example.com"
+5 -5
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_jwt: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
editor_user_id: jsonpath "$.id" editor_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}} Authorization: Bearer {{admin_jwt}}
@@ -85,7 +85,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
viewer_user_id: jsonpath "$.id" viewer_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}} Authorization: Bearer {{admin_jwt}}
@@ -99,7 +99,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
outsider_user_id: jsonpath "$.id" outsider_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -434,7 +434,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
quota_owner_id: jsonpath "$.id" quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+4 -4
View File
@@ -40,7 +40,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_jwt: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
editor_user_id: jsonpath "$.id" editor_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_jwt}} Authorization: Bearer {{admin_jwt}}
@@ -79,7 +79,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
viewer_user_id: jsonpath "$.id" viewer_user_id: jsonpath "$.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -351,7 +351,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
quota_owner_id: jsonpath "$.id" quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+1 -1
View File
@@ -85,7 +85,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
ncq_owner_jwt: jsonpath "$.access_token" 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 POST {{base_url}}/api/auth/app-passwords
Authorization: Bearer {{ncq_owner_jwt}} Authorization: Bearer {{ncq_owner_jwt}}
+2 -2
View File
@@ -45,7 +45,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" 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 HTTP 200
[Captures] [Captures]
bob_token: jsonpath "$.access_token" bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id" bob_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+18 -18
View File
@@ -55,7 +55,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
charlie_token: jsonpath "$.access_token" 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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.email" == "pr18-emailonly@example.com" jsonpath "$.full.user.email" == "pr18-emailonly@example.com"
jsonpath "$.is_external" == false jsonpath "$.full.user.is_external" == false
jsonpath "$.username" not exists jsonpath "$.full.user.username" not exists
# PR 23 — the user redeemed the welcome magic-link in Step 5b, so # 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 # email_verified_at is stamped (the click IS the proof of inbox
# control, regardless of whether the redemption went through the # control, regardless of whether the redemption went through the
# direct or cross-browser-confirm path). # direct or cross-browser-confirm path).
jsonpath "$.email_verified_at" exists jsonpath "$.full.email_verified_at" exists
[Captures] [Captures]
pr18_user_id: jsonpath "$.id" pr18_user_id: jsonpath "$.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -162,9 +162,9 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.id" == "{{pr18_user_id}}" jsonpath "$.full.user.id" == "{{pr18_user_id}}"
jsonpath "$.username" not exists jsonpath "$.full.user.username" not exists
jsonpath "$.given_name" not exists jsonpath "$.full.user.given_name" not exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -178,9 +178,9 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.given_name" == "Pee Are" jsonpath "$.full.user.given_name" == "Pee Are"
jsonpath "$.family_name" == "Eighteen" jsonpath "$.full.user.family_name" == "Eighteen"
jsonpath "$.username" not exists jsonpath "$.full.user.username" not exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -220,7 +220,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "pr18handle" jsonpath "$.full.user.username" == "pr18handle"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -263,7 +263,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.given_name" == "Pr18@Handle" jsonpath "$.full.user.given_name" == "Pr18@Handle"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -276,10 +276,10 @@ Authorization: Bearer {{pr18_access_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "pr18handle" jsonpath "$.full.user.username" == "pr18handle"
jsonpath "$.given_name" == "Pr18@Handle" jsonpath "$.full.user.given_name" == "Pr18@Handle"
jsonpath "$.family_name" == "Eighteen" jsonpath "$.full.user.family_name" == "Eighteen"
jsonpath "$.email_verified_at" exists jsonpath "$.full.email_verified_at" exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -80,7 +80,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
user_token: jsonpath "$.access_token" user_token: jsonpath "$.access_token"
user_user_id: jsonpath "$.user.id" user_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -34,7 +34,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
Authorization: Bearer {{admin_token}} Authorization: Bearer {{admin_token}}
@@ -56,7 +56,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
renee_user_id: jsonpath "$.id" renee_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/admin/users POST {{base_url}}/api/admin/users
@@ -66,7 +66,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
sam_user_id: jsonpath "$.id" sam_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+4 -1
View File
@@ -75,7 +75,10 @@ log "Probe blob and thumbnail confirmed present on disk."
# subsequent trash-empty triggers garbage_collect() to remove the # subsequent trash-empty triggers garbage_collect() to remove the
# now-orphaned blob files from disk. # 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") USERS_JSON=$(curl -sf -H "$AUTH" "$base_url/api/admin/users?limit=500")
ADMIN_USER_ID=$(echo "$USERS_JSON" \ ADMIN_USER_ID=$(echo "$USERS_JSON" \
+2 -2
View File
@@ -35,7 +35,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
grace_user_id: jsonpath "$.id" grace_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -268,7 +268,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
helper_user_id: jsonpath "$.id" helper_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/groups/{{engineers_id}}/members POST {{base_url}}/api/groups/{{engineers_id}}/members
+2 -2
View File
@@ -51,7 +51,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
owner_user_id: jsonpath "$.id" owner_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -209,7 +209,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
viewer_user_id: jsonpath "$.id" viewer_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
+6 -6
View File
@@ -64,7 +64,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
owner_token: jsonpath "$.access_token" 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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 0 jsonpath "$.full.storage_used_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -173,7 +173,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 0 jsonpath "$.full.storage_used_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -202,7 +202,7 @@ retry-interval: 200ms
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 32 jsonpath "$.full.storage_used_bytes" == 32
# Confirm the sweep agrees with the delta — both code paths must # Confirm the sweep agrees with the delta — both code paths must
@@ -217,7 +217,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 32 jsonpath "$.full.storage_used_bytes" == 32
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -247,7 +247,7 @@ Authorization: Bearer {{owner_token}}
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.storage_used_bytes" == 0 jsonpath "$.full.storage_used_bytes" == 0
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -174,7 +174,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
quota_owner_id: jsonpath "$.id" quota_owner_id: jsonpath "$.user.id"
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
+2 -2
View File
@@ -35,7 +35,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" 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 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -151,7 +151,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
wq_owner_token: jsonpath "$.access_token" 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 POST {{base_url}}/api/drives
+2 -2
View File
@@ -39,7 +39,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
alice_token: jsonpath "$.access_token" alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id" alice_user_id: jsonpath "$.user.full.user.id"
GET {{base_url}}/api/folders GET {{base_url}}/api/folders
@@ -65,7 +65,7 @@ Content-Type: application/json
HTTP 201 HTTP 201
[Captures] [Captures]
bob_user_id: jsonpath "$.id" bob_user_id: jsonpath "$.user.id"
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
+1 -1
View File
@@ -47,7 +47,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
admin_token: jsonpath "$.access_token" admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id" admin_user_id: jsonpath "$.user.full.user.id"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+26 -26
View File
@@ -98,11 +98,11 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
# federation_kind is skip_serializing_if=Option::is_none, so a # federation_kind is skip_serializing_if=Option::is_none, so a
# local user's response OMITS the field entirely. # local user's response OMITS the field entirely.
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -202,8 +202,8 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════
@@ -252,8 +252,8 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════
@@ -307,9 +307,9 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Scenario 9 — unlink success (admin has a password, so the # Scenario 9 — unlink success (admin has a password, so the
@@ -326,8 +326,8 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
jsonpath "$.federation_issuer" not exists jsonpath "$.full.federation_issuer" not exists
# ═════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════
@@ -380,7 +380,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
# Unlink to reset state before the auto-link scenarios. # Unlink to reset state before the auto-link scenarios.
@@ -447,9 +447,9 @@ HTTP 200
[Asserts] [Asserts]
# Auto-link resolved to the pre-existing admin, NOT a fresh # Auto-link resolved to the pre-existing admin, NOT a fresh
# JIT-provisioned user. The load-bearing assertion. # JIT-provisioned user. The load-bearing assertion.
jsonpath "$.user.username" == "{{username}}" jsonpath "$.user.full.user.username" == "{{username}}"
jsonpath "$.user.federation_kind" == "oidc" jsonpath "$.user.full.federation_kind" == "oidc"
jsonpath "$.user.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.user.full.federation_issuer" == "{{oidc_issuer}}"
[Captures] [Captures]
# Fresh cookies replace the password session's; capture the # Fresh cookies replace the password session's; capture the
# new CSRF for the unlink below. # new CSRF for the unlink below.
@@ -463,9 +463,9 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "{{username}}" jsonpath "$.full.user.username" == "{{username}}"
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Reset admin state before the next scenario (auto-link would # Reset admin state before the next scenario (auto-link would
@@ -535,7 +535,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
# Reset fake IdP state (email_verified back to true, sub back # Reset fake IdP state (email_verified back to true, sub back
@@ -599,7 +599,7 @@ X-CSRF-Token: {{autolink_csrf_token}}
HTTP 201 HTTP 201
[Captures] [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 # 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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" not exists jsonpath "$.full.federation_kind" not exists
# Cleanup — delete the collider so later scenarios see the same # Cleanup — delete the collider so later scenarios see the same
@@ -701,8 +701,8 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.user.federation_kind" == "oidc" jsonpath "$.user.full.federation_kind" == "oidc"
[Captures] [Captures]
# Fresh CSRF from the OIDC session cookies — the admin CSRFs # Fresh CSRF from the OIDC session cookies — the admin CSRFs
# won't validate against these new cookies. # won't validate against these new cookies.
@@ -730,5 +730,5 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
+19 -19
View File
@@ -172,7 +172,7 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [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 # Snapshotted so Step 7's refresh can prove the tokens rotated
# rather than being re-issued unchanged. The refresh handler in # rather than being re-issued unchanged. The refresh handler in
# auth_handler.rs always rotates all three cookies (access JWT, # 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_refresh_token: jsonpath "$.refresh_token"
initial_csrf_token: cookie "oxicloud_csrf" initial_csrf_token: cookie "oxicloud_csrf"
[Asserts] [Asserts]
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.user.email" == "oidc@example.com" jsonpath "$.user.full.user.email" == "oidc@example.com"
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
# Multiple Set-Cookie headers come back as a list of values, so # Multiple Set-Cookie headers come back as a list of values, so
# `contains` only matches whole-element strings. Each cookie shows up # `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 # 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 # second OIDC flow with the same `sub` must resolve back to this
# exact user, not silently create a duplicate. # exact user, not silently create a duplicate.
oidc_user_id: jsonpath "$.id" oidc_user_id: jsonpath "$.full.user.id"
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
jsonpath "$.email" == "oidc@example.com" jsonpath "$.full.user.email" == "oidc@example.com"
# Post the federation-identity rename (docs/plan/ocm.md § Schema # Post the federation-identity rename (docs/plan/ocm.md § Schema
# rename) UserDto exposes federation_kind + federation_issuer as # rename) UserDto exposes federation_kind + federation_issuer as
# separate nullable fields. Local users have both null; OIDC users # 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 # the fake IdP (tests/oidc/fake_idp/server.js) that URL is the
# issuer published in its discovery document, which matches # issuer published in its discovery document, which matches
# `oidc_issuer` from test.env. # `oidc_issuer` from test.env.
jsonpath "$.federation_kind" == "oidc" jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.federation_issuer" == "{{oidc_issuer}}" jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js) # Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js)
# pins these values and OxiCloud must persist each one verbatim during # pins these values and OxiCloud must persist each one verbatim during
# JIT provisioning (see auth_application_service.rs around line 2257). # JIT provisioning (see auth_application_service.rs around line 2257).
# A regression that drops, swaps, or truncates a claim trips here. # A regression that drops, swaps, or truncates a claim trips here.
# Note the field name flip on the API side: OIDC `picture` becomes # Note the field name flip on the API side: OIDC `picture` becomes
# UserDto.image (a URL or data URI). # UserDto.image (a URL or data URI).
jsonpath "$.given_name" == "OIDC" jsonpath "$.full.user.given_name" == "OIDC"
jsonpath "$.family_name" == "Test" jsonpath "$.full.user.family_name" == "Test"
jsonpath "$.image" == "https://example.com/oidc-test-user.png" jsonpath "$.full.user.image" == "https://example.com/oidc-test-user.png"
# Group-to-role mapping. server-with-oidc.env sets # Group-to-role mapping. server-with-oidc.env sets
# OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include # OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include
# `groups: ["admin-users"]`. The JIT path intersects the claim against # `groups: ["admin-users"]`. The JIT path intersects the claim against
# the env and promotes the new user from `user` to `admin`. A # the env and promotes the new user from `user` to `admin`. A
# regression here would silently strip (or wrongly grant) admin rights # regression here would silently strip (or wrongly grant) admin rights
# for every SSO deployment that uses group-based role mapping. # 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. # the (freshly-rotated) `oxicloud_csrf` cookie on the browser.
refreshed_csrf_token: cookie "oxicloud_csrf" refreshed_csrf_token: cookie "oxicloud_csrf"
[Asserts] [Asserts]
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.access_token" isString jsonpath "$.access_token" isString
jsonpath "$.refresh_token" isString jsonpath "$.refresh_token" isString
# All three cookies must rotate. If any value were re-used, a # All three cookies must rotate. If any value were re-used, a
@@ -296,7 +296,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -412,8 +412,8 @@ HTTP 200
[Asserts] [Asserts]
# Same local id — proves the existing-user resolver matched on `sub` # Same local id — proves the existing-user resolver matched on `sub`
# (or `oidc_provider + oidc_subject`) instead of minting a new row. # (or `oidc_provider + oidc_subject`) instead of minting a new row.
jsonpath "$.user.id" == "{{oidc_user_id}}" jsonpath "$.user.full.user.id" == "{{oidc_user_id}}"
jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.full.user.username" == "oidc_user"
# Role from the prior JIT-provisioned admin survives the re-login. # Role from the prior JIT-provisioned admin survives the re-login.
# Two regressions this catches: (a) the existing-user branch wiping # Two regressions this catches: (a) the existing-user branch wiping
# the role to a default `user`; (b) the existing-user branch # 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 # IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS
# still resolves to "admin"). Either way, the role should remain # still resolves to "admin"). Either way, the role should remain
# `admin` — otherwise we have a silent admin demotion on every login. # `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 HTTP 200
[Asserts] [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 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+3 -3
View File
@@ -123,10 +123,10 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Asserts] [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 # Group-to-role mapping worked — this is now the admin (and the only
# user). # user).
jsonpath "$.user.role" == "admin" jsonpath "$.user.full.user.role" == "admin"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -138,7 +138,7 @@ GET {{base_url}}/api/auth/me
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.username" == "oidc_user" jsonpath "$.full.user.username" == "oidc_user"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -132,9 +132,9 @@ echo " app password minted (id=$APP_PASSWORD_ID)"
# failure path. `storage_quota_bytes == 0` is the unlimited # failure path. `storage_quota_bytes == 0` is the unlimited
# sentinel (see `check_storage_quota`); we read it back here in # sentinel (see `check_storage_quota`); we read it back here in
# case a prior test set a real value. # 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" [[ -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: # Single cleanup on exit:
# - restore admin's original storage envelope (in case Case 3 # - 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 # `current + 100` — leaves enough headroom that MKCOL passes
# (`used + 0 = used < used + 100`) while a 200 B chunk PUT # (`used + 0 = used < used + 100`) while a 200 B chunk PUT
# overflows by exactly 100 (`used + 0 + 200 > used + 100`). # 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" [[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes"
TIGHT_QUOTA=$(( CURRENT_USED + 100 )) TIGHT_QUOTA=$(( CURRENT_USED + 100 ))