diff --git a/aifw-api/src/routes/schedules.rs b/aifw-api/src/routes/schedules.rs index 4b5c16ea..6ede563f 100644 --- a/aifw-api/src/routes/schedules.rs +++ b/aifw-api/src/routes/schedules.rs @@ -128,6 +128,10 @@ pub async fn create_schedule( sqlx::query("INSERT INTO schedules (id, name, description, time_ranges, days_of_week, enabled, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7)") .bind(&id).bind(&req.name).bind(req.description.as_deref()).bind(&time_ranges).bind(&dow).bind(enabled).bind(&now) .execute(&state.pool).await.map_err(|_| bad_request())?; + // A rule may already carry this id after a config import or cluster + // replication. Creating the referenced schedule can therefore change + // its effective state immediately; reload just like update/delete. + reapply_rules(&state).await?; Ok(( StatusCode::CREATED, Json(ApiResponse { @@ -186,23 +190,24 @@ pub async fn delete_schedule( State(state): State, Path(id): Path, ) -> Result, StatusCode> { + // Unlink and delete atomically. A dangling reference deliberately fails + // open in the compiler, so committing only the DELETE could unexpectedly + // activate a rule if the unlink failed. + let mut tx = state.pool.begin().await.map_err(|_| internal())?; let result = sqlx::query("DELETE FROM schedules WHERE id=?1") .bind(&id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|_| internal())?; if result.rows_affected() == 0 { return Err(StatusCode::NOT_FOUND); } - // Unlink from rules. On failure the dangling reference fails open in the - // engine (rule stays active) — warn so the operator can see why. - if let Err(e) = sqlx::query("UPDATE rules SET schedule_id = NULL WHERE schedule_id = ?1") + sqlx::query("UPDATE rules SET schedule_id = NULL WHERE schedule_id = ?1") .bind(&id) - .execute(&state.pool) + .execute(&mut *tx) .await - { - tracing::warn!(schedule_id = %id, error = %e, "schedule delete: rules unlink failed"); - } + .map_err(|_| internal())?; + tx.commit().await.map_err(|_| internal())?; reapply_rules(&state).await?; Ok(Json(MessageResponse { message: format!("Schedule {id} deleted"), diff --git a/aifw-api/src/tests.rs b/aifw-api/src/tests.rs index 61d88fc4..12324f40 100644 --- a/aifw-api/src/tests.rs +++ b/aifw-api/src/tests.rs @@ -1257,6 +1257,60 @@ mod tests { resp.assert_status(StatusCode::CREATED); } + #[tokio::test] + async fn test_schedule_rule_association_and_delete_unlink() { + let (server, _) = test_app().await; + let token = create_user_and_login(&server).await; + + let resp = server + .post("/api/v1/schedules") + .authorization_bearer(&token) + .json(&json!({ + "name":"overnight", + "time_ranges":"22:00-06:00", + "days_of_week":"mon,tue,wed,thu,fri" + })) + .await; + resp.assert_status(StatusCode::CREATED); + let body: Value = resp.json(); + let schedule_id = body["data"]["id"].as_str().unwrap(); + + let resp = server + .post("/api/v1/rules") + .authorization_bearer(&token) + .json(&json!({ + "action":"block", + "direction":"in", + "protocol":"tcp", + "schedule_id":schedule_id + })) + .await; + resp.assert_status(StatusCode::CREATED); + let rule_id = resp.json::()["data"]["id"] + .as_str() + .unwrap() + .to_string(); + + let resp = server + .get(&format!("/api/v1/rules/{rule_id}")) + .authorization_bearer(&token) + .await; + resp.assert_status_ok(); + assert_eq!(resp.json::()["data"]["schedule_id"], schedule_id); + + let resp = server + .delete(&format!("/api/v1/schedules/{schedule_id}")) + .authorization_bearer(&token) + .await; + resp.assert_status_ok(); + let resp = server + .get(&format!("/api/v1/rules/{rule_id}")) + .authorization_bearer(&token) + .await; + resp.assert_status_ok(); + assert!(resp.json::()["data"]["schedule_id"].is_null()); + } + #[tokio::test] async fn test_password_validation() { let (server, _) = test_app().await;