1use axum::extract::{Path, State};
10use axum::http::{HeaderMap, StatusCode};
11use axum::response::IntoResponse;
12use axum::Json;
13use cellos_core::events::{
14 cloud_event_v1_formation_completed, cloud_event_v1_formation_created,
15 cloud_event_v1_formation_degraded, cloud_event_v1_formation_failed,
16 cloud_event_v1_formation_launching, cloud_event_v1_formation_running,
17};
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use crate::auth::require_bearer;
22use crate::error::{AppError, AppErrorKind};
23use crate::state::{AppState, FormationRecord, FormationStatus};
24
25#[derive(Debug, Deserialize)]
42pub struct FormationDocument {
43 pub name: String,
44 pub coordinator: String,
45 pub members: Vec<FormationMember>,
46}
47
48#[derive(Debug, Deserialize)]
49pub struct FormationMember {
50 pub id: String,
51 #[serde(rename = "authorizedBy")]
54 pub authorized_by: Option<String>,
55}
56
57#[derive(Debug, Serialize)]
58pub struct FormationCreated {
59 pub id: Uuid,
60 pub name: String,
61 pub status: FormationStatus,
62}
63
64pub async fn create_formation(
67 State(state): State<AppState>,
68 headers: HeaderMap,
69 body: axum::body::Bytes,
70) -> Result<impl IntoResponse, AppError> {
71 require_bearer(&headers, &state.api_token)?;
72
73 let raw: serde_json::Value = serde_json::from_slice(&body)?;
82 let normalized = normalize_formation_document(&raw)?;
83 let doc: FormationDocument = serde_json::from_value(normalized.clone())?;
84
85 validate_formation_name(&doc.name)?;
92
93 validate_formation(&doc)?;
94
95 let id = Uuid::new_v4();
107 let record = FormationRecord {
108 id,
109 name: doc.name.clone(),
110 status: FormationStatus::Pending,
111 document: normalized,
115 };
116
117 {
118 let mut map = state.formations.write().await;
119 if let Some(existing) = map.values().find(|r| r.name == doc.name) {
120 return Err(AppError::new(
121 AppErrorKind::Conflict,
122 format!(
123 "formation name '{}' already in use by {}",
124 doc.name, existing.id
125 ),
126 ));
127 }
128 map.insert(id, record);
129 }
130
131 let cell_count = doc.members.len() as u32;
138 let no_failed: &[String] = &[];
139 let now_rfc3339 = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
140 let event = cloud_event_v1_formation_created(
141 &id.to_string(),
142 &now_rfc3339,
143 &id.to_string(),
144 &doc.name,
145 cell_count,
146 no_failed,
147 None,
148 );
149 let subject = format!("cellos.events.formations.{id}.created");
150 publish_event(&state, &subject, event).await;
151
152 let body = FormationCreated {
153 id,
154 name: doc.name,
155 status: FormationStatus::Pending,
156 };
157 Ok((StatusCode::CREATED, Json(body)))
158}
159
160#[derive(Debug, Serialize)]
166pub struct FormationsSnapshot {
167 pub formations: Vec<FormationRecord>,
168 pub cursor: u64,
169}
170
171pub async fn list_formations(
174 State(state): State<AppState>,
175 headers: HeaderMap,
176) -> Result<Json<FormationsSnapshot>, AppError> {
177 require_bearer(&headers, &state.api_token)?;
178 let map = state.formations.read().await;
179 Ok(Json(FormationsSnapshot {
180 formations: map.values().cloned().collect(),
181 cursor: state.cursor(),
182 }))
183}
184
185pub async fn get_formation(
187 State(state): State<AppState>,
188 headers: HeaderMap,
189 Path(id): Path<Uuid>,
190) -> Result<Json<FormationRecord>, AppError> {
191 require_bearer(&headers, &state.api_token)?;
192 let map = state.formations.read().await;
193 map.get(&id)
194 .cloned()
195 .map(Json)
196 .ok_or_else(|| AppError::not_found(format!("formation {id} not found")))
197}
198
199pub async fn get_formation_by_name(
213 State(state): State<AppState>,
214 headers: HeaderMap,
215 Path(name): Path<String>,
216) -> Result<Json<FormationRecord>, AppError> {
217 require_bearer(&headers, &state.api_token)?;
218 let map = state.formations.read().await;
219 map.values()
220 .find(|r| r.name == name)
221 .cloned()
222 .map(Json)
223 .ok_or_else(|| AppError::not_found(format!("formation '{name}' not found")))
224}
225
226pub async fn delete_formation_by_name(
231 State(state): State<AppState>,
232 headers: HeaderMap,
233 Path(name): Path<String>,
234) -> Result<StatusCode, AppError> {
235 require_bearer(&headers, &state.api_token)?;
236 let id = {
251 let map = state.formations.read().await;
252 let matches: Vec<Uuid> = map
253 .values()
254 .filter(|r| r.name == name)
255 .map(|r| r.id)
256 .collect();
257 match matches.len() {
258 0 => return Err(AppError::not_found(format!("formation '{name}' not found"))),
259 1 => matches[0],
260 _ => {
261 let mut ids = matches;
264 ids.sort();
265 let id_list = ids
266 .iter()
267 .map(Uuid::to_string)
268 .collect::<Vec<_>>()
269 .join(", ");
270 return Err(AppError::new(
271 AppErrorKind::Conflict,
272 format!(
273 "multiple formations share name '{name}': [{id_list}]; \
274 delete by UUID via /v1/formations/{{id}} to disambiguate"
275 ),
276 ));
277 }
278 }
279 };
280 delete_formation(State(state), headers, Path(id)).await
281}
282
283pub async fn delete_formation(
288 State(state): State<AppState>,
289 headers: HeaderMap,
290 Path(id): Path<Uuid>,
291) -> Result<StatusCode, AppError> {
292 require_bearer(&headers, &state.api_token)?;
293 let mut map = state.formations.write().await;
294 let (name, cell_count) = {
295 let entry = map
296 .get_mut(&id)
297 .ok_or_else(|| AppError::not_found(format!("formation {id} not found")))?;
298 entry.status = FormationStatus::Cancelled;
299 let members = entry
300 .document
301 .get("members")
302 .and_then(|m| m.as_array())
303 .map(|a| a.len() as u32)
304 .unwrap_or(0);
305 (entry.name.clone(), members)
306 };
307 drop(map);
308
309 let no_failed: &[String] = &[];
310 let now_rfc3339 = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
313 let event = cloud_event_v1_formation_failed(
314 &id.to_string(),
315 &now_rfc3339,
316 &id.to_string(),
317 &name,
318 cell_count,
319 no_failed,
320 Some("deleted by operator"),
321 );
322 let subject = format!("cellos.events.formations.{id}.failed");
323 publish_event(&state, &subject, event).await;
324
325 Ok(StatusCode::NO_CONTENT)
326}
327
328#[derive(Debug, Deserialize)]
333pub struct StatusTransition {
334 pub state: String,
335 pub reason: Option<String>,
336 pub failed_cells: Option<Vec<String>>,
337}
338
339pub async fn update_formation_status(
340 State(state): State<AppState>,
341 headers: HeaderMap,
342 Path(id): Path<Uuid>,
343 Json(body): Json<StatusTransition>,
344) -> Result<StatusCode, AppError> {
345 require_bearer(&headers, &state.api_token)?;
346
347 let (new_status, name, cell_count, failed) = {
348 let mut map = state.formations.write().await;
349 let entry = map
350 .get_mut(&id)
351 .ok_or_else(|| AppError::not_found(format!("formation {id} not found")))?;
352
353 let new_status = match body.state.to_uppercase().as_str() {
354 "RUNNING" | "LAUNCHING" => FormationStatus::Running,
355 "DEGRADED" => FormationStatus::Running, "COMPLETED" => FormationStatus::Succeeded,
357 "FAILED" => FormationStatus::Failed,
358 other => {
359 return Err(AppError::new(
365 AppErrorKind::BadRequest,
366 format!("unknown state: {other}"),
367 ));
368 }
369 };
370 entry.status = new_status;
371
372 let members = entry
373 .document
374 .get("members")
375 .and_then(|m| m.as_array())
376 .map(|a| a.len() as u32)
377 .unwrap_or(0);
378 let failed = body.failed_cells.unwrap_or_default();
379 (new_status, entry.name.clone(), members, failed)
380 };
381
382 let sid = id.to_string();
383 let reason = body.reason.as_deref();
384 let empty: &[String] = &[];
385 let now_rfc3339 = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
391
392 let (event, phase) = match body.state.to_uppercase().as_str() {
393 "LAUNCHING" => (
394 cloud_event_v1_formation_launching(
395 &sid,
396 &now_rfc3339,
397 &sid,
398 &name,
399 cell_count,
400 empty,
401 reason,
402 ),
403 "launching",
404 ),
405 "RUNNING" => (
406 cloud_event_v1_formation_running(
407 &sid,
408 &now_rfc3339,
409 &sid,
410 &name,
411 cell_count,
412 empty,
413 reason,
414 ),
415 "running",
416 ),
417 "DEGRADED" => (
418 cloud_event_v1_formation_degraded(
419 &sid,
420 &now_rfc3339,
421 &sid,
422 &name,
423 cell_count,
424 &failed,
425 reason,
426 ),
427 "degraded",
428 ),
429 "COMPLETED" => (
430 cloud_event_v1_formation_completed(
431 &sid,
432 &now_rfc3339,
433 &sid,
434 &name,
435 cell_count,
436 empty,
437 reason,
438 ),
439 "completed",
440 ),
441 _ => (
442 cloud_event_v1_formation_failed(
443 &sid,
444 &now_rfc3339,
445 &sid,
446 &name,
447 cell_count,
448 &failed,
449 reason,
450 ),
451 "failed",
452 ),
453 };
454
455 let subject = format!("cellos.events.formations.{id}.{phase}");
456 publish_event(&state, &subject, event).await;
457
458 let _ = new_status; Ok(StatusCode::NO_CONTENT)
460}
461
462async fn publish_event(state: &AppState, subject: &str, event: impl serde::Serialize) {
466 let Some(nats) = &state.nats else { return };
467 let payload = match serde_json::to_vec(&event) {
468 Ok(b) => b,
469 Err(e) => {
470 tracing::warn!(subject, error = %e, "failed to serialise formation CloudEvent");
471 return;
472 }
473 };
474 if let Err(e) = nats.publish(subject.to_owned(), payload.into()).await {
475 tracing::warn!(subject, error = %e, "failed to publish formation CloudEvent to NATS");
476 }
477}
478
479fn normalize_formation_document(raw: &serde_json::Value) -> Result<serde_json::Value, AppError> {
513 let Some(obj) = raw.as_object() else {
522 return Ok(raw.clone());
525 };
526
527 const FLAT_KEYS: &[&str] = &["name", "coordinator", "members"];
528 const KUBECTL_KEYS: &[&str] = &["apiVersion", "kind", "metadata", "spec"];
529
530 let flat_keys_present: Vec<&str> = FLAT_KEYS
531 .iter()
532 .copied()
533 .filter(|k| obj.contains_key(*k))
534 .collect();
535 let kubectl_keys_present: Vec<&str> = KUBECTL_KEYS
536 .iter()
537 .copied()
538 .filter(|k| obj.contains_key(*k))
539 .collect();
540
541 let has_flat = !flat_keys_present.is_empty();
542 let has_kubectl = !kubectl_keys_present.is_empty();
543
544 if has_flat && has_kubectl {
545 return Err(AppError::bad_request(format!(
546 "hybrid formation document: top-level flat field(s) {flat:?} \
547 conflict with kubectl-style envelope field(s) {kubectl:?}; \
548 pick exactly one shape (see contracts/schemas/formation-v1.schema.json)",
549 flat = flat_keys_present,
550 kubectl = kubectl_keys_present,
551 )));
552 }
553
554 if !has_kubectl {
555 return Ok(raw.clone());
558 }
559
560 let api_version = obj
562 .get("apiVersion")
563 .and_then(|v| v.as_str())
564 .ok_or_else(|| {
565 AppError::bad_request(
566 "kubectl-style formation: missing or non-string 'apiVersion' (expected \"cellos.dev/v1\")"
567 .to_string(),
568 )
569 })?;
570 if api_version != "cellos.dev/v1" {
571 return Err(AppError::bad_request(format!(
572 "kubectl-style formation: unsupported apiVersion '{api_version}' (expected \"cellos.dev/v1\")"
573 )));
574 }
575
576 let kind = obj.get("kind").and_then(|v| v.as_str()).ok_or_else(|| {
577 AppError::bad_request(
578 "kubectl-style formation: missing or non-string 'kind' (expected \"Formation\")"
579 .to_string(),
580 )
581 })?;
582 if kind != "Formation" {
583 return Err(AppError::bad_request(format!(
584 "kubectl-style formation: unsupported kind '{kind}' (expected \"Formation\")"
585 )));
586 }
587
588 let metadata = obj
589 .get("metadata")
590 .and_then(|v| v.as_object())
591 .ok_or_else(|| {
592 AppError::bad_request("kubectl-style formation: missing 'metadata' object".to_string())
593 })?;
594 let name = metadata
595 .get("name")
596 .and_then(|v| v.as_str())
597 .ok_or_else(|| {
598 AppError::bad_request("kubectl-style formation: missing 'metadata.name'".to_string())
599 })?;
600
601 let spec = obj.get("spec").and_then(|v| v.as_object()).ok_or_else(|| {
602 AppError::bad_request("kubectl-style formation: missing 'spec' object".to_string())
603 })?;
604
605 let coordinator = spec
606 .get("coordinator")
607 .and_then(|v| v.as_str())
608 .ok_or_else(|| {
609 AppError::bad_request("kubectl-style formation: missing 'spec.coordinator'".to_string())
610 })?;
611
612 let members_raw = spec
613 .get("members")
614 .and_then(|v| v.as_array())
615 .ok_or_else(|| {
616 AppError::bad_request(
617 "kubectl-style formation: missing or non-array 'spec.members'".to_string(),
618 )
619 })?;
620
621 let mut members_flat = Vec::with_capacity(members_raw.len());
627 for (idx, m) in members_raw.iter().enumerate() {
628 let m_obj = m.as_object().ok_or_else(|| {
629 AppError::bad_request(format!(
630 "kubectl-style formation: spec.members[{idx}] is not an object"
631 ))
632 })?;
633 let member_name = m_obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
634 AppError::bad_request(format!(
635 "kubectl-style formation: spec.members[{idx}] missing 'name'"
636 ))
637 })?;
638
639 if m_obj.contains_key("id") {
649 return Err(AppError::bad_request(format!(
650 "kubectl-style formation: spec.members[{idx}] declares both 'name' \
651 and 'id'; kubectl manifests address members by 'name' only — \
652 remove the 'id' field"
653 )));
654 }
655
656 let mut rewritten = serde_json::Map::with_capacity(m_obj.len());
657 rewritten.insert(
658 "id".to_string(),
659 serde_json::Value::String(member_name.to_string()),
660 );
661 for (k, v) in m_obj.iter() {
662 if k == "name" {
663 continue; }
665 rewritten.insert(k.clone(), v.clone());
668 }
669 members_flat.push(serde_json::Value::Object(rewritten));
670 }
671
672 let mut flat = serde_json::Map::with_capacity(3);
673 flat.insert(
674 "name".to_string(),
675 serde_json::Value::String(name.to_string()),
676 );
677 flat.insert(
678 "coordinator".to_string(),
679 serde_json::Value::String(coordinator.to_string()),
680 );
681 flat.insert(
682 "members".to_string(),
683 serde_json::Value::Array(members_flat),
684 );
685
686 Ok(serde_json::Value::Object(flat))
687}
688
689fn validate_formation_name(name: &str) -> Result<(), AppError> {
712 if name.is_empty() {
714 return Err(AppError::bad_request(
715 "formation name must not be empty".to_string(),
716 ));
717 }
718 if name.len() > 253 {
719 return Err(AppError::bad_request(format!(
720 "formation name length {} exceeds maximum of 253 bytes",
721 name.len()
722 )));
723 }
724
725 if name == "." || name == ".." {
727 return Err(AppError::bad_request(format!(
728 "formation name '{name}' is reserved"
729 )));
730 }
731
732 for (idx, b) in name.as_bytes().iter().enumerate() {
736 let ok = matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.');
737 if !ok {
738 let rendered = if b.is_ascii_graphic() {
742 format!("'{}'", *b as char)
743 } else {
744 format!("\\x{b:02x}")
745 };
746 return Err(AppError::bad_request(format!(
747 "formation name contains disallowed character {rendered} at byte offset {idx} \
748 (allowed: A-Z a-z 0-9 . - _)"
749 )));
750 }
751 }
752
753 let first = name.as_bytes()[0];
755 let last = name.as_bytes()[name.len() - 1];
756 if first == b'-' || first == b'.' {
757 return Err(AppError::bad_request(format!(
758 "formation name '{name}' must not start with '-' or '.'"
759 )));
760 }
761 if last == b'-' || last == b'.' {
762 return Err(AppError::bad_request(format!(
763 "formation name '{name}' must not end with '-' or '.'"
764 )));
765 }
766
767 Ok(())
768}
769
770fn validate_formation(doc: &FormationDocument) -> Result<(), AppError> {
793 use std::collections::{HashMap, HashSet};
794
795 let coord_present = doc.members.iter().any(|m| m.id == doc.coordinator);
797 if !coord_present {
798 return Err(AppError::new(
799 AppErrorKind::FormationNoCoordinator,
800 format!(
801 "coordinator '{}' must appear in members list",
802 doc.coordinator
803 ),
804 ));
805 }
806
807 let mut seen: HashSet<&str> = HashSet::new();
812 for m in &doc.members {
813 if !seen.insert(m.id.as_str()) {
814 return Err(AppError::new(
815 AppErrorKind::FormationMultipleCoordinators,
816 format!("duplicate member id '{}'", m.id),
817 ));
818 }
819 }
820
821 for m in &doc.members {
826 let is_coord = m.id == doc.coordinator;
827 match (is_coord, &m.authorized_by) {
828 (true, Some(_)) => {
829 return Err(AppError::new(
830 AppErrorKind::FormationAuthorityNotNarrowing,
831 format!("coordinator '{}' must not declare authorizedBy", m.id),
832 ));
833 }
834 (false, None) => {
835 return Err(AppError::new(
836 AppErrorKind::FormationAuthorityNotNarrowing,
837 format!("non-coordinator member '{}' missing authorizedBy", m.id),
838 ));
839 }
840 (false, Some(parent)) => {
841 if !seen.contains(parent.as_str()) {
842 return Err(AppError::new(
843 AppErrorKind::FormationAuthorityNotNarrowing,
844 format!("member '{}' references unknown parent '{}'", m.id, parent),
845 ));
846 }
847 }
848 _ => {}
849 }
850 }
851
852 let parent: HashMap<&str, &str> = doc
857 .members
858 .iter()
859 .filter_map(|m| m.authorized_by.as_deref().map(|p| (m.id.as_str(), p)))
860 .collect();
861
862 for m in &doc.members {
863 if m.id == doc.coordinator {
864 continue;
865 }
866 let mut cursor = m.id.as_str();
867 for _ in 0..doc.members.len() {
868 let Some(&p) = parent.get(cursor) else {
869 break;
872 };
873 if p == m.id {
874 return Err(AppError::new(
875 AppErrorKind::FormationCycle,
876 format!("authorizedBy cycle detected involving member '{}'", m.id),
877 ));
878 }
879 cursor = p;
880 }
881 if parent.contains_key(cursor) {
882 return Err(AppError::new(
886 AppErrorKind::FormationCycle,
887 format!(
888 "authorizedBy cycle detected on chain starting at '{}'",
889 m.id
890 ),
891 ));
892 }
893 }
894
895 Ok(())
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use crate::router;
902 use axum::body::Body;
903 use axum::http::{header, Request};
904 use http_body_util::BodyExt;
905 use tower::ServiceExt;
906
907 const TOKEN: &str = "test-token";
908
909 fn test_state() -> AppState {
910 AppState::new(None, TOKEN)
911 }
912
913 fn auth_req(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
914 let mut b = Request::builder()
915 .method(method)
916 .uri(uri)
917 .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"));
918 if body.is_some() {
919 b = b.header(header::CONTENT_TYPE, "application/json");
920 }
921 b.body(
922 body.map(|s| Body::from(s.to_owned()))
923 .unwrap_or_else(Body::empty),
924 )
925 .expect("build request")
926 }
927
928 #[tokio::test]
929 async fn post_valid_formation_returns_201() {
930 let app = router(test_state());
931 let body = serde_json::json!({
932 "name": "demo",
933 "coordinator": "coord",
934 "members": [
935 { "id": "coord" },
936 { "id": "worker-a", "authorizedBy": "coord" },
937 { "id": "worker-b", "authorizedBy": "coord" }
938 ]
939 })
940 .to_string();
941
942 let resp = app
943 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
944 .await
945 .expect("router response");
946 assert_eq!(resp.status(), StatusCode::CREATED);
947
948 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
949 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
950 assert_eq!(parsed["status"], "PENDING");
951 assert_eq!(parsed["name"], "demo");
952 assert!(parsed["id"].as_str().is_some());
953 }
954
955 #[tokio::test]
956 async fn post_formation_missing_coordinator_returns_400() {
957 let app = router(test_state());
958 let body = serde_json::json!({
960 "name": "demo",
961 "coordinator": "coord",
962 "members": [
963 { "id": "worker-a", "authorizedBy": "coord" }
964 ]
965 })
966 .to_string();
967
968 let resp = app
969 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
970 .await
971 .expect("router response");
972 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
973 let ct = resp
974 .headers()
975 .get(header::CONTENT_TYPE)
976 .and_then(|v| v.to_str().ok())
977 .unwrap_or_default()
978 .to_owned();
979 assert!(
980 ct.starts_with("application/problem+json"),
981 "expected RFC 9457 media type, got {ct:?}"
982 );
983 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
984 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
985 assert_eq!(parsed["type"], "/problems/formation/no-coordinator");
986 }
987
988 #[tokio::test]
989 async fn post_formation_member_missing_authorized_by_returns_400() {
990 let app = router(test_state());
991 let body = serde_json::json!({
992 "name": "demo",
993 "coordinator": "coord",
994 "members": [
995 { "id": "coord" },
996 { "id": "worker-a" } ]
998 })
999 .to_string();
1000
1001 let resp = app
1002 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1003 .await
1004 .expect("router response");
1005 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1006 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1007 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1008 assert_eq!(
1009 parsed["type"], "/problems/formation/authority-not-narrowing",
1010 "expected authority-not-narrowing discriminant, got {parsed}"
1011 );
1012 }
1013
1014 #[tokio::test]
1015 async fn get_formations_returns_snapshot_with_cursor() {
1016 let app = router(test_state());
1018 let resp = app
1019 .oneshot(auth_req("GET", "/v1/formations", None))
1020 .await
1021 .expect("router response");
1022 assert_eq!(resp.status(), StatusCode::OK);
1023 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1024 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1025 assert!(parsed.is_object(), "expected snapshot object, got {parsed}");
1026 let arr = parsed["formations"].as_array().expect("formations array");
1027 assert_eq!(arr.len(), 0);
1028 assert!(
1029 parsed["cursor"].is_u64(),
1030 "cursor field must be an unsigned integer, got {}",
1031 parsed["cursor"]
1032 );
1033 assert_eq!(parsed["cursor"].as_u64(), Some(0));
1034 }
1035
1036 #[tokio::test]
1037 async fn snapshot_returns_cursor() {
1038 let app = router(test_state());
1042 let body = serde_json::json!({
1043 "name": "with-cursor",
1044 "coordinator": "coord",
1045 "members": [
1046 { "id": "coord" },
1047 { "id": "worker-a", "authorizedBy": "coord" }
1048 ]
1049 })
1050 .to_string();
1051
1052 let resp = app
1053 .clone()
1054 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1055 .await
1056 .expect("router response");
1057 assert_eq!(resp.status(), StatusCode::CREATED);
1058
1059 let resp = app
1060 .oneshot(auth_req("GET", "/v1/formations", None))
1061 .await
1062 .expect("router response");
1063 assert_eq!(resp.status(), StatusCode::OK);
1064 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1065 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1066 assert!(
1067 parsed["cursor"].is_u64(),
1068 "cursor must be unsigned integer; got {}",
1069 parsed["cursor"]
1070 );
1071 let formations = parsed["formations"].as_array().expect("formations array");
1072 assert_eq!(formations.len(), 1, "expected 1 formation after POST");
1073 assert_eq!(formations[0]["name"], "with-cursor");
1074 }
1075
1076 #[tokio::test]
1077 async fn missing_bearer_returns_401() {
1078 let app = router(test_state());
1079 let resp = app
1080 .oneshot(
1081 Request::builder()
1082 .method("GET")
1083 .uri("/v1/formations")
1084 .body(Body::empty())
1085 .unwrap(),
1086 )
1087 .await
1088 .expect("router response");
1089 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1090 }
1091
1092 #[tokio::test]
1098 async fn rejects_duplicate_member_ids_with_multiple_coordinators_type() {
1099 let app = router(test_state());
1100 let body = serde_json::json!({
1101 "name": "dup-ids",
1102 "coordinator": "coord",
1103 "members": [
1104 { "id": "coord" },
1105 { "id": "coord", "authorizedBy": "coord" }
1106 ]
1107 })
1108 .to_string();
1109 let resp = app
1110 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1111 .await
1112 .expect("router response");
1113 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1114 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1115 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1116 assert_eq!(
1117 parsed["type"], "/problems/formation/multiple-coordinators",
1118 "duplicate member ids must surface multipleCoordinators"
1119 );
1120 }
1121
1122 #[tokio::test]
1125 async fn rejects_self_authorized_cycle() {
1126 let app = router(test_state());
1127 let body = serde_json::json!({
1128 "name": "self-cycle",
1129 "coordinator": "coord",
1130 "members": [
1131 { "id": "coord" },
1132 { "id": "worker-a", "authorizedBy": "worker-a" }
1133 ]
1134 })
1135 .to_string();
1136 let resp = app
1137 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1138 .await
1139 .expect("router response");
1140 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1141 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1142 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1143 assert_eq!(parsed["type"], "/problems/formation/cycle");
1144 }
1145
1146 #[tokio::test]
1148 async fn rejects_two_node_cycle() {
1149 let app = router(test_state());
1150 let body = serde_json::json!({
1151 "name": "two-cycle",
1152 "coordinator": "coord",
1153 "members": [
1154 { "id": "coord" },
1155 { "id": "a", "authorizedBy": "b" },
1156 { "id": "b", "authorizedBy": "a" }
1157 ]
1158 })
1159 .to_string();
1160 let resp = app
1161 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1162 .await
1163 .expect("router response");
1164 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1165 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1166 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1167 assert_eq!(parsed["type"], "/problems/formation/cycle");
1168 }
1169
1170 #[tokio::test]
1174 async fn rejects_orphan_parent_reference() {
1175 let app = router(test_state());
1176 let body = serde_json::json!({
1177 "name": "orphan-parent",
1178 "coordinator": "coord",
1179 "members": [
1180 { "id": "coord" },
1181 { "id": "worker-a", "authorizedBy": "ghost" }
1182 ]
1183 })
1184 .to_string();
1185 let resp = app
1186 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1187 .await
1188 .expect("router response");
1189 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1190 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1191 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1192 assert_eq!(
1193 parsed["type"],
1194 "/problems/formation/authority-not-narrowing"
1195 );
1196 }
1197
1198 #[tokio::test]
1202 async fn post_formation_oversized_body_returns_413() {
1203 let app = router(test_state());
1204 let big = "x".repeat(70 * 1024);
1209 let body =
1210 format!(r#"{{"name":"{big}","coordinator":"coord","members":[{{"id":"coord"}}]}}"#,);
1211 let resp = app
1212 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1213 .await
1214 .expect("router response");
1215 assert_eq!(
1216 resp.status(),
1217 StatusCode::PAYLOAD_TOO_LARGE,
1218 "oversized body must surface 413; got {:?}",
1219 resp.status(),
1220 );
1221 }
1222
1223 #[tokio::test]
1235 async fn get_formation_by_id_captures_path() {
1236 let state = test_state();
1237 let body = serde_json::json!({
1238 "name": "probe",
1239 "coordinator": "coord",
1240 "members": [
1241 { "id": "coord" },
1242 { "id": "worker-a", "authorizedBy": "coord" }
1243 ]
1244 })
1245 .to_string();
1246 let resp = router(state.clone())
1247 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1248 .await
1249 .expect("router response");
1250 assert_eq!(resp.status(), StatusCode::CREATED);
1251 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1252 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1253 let id = parsed["id"].as_str().expect("uuid string");
1254
1255 let resp = router(state)
1256 .oneshot(auth_req("GET", &format!("/v1/formations/{id}"), None))
1257 .await
1258 .expect("router response");
1259 assert_eq!(
1260 resp.status(),
1261 StatusCode::OK,
1262 "GET /v1/formations/<id> must capture the path segment; got {:?}",
1263 resp.status(),
1264 );
1265 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1266 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1267 assert_eq!(parsed["name"], "probe");
1268 }
1269
1270 #[tokio::test]
1275 async fn post_kubectl_style_formation_returns_201() {
1276 let app = router(test_state());
1277 let body = serde_json::json!({
1278 "apiVersion": "cellos.dev/v1",
1279 "kind": "Formation",
1280 "metadata": { "name": "kubectl-demo" },
1281 "spec": {
1282 "coordinator": "coord",
1283 "members": [
1284 { "name": "coord" },
1285 { "name": "worker-a", "authorizedBy": "coord" },
1286 { "name": "worker-b", "authorizedBy": "coord" }
1287 ]
1288 }
1289 })
1290 .to_string();
1291
1292 let resp = app
1293 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1294 .await
1295 .expect("router response");
1296 assert_eq!(resp.status(), StatusCode::CREATED);
1297
1298 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1299 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1300 assert_eq!(parsed["status"], "PENDING");
1301 assert_eq!(parsed["name"], "kubectl-demo");
1302 assert!(parsed["id"].as_str().is_some());
1303 }
1304
1305 #[tokio::test]
1310 async fn post_kubectl_style_missing_coordinator_returns_no_coordinator() {
1311 let app = router(test_state());
1312 let body = serde_json::json!({
1313 "apiVersion": "cellos.dev/v1",
1314 "kind": "Formation",
1315 "metadata": { "name": "missing-coord" },
1316 "spec": {
1317 "coordinator": "coord",
1318 "members": [
1319 { "name": "worker-a", "authorizedBy": "coord" }
1320 ]
1321 }
1322 })
1323 .to_string();
1324
1325 let resp = app
1326 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1327 .await
1328 .expect("router response");
1329 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1330 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1331 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1332 assert_eq!(parsed["type"], "/problems/formation/no-coordinator");
1333 }
1334
1335 #[tokio::test]
1339 async fn post_hybrid_formation_returns_400_bad_request() {
1340 let app = router(test_state());
1341 let body = serde_json::json!({
1342 "apiVersion": "cellos.dev/v1",
1343 "kind": "Formation",
1344 "metadata": { "name": "hybrid" },
1345 "spec": {
1346 "coordinator": "coord",
1347 "members": [ { "name": "coord" } ]
1348 },
1349 "name": "hybrid",
1351 "members": [ { "id": "coord" } ]
1352 })
1353 .to_string();
1354
1355 let resp = app
1356 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1357 .await
1358 .expect("router response");
1359 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1360 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1361 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1362 assert_eq!(
1363 parsed["type"], "/problems/bad-request",
1364 "hybrid shape must surface a generic bad-request, not an admission discriminant"
1365 );
1366 let detail = parsed["detail"].as_str().unwrap_or_default();
1367 assert!(
1368 detail.contains("hybrid"),
1369 "detail must mention 'hybrid'; got {detail:?}"
1370 );
1371 }
1372
1373 #[tokio::test]
1376 async fn post_kubectl_style_wrong_api_version_returns_400() {
1377 let app = router(test_state());
1378 let body = serde_json::json!({
1379 "apiVersion": "cellos.dev/v2",
1380 "kind": "Formation",
1381 "metadata": { "name": "wrong-api" },
1382 "spec": {
1383 "coordinator": "coord",
1384 "members": [ { "name": "coord" } ]
1385 }
1386 })
1387 .to_string();
1388
1389 let resp = app
1390 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1391 .await
1392 .expect("router response");
1393 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1394 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1395 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1396 assert_eq!(parsed["type"], "/problems/bad-request");
1397 let detail = parsed["detail"].as_str().unwrap_or_default();
1398 assert!(
1399 detail.contains("apiVersion") && detail.contains("cellos.dev/v2"),
1400 "detail must name the bad apiVersion; got {detail:?}"
1401 );
1402 }
1403
1404 #[tokio::test]
1406 async fn post_kubectl_style_wrong_kind_returns_400() {
1407 let app = router(test_state());
1408 let body = serde_json::json!({
1409 "apiVersion": "cellos.dev/v1",
1410 "kind": "Cell",
1411 "metadata": { "name": "wrong-kind" },
1412 "spec": {
1413 "coordinator": "coord",
1414 "members": [ { "name": "coord" } ]
1415 }
1416 })
1417 .to_string();
1418
1419 let resp = app
1420 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1421 .await
1422 .expect("router response");
1423 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1424 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1425 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1426 assert_eq!(parsed["type"], "/problems/bad-request");
1427 let detail = parsed["detail"].as_str().unwrap_or_default();
1428 assert!(
1429 detail.contains("kind") && detail.contains("Cell"),
1430 "detail must name the bad kind; got {detail:?}"
1431 );
1432 }
1433
1434 #[tokio::test]
1438 async fn kubectl_style_post_then_get_returns_normalized_flat_document() {
1439 let state = test_state();
1440 let body = serde_json::json!({
1441 "apiVersion": "cellos.dev/v1",
1442 "kind": "Formation",
1443 "metadata": { "name": "roundtrip" },
1444 "spec": {
1445 "coordinator": "coord",
1446 "members": [
1447 { "name": "coord" },
1448 { "name": "worker-a", "authorizedBy": "coord" }
1449 ]
1450 }
1451 })
1452 .to_string();
1453
1454 let resp = router(state.clone())
1455 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1456 .await
1457 .expect("router response");
1458 assert_eq!(resp.status(), StatusCode::CREATED);
1459 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1460 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1461 let id = parsed["id"].as_str().expect("uuid string");
1462
1463 let resp = router(state)
1464 .oneshot(auth_req("GET", &format!("/v1/formations/{id}"), None))
1465 .await
1466 .expect("router response");
1467 assert_eq!(resp.status(), StatusCode::OK);
1468 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1469 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1470 let doc = &parsed["document"];
1471 assert_eq!(doc["name"], "roundtrip", "flat 'name' present");
1472 assert_eq!(doc["coordinator"], "coord", "flat 'coordinator' present");
1473 let members = doc["members"]
1474 .as_array()
1475 .expect("members array on normalized doc");
1476 assert_eq!(members.len(), 2);
1477 assert_eq!(members[0]["id"], "coord");
1478 assert_eq!(members[1]["id"], "worker-a");
1479 assert_eq!(members[1]["authorizedBy"], "coord");
1480 assert!(
1482 doc.get("apiVersion").is_none(),
1483 "kubectl envelope must not leak into normalized doc"
1484 );
1485 assert!(doc.get("kind").is_none());
1486 assert!(doc.get("metadata").is_none());
1487 assert!(doc.get("spec").is_none());
1488 }
1489
1490 #[tokio::test]
1497 async fn kubectl_member_with_explicit_id_returns_400() {
1498 let app = router(test_state());
1499 let body = serde_json::json!({
1500 "apiVersion": "cellos.dev/v1",
1501 "kind": "Formation",
1502 "metadata": { "name": "rt3-ctl-003-a" },
1503 "spec": {
1504 "coordinator": "alice",
1505 "members": [
1506 { "name": "alice", "id": "bob" },
1511 { "name": "worker-a", "authorizedBy": "alice" }
1512 ]
1513 }
1514 })
1515 .to_string();
1516
1517 let resp = app
1518 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1519 .await
1520 .expect("router response");
1521 assert_eq!(
1522 resp.status(),
1523 StatusCode::BAD_REQUEST,
1524 "manifest with both name+id at member level must be rejected"
1525 );
1526 let ct = resp
1527 .headers()
1528 .get(header::CONTENT_TYPE)
1529 .and_then(|v| v.to_str().ok())
1530 .unwrap_or_default()
1531 .to_owned();
1532 assert!(
1533 ct.starts_with("application/problem+json"),
1534 "expected RFC 9457 media type, got {ct:?}"
1535 );
1536 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1537 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1538 assert_eq!(
1539 parsed["type"], "/problems/bad-request",
1540 "kubectl id-conflict is a generic bad-request, not an ADR-0010 discriminant"
1541 );
1542 let detail = parsed["detail"].as_str().unwrap_or_default();
1543 assert!(
1544 detail.contains("'name'") && detail.contains("'id'"),
1545 "detail must name both conflicting fields; got {detail:?}"
1546 );
1547 }
1548
1549 #[tokio::test]
1556 async fn delete_by_name_with_duplicates_returns_409() {
1557 let state = test_state();
1558
1559 let id_a = Uuid::new_v4();
1564 let id_b = Uuid::new_v4();
1565 {
1566 let mut map = state.formations.write().await;
1567 for id in [id_a, id_b] {
1568 map.insert(
1569 id,
1570 FormationRecord {
1571 id,
1572 name: "rt3-dup".to_string(),
1573 status: FormationStatus::Pending,
1574 document: serde_json::json!({"name": "rt3-dup"}),
1575 },
1576 );
1577 }
1578 }
1579
1580 let resp = router(state.clone())
1581 .oneshot(auth_req("DELETE", "/v1/formations/by-name/rt3-dup", None))
1582 .await
1583 .expect("router response");
1584 assert_eq!(
1585 resp.status(),
1586 StatusCode::CONFLICT,
1587 "duplicate-name DELETE must surface 409, not silently delete"
1588 );
1589
1590 let ct = resp
1591 .headers()
1592 .get(header::CONTENT_TYPE)
1593 .and_then(|v| v.to_str().ok())
1594 .unwrap_or_default()
1595 .to_owned();
1596 assert!(
1597 ct.starts_with("application/problem+json"),
1598 "expected RFC 9457 media type, got {ct:?}"
1599 );
1600 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1601 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1602 assert_eq!(parsed["type"], "/problems/conflict");
1603 let detail = parsed["detail"].as_str().unwrap_or_default();
1604 assert!(
1605 detail.contains(&id_a.to_string()) && detail.contains(&id_b.to_string()),
1606 "detail must list BOTH conflicting UUIDs so the operator can disambiguate; \
1607 got {detail:?}"
1608 );
1609 assert!(
1610 detail.contains("rt3-dup"),
1611 "detail must name the conflicting formation name; got {detail:?}"
1612 );
1613
1614 let map = state.formations.read().await;
1616 assert!(map.contains_key(&id_a), "id_a must still exist after 409");
1617 assert!(map.contains_key(&id_b), "id_b must still exist after 409");
1618 assert_eq!(map.get(&id_a).unwrap().status, FormationStatus::Pending);
1619 assert_eq!(map.get(&id_b).unwrap().status, FormationStatus::Pending);
1620 }
1621
1622 #[tokio::test]
1627 async fn unknown_state_returns_bad_request_problem_type() {
1628 let state = test_state();
1629 let body = serde_json::json!({
1630 "name": "demo",
1631 "coordinator": "coord",
1632 "members": [
1633 { "id": "coord" },
1634 { "id": "worker-a", "authorizedBy": "coord" }
1635 ]
1636 })
1637 .to_string();
1638 let resp = router(state.clone())
1639 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1640 .await
1641 .expect("router response");
1642 assert_eq!(resp.status(), StatusCode::CREATED);
1643 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1644 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1645 let id = parsed["id"].as_str().expect("uuid string").to_owned();
1646
1647 let bad = serde_json::json!({ "state": "TELEPORTING" }).to_string();
1648 let resp = router(state)
1649 .oneshot(auth_req(
1650 "POST",
1651 &format!("/v1/formations/{id}/status"),
1652 Some(&bad),
1653 ))
1654 .await
1655 .expect("router response");
1656 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1657 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1658 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1659 assert_eq!(
1660 parsed["type"], "/problems/bad-request",
1661 "unknown state must surface generic bad-request, not an ADR-0010 discriminant"
1662 );
1663 }
1664
1665 fn minimal_body(name: &str) -> String {
1678 serde_json::json!({
1679 "name": name,
1680 "coordinator": "coord",
1681 "members": [
1682 { "id": "coord" },
1683 { "id": "worker-a", "authorizedBy": "coord" }
1684 ]
1685 })
1686 .to_string()
1687 }
1688
1689 async fn assert_name_rejected_bad_request(name: &str, expect_in_detail: &str) {
1694 let app = router(test_state());
1695 let body = minimal_body(name);
1696 let resp = app
1697 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1698 .await
1699 .expect("router response");
1700 assert_eq!(
1701 resp.status(),
1702 StatusCode::BAD_REQUEST,
1703 "name {name:?} must be rejected with 400; got {:?}",
1704 resp.status()
1705 );
1706 let ct = resp
1707 .headers()
1708 .get(header::CONTENT_TYPE)
1709 .and_then(|v| v.to_str().ok())
1710 .unwrap_or_default()
1711 .to_owned();
1712 assert!(
1713 ct.starts_with("application/problem+json"),
1714 "name {name:?} must surface RFC 9457 media type; got {ct:?}"
1715 );
1716 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1717 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1718 assert_eq!(
1719 parsed["type"], "/problems/bad-request",
1720 "name {name:?} must surface generic bad-request, not an admission discriminant; got {parsed}"
1721 );
1722 let detail = parsed["detail"].as_str().unwrap_or_default();
1723 assert!(
1724 detail.contains(expect_in_detail),
1725 "detail for {name:?} must contain {expect_in_detail:?}; got {detail:?}"
1726 );
1727 }
1728
1729 #[tokio::test]
1731 async fn rejects_empty_name() {
1732 assert_name_rejected_bad_request("", "empty").await;
1733 }
1734
1735 #[tokio::test]
1738 async fn rejects_whitespace_only_name() {
1739 assert_name_rejected_bad_request(" ", "disallowed character").await;
1740 }
1741
1742 #[tokio::test]
1745 async fn rejects_newline_in_name() {
1746 assert_name_rejected_bad_request("a\nb", "disallowed character").await;
1747 }
1748
1749 #[tokio::test]
1752 async fn rejects_tab_in_name() {
1753 assert_name_rejected_bad_request("a\tb", "disallowed character").await;
1754 }
1755
1756 #[tokio::test]
1759 async fn rejects_nul_byte_in_name() {
1760 assert_name_rejected_bad_request("a\0b", "disallowed character").await;
1761 }
1762
1763 #[tokio::test]
1767 async fn rejects_non_ascii_in_name() {
1768 assert_name_rejected_bad_request("café", "disallowed character").await;
1769 }
1770
1771 #[tokio::test]
1774 async fn rejects_overlong_name() {
1775 let long = "a".repeat(254);
1776 assert_name_rejected_bad_request(&long, "exceeds maximum").await;
1777 }
1778
1779 #[tokio::test]
1782 async fn rejects_leading_hyphen() {
1783 assert_name_rejected_bad_request("-leading", "start with").await;
1784 }
1785
1786 #[tokio::test]
1789 async fn rejects_trailing_dot() {
1790 assert_name_rejected_bad_request("trailing.", "end with").await;
1791 }
1792
1793 #[tokio::test]
1797 async fn rejects_single_dot_reserved_name() {
1798 assert_name_rejected_bad_request(".", "reserved").await;
1799 }
1800
1801 #[tokio::test]
1805 async fn rejects_double_dot_reserved_name() {
1806 let app = router(test_state());
1807 let body = minimal_body("..");
1808 let resp = app
1809 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1810 .await
1811 .expect("router response");
1812 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1813 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1814 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1815 assert_eq!(parsed["type"], "/problems/bad-request");
1816 }
1817
1818 #[tokio::test]
1820 async fn accepts_simple_lowercase_name() {
1821 let app = router(test_state());
1822 let body = minimal_body("demo");
1823 let resp = app
1824 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1825 .await
1826 .expect("router response");
1827 assert_eq!(resp.status(), StatusCode::CREATED);
1828 }
1829
1830 #[tokio::test]
1833 async fn accepts_hyphenated_name() {
1834 let app = router(test_state());
1835 let body = minimal_body("my-formation-v2");
1836 let resp = app
1837 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1838 .await
1839 .expect("router response");
1840 assert_eq!(resp.status(), StatusCode::CREATED);
1841 }
1842
1843 #[tokio::test]
1846 async fn accepts_dotted_and_underscored_name() {
1847 let app = router(test_state());
1848 let body = minimal_body("team.alpha_one");
1849 let resp = app
1850 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1851 .await
1852 .expect("router response");
1853 assert_eq!(resp.status(), StatusCode::CREATED);
1854 }
1855
1856 #[tokio::test]
1869 async fn duplicate_name_returns_409() {
1870 let state = test_state();
1871 let body = minimal_body("demo");
1872
1873 let resp = router(state.clone())
1875 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1876 .await
1877 .expect("router response");
1878 assert_eq!(
1879 resp.status(),
1880 StatusCode::CREATED,
1881 "first POST must succeed"
1882 );
1883 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1884 let first: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1885 let first_id = first["id"]
1886 .as_str()
1887 .expect("first POST returned uuid")
1888 .to_owned();
1889
1890 let resp = router(state.clone())
1892 .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1893 .await
1894 .expect("router response");
1895 assert_eq!(
1896 resp.status(),
1897 StatusCode::CONFLICT,
1898 "duplicate name must surface 409; got {:?}",
1899 resp.status()
1900 );
1901 let ct = resp
1902 .headers()
1903 .get(header::CONTENT_TYPE)
1904 .and_then(|v| v.to_str().ok())
1905 .unwrap_or_default()
1906 .to_owned();
1907 assert!(
1908 ct.starts_with("application/problem+json"),
1909 "409 must use RFC 9457 media type; got {ct:?}"
1910 );
1911 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1912 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1913 assert_eq!(parsed["type"], "/problems/conflict");
1914 let detail = parsed["detail"].as_str().unwrap_or_default();
1915 assert!(
1916 detail.contains(&first_id),
1917 "conflict detail must name the existing UUID {first_id}; got {detail:?}"
1918 );
1919 assert!(
1920 detail.contains("demo"),
1921 "conflict detail must name the conflicting name; got {detail:?}"
1922 );
1923
1924 let resp = router(state)
1927 .oneshot(auth_req("GET", "/v1/formations/by-name/demo", None))
1928 .await
1929 .expect("router response");
1930 assert_eq!(resp.status(), StatusCode::OK);
1931 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1932 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1933 assert_eq!(
1934 parsed["id"].as_str(),
1935 Some(first_id.as_str()),
1936 "first formation must remain addressable by name"
1937 );
1938 }
1939}