feat(manager-core,picloud): accept email on admin create + patch

The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.

CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
  absent     → don't change
  null       → clear (write NULL)
  "<string>" → set to that value

The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.

normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.

Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.

Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 19:27:52 +02:00
parent 39a6df2bfe
commit 0c9f11558a
5 changed files with 163 additions and 12 deletions

View File

@@ -36,7 +36,7 @@ async fn server_with_app(pool: PgPool) -> (TestServer, String) {
let auth = picloud::AuthDeps::from_pool(pool.clone());
let hash = hash_password("test-pw").expect("hash");
auth.users
.create("test-admin", &hash, InstanceRole::Owner)
.create("test-admin", &hash, InstanceRole::Owner, None)
.await
.expect("seed admin");
@@ -114,6 +114,47 @@ async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) {
assert!(body["id"].as_str().is_some());
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_admin_accepts_email_and_patch_clears_it(pool: PgPool) {
let s = server(pool).await;
// Create with email set.
let created = s
.post("/api/v1/admin/admins")
.json(&json!({
"username": "alice",
"password": "correct-horse-battery",
"instance_role": "member",
"email": "alice@example.com",
}))
.await;
created.assert_status(axum::http::StatusCode::CREATED);
let body: Value = created.json();
let alice_id = body["id"].as_str().expect("id").to_string();
assert_eq!(body["email"], "alice@example.com");
// Patch with email present-and-null clears it.
let cleared = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "email": null }))
.await;
cleared.assert_status_ok();
assert!(cleared.json::<Value>()["email"].is_null());
// Patch with email omitted is a no-op (doesn't clobber a re-set).
let reset = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "email": "alice2@example.com" }))
.await;
reset.assert_status_ok();
let omit = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "username": "alice" })) // no email key
.await;
omit.assert_status_ok();
assert_eq!(omit.json::<Value>()["email"], "alice2@example.com");
}
// ============================================================================
// Script CRUD
// ============================================================================