diff --git a/crates/rhema-claw/src/http.rs b/crates/rhema-claw/src/http.rs index 242ae6e..867619d 100644 --- a/crates/rhema-claw/src/http.rs +++ b/crates/rhema-claw/src/http.rs @@ -237,12 +237,8 @@ pub fn router(state: ClawState) -> Router { .route("/bootstrap", get(bootstrap_status)) .route("/watches", get(watches_list).post(watches_add)) .route("/watches/{forge}/{*repo}", delete(watches_remove)) - .route( - "/calendars", - get(calendars_list) - .post(calendars_add) - .delete(calendars_remove), - ) + .route("/calendars", get(calendars_list).post(calendars_add)) + .route("/calendars/{name}", delete(calendars_remove)) .route("/accounts", get(accounts_list).post(accounts_add)) .route("/accounts/{id}", delete(accounts_remove)) .route("/consents", get(consents_list)) @@ -658,17 +654,10 @@ async fn calendars_add( } } -/// `DELETE /v1/calendars?name=…`: forget a calendar; the reminders +/// `DELETE /v1/calendars/{name}`: forget a calendar; the reminders /// source stops polling it on the next tick. -async fn calendars_remove( - State(state): State, - Query(params): Query>, -) -> Response { - let Some(name) = params - .get("name") - .map(|n| n.trim()) - .filter(|n| !n.is_empty()) - else { +async fn calendars_remove(State(state): State, Path(name): Path) -> Response { + let Some(name) = Some(name.trim()).filter(|n| !n.is_empty()) else { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "name is required"})), diff --git a/crates/rhema-claw/tests/http.rs b/crates/rhema-claw/tests/http.rs index 8c18fac..4c8f1a2 100644 --- a/crates/rhema-claw/tests/http.rs +++ b/crates/rhema-claw/tests/http.rs @@ -955,7 +955,7 @@ async fn calendars_round_trip_over_their_door() { // Removal is a delete on the name; the next pass simply has one // fewer feed. let res = client - .delete(format!("{base}/v1/calendars?name=team")) + .delete(format!("{base}/v1/calendars/team")) .send() .await .expect("the delete answers"); diff --git a/tools/contract/src/conformance.rs b/tools/contract/src/conformance.rs index 31b0be7..2123d09 100644 --- a/tools/contract/src/conformance.rs +++ b/tools/contract/src/conformance.rs @@ -96,6 +96,53 @@ where ) .await?; + // — Calendars: add, the invalid refused, listed, removed — + ctx.expect( + "the agent lists calendars", + req("GET", "/v1/calendars", Auth::Agent, Some(1), None), + 200, + ) + .await?; + ctx.expect_success( + "the agent adds a calendar", + req( + "POST", + "/v1/calendars", + Auth::Agent, + Some(1), + Some(serde_json::json!({ + "name": "team", "kind": "ics", "url": "https://c.example.net/a.ics" + })), + ), + ) + .await?; + ctx.expect( + "an urlless ics is refused loudly", + req( + "POST", + "/v1/calendars", + Auth::Agent, + Some(1), + Some(serde_json::json!({"name": "broken", "kind": "ics"})), + ), + 400, + ) + .await?; + let listed = ctx + .expect_success( + "the added calendar is listed", + req("GET", "/v1/calendars", Auth::Agent, Some(1), None), + ) + .await?; + if !listed.body.to_string().contains("team") { + return Err("the added calendar is listed: the name is missing".into()); + } + ctx.expect_success( + "the agent removes the calendar", + req("DELETE", "/v1/calendars/team", Auth::Agent, Some(1), None), + ) + .await?; + // — Notifications: push with and without a device — let pushed = ctx .expect_success( diff --git a/tools/contract/src/lib.rs b/tools/contract/src/lib.rs index 700848b..4691913 100644 --- a/tools/contract/src/lib.rs +++ b/tools/contract/src/lib.rs @@ -39,6 +39,10 @@ pub const TASKS: &str = "/v1/tasks"; /// `DELETE /v1/watches/{forge}/{repo}` removes. pub const WATCHES: &str = "/v1/watches"; +/// The calendar table: `GET` lists, `POST` adds (a validated +/// calendar spec), `DELETE /v1/calendars/{name}` removes. +pub const CALENDARS: &str = "/v1/calendars"; + /// The route allowlist the agent token answers for. The claw's auth /// middleware and the mock router both run this — one list, so the /// daemon's enforcement and the contract's conformance suite cannot @@ -56,6 +60,9 @@ pub fn agent_allowed(method: &str, path: &str) -> bool { ("GET", &["v1", "watches"]), ("POST", &["v1", "watches"]), ("DELETE", &["v1", "watches", "*", "**"]), + ("GET", &["v1", "calendars"]), + ("POST", &["v1", "calendars"]), + ("DELETE", &["v1", "calendars", "*"]), ("GET", &["v1", "notifications"]), ("POST", &["v1", "notifications"]), ("GET", &["v1", "tasks"]), diff --git a/tools/contract/src/mock.rs b/tools/contract/src/mock.rs index 8f5e5be..f09313e 100644 --- a/tools/contract/src/mock.rs +++ b/tools/contract/src/mock.rs @@ -24,6 +24,7 @@ pub struct MockClaw { notifications: Mutex>, tasks: Mutex>, watches: Mutex>, + calendars: Mutex>, notification_ids: Mutex, task_ids: Mutex, } @@ -43,6 +44,7 @@ impl MockClaw { notifications: Mutex::new(Vec::new()), tasks: Mutex::new(Vec::new()), watches: Mutex::new(Vec::new()), + calendars: Mutex::new(Vec::new()), notification_ids: Mutex::new(0), task_ids: Mutex::new(0), } @@ -100,6 +102,38 @@ impl MockClaw { watches.push(json!({"forge": body.forge, "repo": body.repo})); Ok(self.answer(200, json!({"forge": body.forge, "repo": body.repo}))) } + ("GET", ["v1", "calendars"]) => Ok(self.answer(200, json!({ + "calendars": self.calendars.lock().unwrap_or_else(PoisonError::into_inner).clone(), + }))), + ("POST", ["v1", "calendars"]) => { + // The mock enforces the door's loudest rule — a named + // calendar, and for an ics a URL — so the suite's + // refusal case reads the same on both sides. + let body = request.body.clone().unwrap_or(Value::Null); + let name = body["name"].as_str().map(str::trim).unwrap_or(""); + let kind = body["kind"].as_str().unwrap_or(""); + let url = body["url"].as_str().map(str::trim).unwrap_or(""); + if name.is_empty() { + return Ok(self.error(400, "name must not be blank")); + } + if kind == "ics" && url.is_empty() { + return Ok(self.error(400, "ics is a feed URL: set url to the calendar's address")); + } + let mut calendars = self.calendars.lock().unwrap_or_else(PoisonError::into_inner); + calendars.retain(|c| c["name"] != name); + calendars.push(json!({"name": name, "kind": kind})); + Ok(self.answer(201, json!({"added": name}))) + } + ("DELETE", ["v1", "calendars", name]) => { + if name.trim().is_empty() { + return Ok(self.error(400, "name is required")); + } + let name = *name; + let mut calendars = self.calendars.lock().unwrap_or_else(PoisonError::into_inner); + let before = calendars.len(); + calendars.retain(|c| c["name"] != name); + Ok(self.answer(200, json!({"removed": calendars.len() != before}))) + } ("DELETE", ["v1", "watches", forge, rest @ ..]) => { let repo = rest.join("/"); let mut watches = self.watches.lock().unwrap_or_else(PoisonError::into_inner);