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:
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -53,7 +53,7 @@ async fn boot(pool: PgPool) -> Seeded {
|
||||
let hash = hash_password("owner-pw").expect("hash");
|
||||
let owner = auth
|
||||
.users
|
||||
.create("owner", &hash, InstanceRole::Owner)
|
||||
.create("owner", &hash, InstanceRole::Owner, None)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn seed_user(
|
||||
) -> AdminUserId {
|
||||
let repo = PostgresAdminUserRepository::new(pool.clone());
|
||||
let hash = hash_password(password).expect("hash");
|
||||
repo.create(username, &hash, role)
|
||||
repo.create(username, &hash, role, None)
|
||||
.await
|
||||
.expect("seed user")
|
||||
.id
|
||||
|
||||
Reference in New Issue
Block a user