From 83a72028610d6f73debdf284867f39a56099b8d6 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 19:35:33 +0200 Subject: [PATCH] feat: rename + delete vocabularies, blocked when in use (#30) --- crates/api/src/admin_vocab.rs | 107 +++++++++++++++++++++++++ crates/api/src/openapi.rs | 3 + crates/api/tests/admin_catalog.rs | 128 ++++++++++++++++++++++++++++++ crates/db/src/vocab.rs | 82 +++++++++++++++++++ crates/db/tests/vocab.rs | 72 +++++++++++++++++ 5 files changed, 392 insertions(+) diff --git a/crates/api/src/admin_vocab.rs b/crates/api/src/admin_vocab.rs index b8d60de..d9b5e26 100644 --- a/crates/api/src/admin_vocab.rs +++ b/crates/api/src/admin_vocab.rs @@ -355,12 +355,119 @@ pub(crate) async fn delete_term( } } +#[derive(Deserialize, ToSchema)] +pub(crate) struct RenameVocabularyRequest { + pub key: String, +} + +#[utoipa::path( + patch, path = "/api/admin/vocabularies/{id}", + request_body = RenameVocabularyRequest, + params(("id" = String, Path, description = "Vocabulary id (UUID)")), + responses( + (status = 204), + (status = 401), + (status = 403), + (status = 404), + (status = 409, description = "Key already in use") + ) +)] +pub(crate) async fn rename_vocabulary( + auth: Authorized, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let id = id + .parse::() + .map_err(|_| StatusCode::NOT_FOUND)?; + + let mut tx = state + .db + .pool() + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let existed = db::vocab::rename_vocabulary( + &mut tx, + AuditActor::User(auth.user.id.to_uuid()), + id, + &req.key, + ) + .await + .map_err(|err| { + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") { + StatusCode::CONFLICT + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + + if existed { + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +#[utoipa::path( + delete, path = "/api/admin/vocabularies/{id}", + params(("id" = String, Path, description = "Vocabulary id (UUID)")), + responses( + (status = 204), + (status = 401), + (status = 403), + (status = 404), + (status = 409, body = InUseView, description = "Has terms or is bound by a field") + ) +)] +pub(crate) async fn delete_vocabulary( + auth: Authorized, + State(state): State, + Path(id): Path, +) -> Response { + let Ok(id) = id.parse::() else { + return StatusCode::NOT_FOUND.into_response(); + }; + + let Ok(mut tx) = state.db.pool().begin().await else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + + match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await + { + Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, + Ok(db::DeleteOutcome::InUse { count }) => { + let _ = tx.rollback().await; + + (StatusCode::CONFLICT, Json(InUseView { count })).into_response() + } + Ok(db::DeleteOutcome::NotFound) => { + let _ = tx.rollback().await; + + StatusCode::NOT_FOUND.into_response() + } + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + pub(crate) fn routes() -> Router { Router::new() .route( "/api/admin/vocabularies", get(list_vocabularies).post(create_vocabulary), ) + .route( + "/api/admin/vocabularies/{id}", + axum::routing::patch(rename_vocabulary).delete(delete_vocabulary), + ) .route( "/api/admin/vocabularies/{id}/terms", get(list_terms).post(add_term), diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index dcf2e99..1484363 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -33,6 +33,8 @@ use crate::{ admin_vocab::add_term, admin_vocab::update_term, admin_vocab::delete_term, + admin_vocab::rename_vocabulary, + admin_vocab::delete_vocabulary, admin_search::search_objects, admin_authorities::list_authorities, admin_authorities::create_authority @@ -64,6 +66,7 @@ use crate::{ admin_vocab::CreatedId, admin_vocab::UpdateTermRequest, admin_vocab::InUseView, + admin_vocab::RenameVocabularyRequest, admin_search::SearchHitView, admin_search::SearchResultsView, admin_authorities::AuthorityView, diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index 336455f..4f9d05a 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -459,3 +459,131 @@ async fn term_edit_delete_requires_auth(pool: PgPool) { .unwrap(); assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED); } + +#[sqlx::test(migrations = "../db/migrations")] +async fn vocabulary_edit_delete_requires_auth(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + let app = build_app(state(pool)); + let vocab_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000"; + + let patch_resp = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(vocab_uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key":"x"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED); + + let delete_resp = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(vocab_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn rename_and_delete_vocabulary(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let v = send( + &app, + &cookie, + "POST", + "/api/admin/vocabularies", + Some(r#"{"key":"old"}"#), + ) + .await; + let vid: serde_json::Value = + serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vid = vid["id"].as_str().unwrap().to_owned(); + + let renamed = send( + &app, + &cookie, + "PATCH", + &format!("/api/admin/vocabularies/{vid}"), + Some(r#"{"key":"new"}"#), + ) + .await; + assert_eq!(renamed.status(), StatusCode::NO_CONTENT); + + let deleted = send( + &app, + &cookie, + "DELETE", + &format!("/api/admin/vocabularies/{vid}"), + None, + ) + .await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn delete_vocabulary_with_terms_is_409(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let v = send( + &app, + &cookie, + "POST", + "/api/admin/vocabularies", + Some(r#"{"key":"material"}"#), + ) + .await; + let vid: serde_json::Value = + serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vid = vid["id"].as_str().unwrap().to_owned(); + + send( + &app, + &cookie, + "POST", + &format!("/api/admin/vocabularies/{vid}/terms"), + Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#), + ) + .await; + + let blocked = send( + &app, + &cookie, + "DELETE", + &format!("/api/admin/vocabularies/{vid}"), + None, + ) + .await; + assert_eq!(blocked.status(), StatusCode::CONFLICT); + + let body: serde_json::Value = + serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(body["count"], 1); +} diff --git a/crates/db/src/vocab.rs b/crates/db/src/vocab.rs index 681ef14..4c8f9a9 100644 --- a/crates/db/src/vocab.rs +++ b/crates/db/src/vocab.rs @@ -293,6 +293,88 @@ pub async fn delete_term( Ok(crate::DeleteOutcome::Deleted) } +/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no +/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505). +pub async fn rename_vocabulary( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: VocabularyId, + key: &str, +) -> Result { + let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1") + .bind(id.to_uuid()) + .bind(key) + .execute(&mut *conn) + .await? + .rows_affected(); + + if updated == 0 { + return Ok(false); + } + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Updated, + entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + + Ok(true) +} + +/// Delete a vocabulary unless it still has terms or is bound by a field definition +/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry. +pub async fn delete_vocabulary( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: VocabularyId, +) -> Result { + let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1") + .bind(id.to_uuid()) + .fetch_optional(&mut *conn) + .await?; + + if exists.is_none() { + return Ok(crate::DeleteOutcome::NotFound); + } + + let count: i64 = sqlx::query_scalar( + "SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \ + + (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)", + ) + .bind(id.to_uuid()) + .fetch_one(&mut *conn) + .await?; + + if count > 0 { + return Ok(crate::DeleteOutcome::InUse { count }); + } + + sqlx::query("DELETE FROM vocabulary WHERE id = $1") + .bind(id.to_uuid()) + .execute(&mut *conn) + .await?; + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Deleted, + entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + + Ok(crate::DeleteOutcome::Deleted) +} + fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result { Ok(Vocabulary { id: VocabularyId::from_uuid(row.try_get("id")?), diff --git a/crates/db/tests/vocab.rs b/crates/db/tests/vocab.rs index 79564d3..2b5f1cb 100644 --- a/crates/db/tests/vocab.rs +++ b/crates/db/tests/vocab.rs @@ -313,3 +313,75 @@ async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) { .unwrap(); assert_eq!(gone, DeleteOutcome::NotFound); } + +#[sqlx::test(migrations = "../db/migrations")] +async fn rename_vocabulary_changes_key(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old") + .await + .unwrap(); + let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new") + .await + .unwrap(); + assert!(existed); + tx.commit().await.unwrap(); + + assert!( + vocab::vocabulary_by_key(db.pool(), "new") + .await + .unwrap() + .is_some() + ); + assert!( + vocab::vocabulary_by_key(db.pool(), "old") + .await + .unwrap() + .is_none() + ); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) { + use db::DeleteOutcome; + + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") + .await + .unwrap(); + vocab::add_term( + &mut tx, + AuditActor::System, + &NewTerm { + vocabulary_id: v.id, + external_uri: None, + labels: vec![LocalizedLabel { + lang: "sv".into(), + label: "Trä".into(), + }], + }, + ) + .await + .unwrap(); + + let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id) + .await + .unwrap(); + assert_eq!(blocked, DeleteOutcome::InUse { count: 1 }); + + let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty") + .await + .unwrap(); + assert_eq!( + vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id) + .await + .unwrap(), + DeleteOutcome::Deleted + ); + + let gone = vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id) + .await + .unwrap(); + assert_eq!(gone, DeleteOutcome::NotFound); +}