From aa9a68ab6dc7539b594fc8eb9e17c1a015b5f556 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 24 Oct 2026 01:58:09 +0200 Subject: [PATCH] feat(contract): /v1/calendars joins the tool contract, one allowlist for both doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar table gets the same contract treatment as every other data domain: a named route constant, three allowlist rows (GET, POST, DELETE /v1/calendars/{name}), mock-router arms that enforce the door's loudest rules — a named calendar, an ics with a URL — and conformance cases (add, the invalid refused, listed, removed) that run against the mock and the real daemon alike, so the two cannot drift. The claw's auth middleware reads the same list, which is how the rows land there with no claw-side code. The spec names the doors and the pattern they establish: domains the daemon reconfigures at runtime live in tables behind their routes, validation runs at the door, and claw.yaml carries only what boot needs. --- crates/rhema-claw/src/http.rs | 21 ++++---------- crates/rhema-claw/tests/http.rs | 2 +- tools/contract/src/conformance.rs | 47 +++++++++++++++++++++++++++++++ tools/contract/src/lib.rs | 7 +++++ tools/contract/src/mock.rs | 34 ++++++++++++++++++++++ 5 files changed, 94 insertions(+), 17 deletions(-) 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);