Skip to main content

gonzalo_server/
http.rs

1//! HTTP/JSON transport over the shared `Service`, using axum.
2
3use crate::Service;
4use crate::auth::{Access, Auth, Principal};
5use axum::{
6    Extension, Json, Router,
7    body::Bytes,
8    extract::{DefaultBodyLimit, Path, Query, Request, State},
9    http::{HeaderMap, StatusCode, header},
10    middleware::{Next, from_fn},
11    response::{IntoResponse, Response},
12    routing::get,
13};
14use gonzalo_core::{ContentHash, DeleteResult, Identity, KeyPrefix, PutResult, RecordKey};
15use gonzalo_proto::http::{DeleteBody, DeleteOutcome, PutBody, PutOutcome};
16use serde::Deserialize;
17use std::sync::Arc;
18
19/// Blobs are namespace-agnostic; they authorize against this reserved
20/// namespace (ADR 0015). Admins (`*`) and open mode cover it; a scoped
21/// principal is granted blob access by listing `_blobs` in its read/write set.
22const BLOB_NS: &str = "_blobs";
23
24/// Build the axum router. `auth` governs per-namespace authorization (ADR 0015);
25/// `Auth::Disabled` serves open. The middleware authenticates every non-probe
26/// request (bearer → [`Principal`], or `401`) and hands the principal to the
27/// handlers, which authorize against the target namespace.
28pub fn router(service: Service, auth: Arc<Auth>) -> Router {
29    let max_blob = service.max_blob_size();
30    let blob_routes = Router::new()
31        .route(
32            "/v1/blobs/{hash}",
33            get(get_blob).put(put_blob).delete(delete_blob),
34        )
35        .route("/v1/blobs", get(list_blobs))
36        .layer(DefaultBodyLimit::max(max_blob));
37
38    let app = Router::new()
39        .route("/healthz", get(healthz))
40        .route("/readyz", get(readyz))
41        .route(
42            "/v1/records/{ns}/{col}/{id}",
43            get(get_record).put(put_record).delete(delete_record),
44        )
45        .route("/v1/keys", get(list_keys))
46        .route("/v1/tickets/sync", axum::routing::post(ticket_sync))
47        .route("/v1/graph/definitions", get(graph_definitions))
48        .route("/v1/graph/references", get(graph_references_to))
49        .route("/v1/graph/callers", get(graph_callers_of))
50        .route("/v1/graph/callees", get(graph_callees))
51        .route("/v1/graph/impact", get(graph_impact))
52        .merge(blob_routes)
53        .with_state(Arc::new(service));
54    app.layer(from_fn(move |mut req: Request, next: Next| {
55        let auth = auth.clone();
56        async move {
57            // Health/readiness probes are unauthenticated: k8s liveness and
58            // readiness checks carry no bearer token, and a probe gated behind
59            // auth would fail closed and get the pod killed.
60            if is_probe_path(req.uri().path()) {
61                return next.run(req).await;
62            }
63            match auth.authenticate(bearer(req.headers())) {
64                Some(principal) => {
65                    req.extensions_mut().insert(principal);
66                    next.run(req).await
67                }
68                None => StatusCode::UNAUTHORIZED.into_response(),
69            }
70        }
71    }))
72}
73
74/// `403` when a principal lacks the required access on a namespace.
75fn forbidden(principal: &Principal, access: Access, namespace: &str) -> Response {
76    (
77        StatusCode::FORBIDDEN,
78        format!(
79            "principal {:?} lacks {access:?} on namespace {namespace:?}",
80            principal.name()
81        ),
82    )
83        .into_response()
84}
85
86/// Paths served without authentication (k8s probes).
87fn is_probe_path(path: &str) -> bool {
88    path == "/healthz" || path == "/readyz"
89}
90
91/// Liveness: the process is up and serving. No store access — a `/healthz` that
92/// touched the store would conflate liveness with readiness and kill a pod that
93/// is merely waiting on its backend.
94async fn healthz() -> Response {
95    (StatusCode::OK, "ok").into_response()
96}
97
98/// Readiness: `200` when the backing store is reachable, `503` otherwise, so a
99/// load balancer only routes to replicas that can actually serve.
100async fn readyz(State(svc): State<Arc<Service>>) -> Response {
101    if svc.ready().await {
102        (StatusCode::OK, "ready").into_response()
103    } else {
104        (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response()
105    }
106}
107
108fn bearer(h: &HeaderMap) -> Option<&str> {
109    h.get("authorization")?
110        .to_str()
111        .ok()?
112        .strip_prefix("Bearer ")
113}
114
115/// Map a backend failure to an opaque `500`. The full error is logged
116/// server-side; the client sees only "internal error" so on-disk graph paths
117/// (`view_db_path`), SQLite text, and S3 endpoint/bucket detail never leak to
118/// the network (#148).
119fn server_error<E: std::fmt::Display>(e: E) -> Response {
120    eprintln!("gonzalod: internal error: {e}");
121    (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
122}
123
124async fn get_record(
125    State(svc): State<Arc<Service>>,
126    Extension(principal): Extension<Principal>,
127    Path((ns, col, id)): Path<(String, String, String)>,
128) -> Response {
129    if !principal.allows(Access::Read, &ns) {
130        return forbidden(&principal, Access::Read, &ns);
131    }
132    match svc.get(&RecordKey::new(ns, col, id)).await {
133        Ok(Some(rec)) => (StatusCode::OK, Json(rec)).into_response(),
134        Ok(None) => StatusCode::NOT_FOUND.into_response(),
135        Err(e) => server_error(e),
136    }
137}
138
139async fn put_record(
140    State(svc): State<Arc<Service>>,
141    Extension(principal): Extension<Principal>,
142    Path((ns, col, id)): Path<(String, String, String)>,
143    Json(mut body): Json<PutBody>,
144) -> Response {
145    // The URL path addresses the record; the body must agree with it. Without
146    // this check the path is decorative and authz/write key off the body alone,
147    // so a path-based proxy control could be bypassed by a mismatched body
148    // (#158). Reject the disagreement with 400 before any authz or write.
149    let key = &body.record.key;
150    if ns != key.namespace || col != key.collection || id != key.id {
151        return (
152            StatusCode::BAD_REQUEST,
153            "URL path does not match record key",
154        )
155            .into_response();
156    }
157    let ns = &body.record.key.namespace;
158    if !principal.allows(Access::Write, ns) {
159        return forbidden(&principal, Access::Write, &ns.clone());
160    }
161    // Stamp the author from the authenticated principal — unforgeable (ADR
162    // 0015). Open mode (no auth) leaves the record's author untouched.
163    if principal.is_authenticated() {
164        body.record.meta.author = Identity::new(principal.name());
165    }
166    match svc.put(body.record, body.expected).await {
167        Ok(PutResult::Committed(revision)) => {
168            (StatusCode::OK, Json(PutOutcome::Committed { revision })).into_response()
169        }
170        Ok(PutResult::Conflict(conflict)) => (
171            StatusCode::CONFLICT,
172            Json(PutOutcome::Conflict { conflict }),
173        )
174            .into_response(),
175        Err(e) => server_error(e),
176    }
177}
178
179/// The URL path addresses the record; the OCC precondition rides in an optional
180/// JSON body. Authorize `Write` on the path's namespace, then delegate — the key
181/// is taken from the path, so there is no body-key-vs-path check to make.
182async fn delete_record(
183    State(svc): State<Arc<Service>>,
184    Extension(principal): Extension<Principal>,
185    Path((ns, col, id)): Path<(String, String, String)>,
186    body: Option<Json<DeleteBody>>,
187) -> Response {
188    if !principal.allows(Access::Write, &ns) {
189        return forbidden(&principal, Access::Write, &ns);
190    }
191    let expected = body.map(|Json(b)| b.expected).unwrap_or_default();
192    let key = RecordKey::new(ns, col, id);
193    match svc.delete(&key, expected).await {
194        Ok(DeleteResult::Deleted) => (StatusCode::OK, Json(DeleteOutcome::Deleted)).into_response(),
195        Ok(DeleteResult::Conflict(conflict)) => (
196            StatusCode::CONFLICT,
197            Json(DeleteOutcome::Conflict { conflict }),
198        )
199            .into_response(),
200        Err(e) => server_error(e),
201    }
202}
203
204#[derive(Deserialize)]
205struct ListQuery {
206    namespace: Option<String>,
207    collection: Option<String>,
208}
209
210async fn list_keys(
211    State(svc): State<Arc<Service>>,
212    Extension(principal): Extension<Principal>,
213    Query(q): Query<ListQuery>,
214) -> Response {
215    // No namespace → spans all → requires admin (`read` on `"*"`).
216    let ns = q.namespace.as_deref().unwrap_or("*");
217    if !principal.allows(Access::Read, ns) {
218        return forbidden(&principal, Access::Read, ns);
219    }
220    let prefix = KeyPrefix {
221        namespace: q.namespace,
222        collection: q.collection,
223    };
224    match svc.list(&prefix).await {
225        Ok(keys) => (StatusCode::OK, Json(keys)).into_response(),
226        Err(e) => server_error(e),
227    }
228}
229
230/// `GET /v1/blobs/{hash}` — raw blob bytes, or `404`. Authorized `Read` on the
231/// reserved `_blobs` namespace.
232async fn get_blob(
233    State(svc): State<Arc<Service>>,
234    Extension(principal): Extension<Principal>,
235    Path(hash): Path<String>,
236) -> Response {
237    if !principal.allows(Access::Read, BLOB_NS) {
238        return forbidden(&principal, Access::Read, BLOB_NS);
239    }
240    match svc.get_blob(&ContentHash(hash)).await {
241        Ok(Some(bytes)) => (
242            StatusCode::OK,
243            [(header::CONTENT_TYPE, "application/octet-stream")],
244            bytes,
245        )
246            .into_response(),
247        Ok(None) => StatusCode::NOT_FOUND.into_response(),
248        Err(e) => server_error(e),
249    }
250}
251
252/// `PUT /v1/blobs/{hash}` — store raw body content, write-if-absent. The server
253/// recomputes the content hash and rejects a mismatch with the URL `{hash}`
254/// (`400`) before writing, so the address is authoritative. Authorized `Write`
255/// on `_blobs`. A body over `max_blob_size` is rejected upstream as `413` by the
256/// route's `DefaultBodyLimit`.
257async fn put_blob(
258    State(svc): State<Arc<Service>>,
259    Extension(principal): Extension<Principal>,
260    Path(hash): Path<String>,
261    body: Bytes,
262) -> Response {
263    if !principal.allows(Access::Write, BLOB_NS) {
264        return forbidden(&principal, Access::Write, BLOB_NS);
265    }
266    let computed = ContentHash::of(&body);
267    if computed.0 != hash {
268        return (
269            StatusCode::BAD_REQUEST,
270            "blob content does not match the URL hash",
271        )
272            .into_response();
273    }
274    match svc.put_blob(&body).await {
275        Ok(_) => StatusCode::OK.into_response(),
276        Err(e) => server_error(e),
277    }
278}
279
280/// `DELETE /v1/blobs/{hash}` — idempotent delete. Authorized `Write` on `_blobs`.
281async fn delete_blob(
282    State(svc): State<Arc<Service>>,
283    Extension(principal): Extension<Principal>,
284    Path(hash): Path<String>,
285) -> Response {
286    if !principal.allows(Access::Write, BLOB_NS) {
287        return forbidden(&principal, Access::Write, BLOB_NS);
288    }
289    match svc.delete_blob(&ContentHash(hash)).await {
290        Ok(()) => StatusCode::OK.into_response(),
291        Err(e) => server_error(e),
292    }
293}
294
295/// `GET /v1/blobs` — JSON array of every stored blob hash. Authorized `Read` on
296/// `_blobs`.
297async fn list_blobs(
298    State(svc): State<Arc<Service>>,
299    Extension(principal): Extension<Principal>,
300) -> Response {
301    if !principal.allows(Access::Read, BLOB_NS) {
302        return forbidden(&principal, Access::Read, BLOB_NS);
303    }
304    match svc.list_blobs().await {
305        Ok(hashes) => (StatusCode::OK, Json(hashes)).into_response(),
306        Err(e) => server_error(e),
307    }
308}
309
310async fn ticket_sync(
311    State(svc): State<Arc<Service>>,
312    Extension(principal): Extension<Principal>,
313    Json(conn): Json<gonzalo_ticket_config::Connection>,
314) -> Response {
315    // Ticket sync writes records in the `tickets` namespace.
316    if !principal.allows(Access::Write, "tickets") {
317        return forbidden(&principal, Access::Write, "tickets");
318    }
319    match svc.ticket_sync(&conn, "gonzalod").await {
320        Ok(summary) => (StatusCode::OK, Json(summary)).into_response(),
321        // A misconfigured request is the caller's own input → safe to echo. An
322        // internal failure goes through `server_error` so its detail is logged,
323        // not leaked (#148).
324        Err(crate::service::TicketSyncError::BadRequest(m)) => {
325            (StatusCode::BAD_REQUEST, m).into_response()
326        }
327        Err(crate::service::TicketSyncError::Internal(m)) => server_error(m),
328    }
329}
330
331/// Selects a code-graph view `(repo, view)` and the `name` a query is about,
332/// e.g. `GET /v1/graph/impact?repo=acme/widgets&view=main&name=helper`.
333#[derive(Deserialize)]
334struct GraphQuery {
335    repo: String,
336    view: String,
337    name: String,
338}
339
340/// Graph queries read the view for `repo`, whose records live in the `repo`
341/// namespace — so they require `read` on `repo`.
342fn graph_authz(principal: &Principal, repo: &str) -> Option<Response> {
343    (!principal.allows(Access::Read, repo)).then(|| forbidden(principal, Access::Read, repo))
344}
345
346async fn graph_definitions(
347    State(svc): State<Arc<Service>>,
348    Extension(principal): Extension<Principal>,
349    Query(q): Query<GraphQuery>,
350) -> Response {
351    if let Some(denied) = graph_authz(&principal, &q.repo) {
352        return denied;
353    }
354    match svc.graph_definitions(&q.repo, &q.view, &q.name).await {
355        Ok(items) => (StatusCode::OK, Json(items)).into_response(),
356        Err(e) => server_error(e),
357    }
358}
359
360async fn graph_references_to(
361    State(svc): State<Arc<Service>>,
362    Extension(principal): Extension<Principal>,
363    Query(q): Query<GraphQuery>,
364) -> Response {
365    if let Some(denied) = graph_authz(&principal, &q.repo) {
366        return denied;
367    }
368    match svc.graph_references_to(&q.repo, &q.view, &q.name).await {
369        Ok(items) => (StatusCode::OK, Json(items)).into_response(),
370        Err(e) => server_error(e),
371    }
372}
373
374async fn graph_callers_of(
375    State(svc): State<Arc<Service>>,
376    Extension(principal): Extension<Principal>,
377    Query(q): Query<GraphQuery>,
378) -> Response {
379    if let Some(denied) = graph_authz(&principal, &q.repo) {
380        return denied;
381    }
382    match svc.graph_callers_of(&q.repo, &q.view, &q.name).await {
383        Ok(names) => (StatusCode::OK, Json(names)).into_response(),
384        Err(e) => server_error(e),
385    }
386}
387
388async fn graph_callees(
389    State(svc): State<Arc<Service>>,
390    Extension(principal): Extension<Principal>,
391    Query(q): Query<GraphQuery>,
392) -> Response {
393    if let Some(denied) = graph_authz(&principal, &q.repo) {
394        return denied;
395    }
396    match svc.graph_callees(&q.repo, &q.view, &q.name).await {
397        Ok(names) => (StatusCode::OK, Json(names)).into_response(),
398        Err(e) => server_error(e),
399    }
400}
401
402async fn graph_impact(
403    State(svc): State<Arc<Service>>,
404    Extension(principal): Extension<Principal>,
405    Query(q): Query<GraphQuery>,
406) -> Response {
407    if let Some(denied) = graph_authz(&principal, &q.repo) {
408        return denied;
409    }
410    match svc.graph_impact_names(&q.repo, &q.view, &q.name).await {
411        Ok(names) => (StatusCode::OK, Json(names)).into_response(),
412        Err(e) => server_error(e),
413    }
414}
415
416/// Serve HTTP/JSON on an already-bound listener until the process ends.
417pub async fn serve_http(
418    listener: tokio::net::TcpListener,
419    service: Service,
420    auth: Arc<Auth>,
421) -> std::io::Result<()> {
422    axum::serve(listener, router(service, auth)).await
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use axum::body::Body;
429    use axum::http::Request as HttpRequest;
430    use gonzalo_core::{CoreError, Record, Result as CoreResult, Revision, Store};
431    use gonzalo_store_fs::FsStore;
432    use tempfile::TempDir;
433    use tower::ServiceExt; // oneshot
434
435    /// A Service backed by a fresh filesystem store (reachable → ready).
436    fn fs_service() -> (Service, TempDir) {
437        let dir = TempDir::new().unwrap();
438        let fs = Arc::new(FsStore::new(dir.path()));
439        (Service::new(fs.clone(), fs), dir)
440    }
441
442    fn open() -> Arc<Auth> {
443        Arc::new(Auth::Disabled)
444    }
445
446    /// An `Enabled` registry with one admin token.
447    fn admin_token(token: &str) -> Arc<Auth> {
448        Arc::new(Auth::Enabled(std::collections::HashMap::from([(
449            token.to_string(),
450            Principal::admin("admin"),
451        )])))
452    }
453
454    async fn status_of(service: Service, auth: Arc<Auth>, path: &str) -> StatusCode {
455        router(service, auth)
456            .oneshot(
457                HttpRequest::builder()
458                    .uri(path)
459                    .body(Body::empty())
460                    .unwrap(),
461            )
462            .await
463            .unwrap()
464            .status()
465    }
466
467    #[tokio::test]
468    async fn healthz_is_ok() {
469        let (svc, _dir) = fs_service();
470        assert_eq!(status_of(svc, open(), "/healthz").await, StatusCode::OK);
471    }
472
473    #[tokio::test]
474    async fn readyz_is_ok_when_store_reachable() {
475        let (svc, _dir) = fs_service();
476        assert_eq!(status_of(svc, open(), "/readyz").await, StatusCode::OK);
477    }
478
479    #[tokio::test]
480    async fn probes_bypass_auth_but_other_routes_do_not() {
481        let (svc, _d1) = fs_service();
482        assert_eq!(
483            status_of(svc, admin_token("secret"), "/healthz").await,
484            StatusCode::OK,
485            "healthz must not require a token"
486        );
487        let (svc, _d2) = fs_service();
488        assert_eq!(
489            status_of(svc, admin_token("secret"), "/readyz").await,
490            StatusCode::OK,
491            "readyz must not require a token"
492        );
493        // A normal route without the token is still rejected.
494        let (svc, _d3) = fs_service();
495        assert_eq!(
496            status_of(svc, admin_token("secret"), "/v1/keys").await,
497            StatusCode::UNAUTHORIZED
498        );
499    }
500
501    /// A store whose every operation fails — models an unreachable backend.
502    struct DownStore;
503
504    #[async_trait::async_trait]
505    impl Store for DownStore {
506        async fn get(&self, _key: &RecordKey) -> CoreResult<Option<Record>> {
507            Err(CoreError::Backend("store unreachable".into()))
508        }
509        async fn put(&self, _record: Record, _expected: Option<Revision>) -> CoreResult<PutResult> {
510            Err(CoreError::Backend("store unreachable".into()))
511        }
512        async fn list(&self, _prefix: &KeyPrefix) -> CoreResult<Vec<RecordKey>> {
513            Err(CoreError::Backend("store unreachable".into()))
514        }
515        async fn delete(
516            &self,
517            _key: &RecordKey,
518            _expected: Option<Revision>,
519        ) -> CoreResult<DeleteResult> {
520            Err(CoreError::Backend("store unreachable".into()))
521        }
522    }
523
524    // --- namespace-scoped auth (ADR 0015) ---
525
526    /// `writer` scoped to `memory`, plus an `admin`.
527    fn scoped() -> Arc<Auth> {
528        Arc::new(Auth::Enabled(std::collections::HashMap::from([
529            (
530                "wtok".to_string(),
531                Principal::new("writer", vec!["memory".into()], vec!["memory".into()]),
532            ),
533            ("atok".to_string(), Principal::admin("admin")),
534        ])))
535    }
536
537    async fn call(
538        service: Service,
539        auth: Arc<Auth>,
540        method: &str,
541        path: &str,
542        token: Option<&str>,
543        body: Option<Vec<u8>>,
544    ) -> (StatusCode, Vec<u8>) {
545        let mut b = HttpRequest::builder().method(method).uri(path);
546        if let Some(t) = token {
547            b = b.header("authorization", format!("Bearer {t}"));
548        }
549        if body.is_some() {
550            b = b.header("content-type", "application/json");
551        }
552        let req = b
553            .body(body.map(Body::from).unwrap_or_else(Body::empty))
554            .unwrap();
555        let resp = router(service, auth).oneshot(req).await.unwrap();
556        let status = resp.status();
557        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
558            .await
559            .unwrap()
560            .to_vec();
561        (status, bytes)
562    }
563
564    fn put_body(namespace: &str, author: &str) -> Vec<u8> {
565        let record = Record {
566            revision: Revision::initial(b"{}"),
567            parent: None,
568            body: gonzalo_core::Body::Inline(b"{}".to_vec()),
569            kind: gonzalo_core::RecordKind::MemoryTier,
570            meta: gonzalo_core::Meta {
571                author: gonzalo_core::Identity::new(author),
572                origin_system: "test".into(),
573                created: 0,
574                updated: 0,
575                labels: std::collections::BTreeMap::new(),
576            },
577            links: Vec::new(),
578            key: RecordKey::new(namespace, "col", "x"),
579        };
580        serde_json::to_vec(&PutBody {
581            record,
582            expected: None,
583        })
584        .unwrap()
585    }
586
587    #[tokio::test]
588    async fn read_scope_and_missing_token() {
589        let (svc, _d) = fs_service();
590        // In-scope read of an absent record: authorized → 404 (not 401/403).
591        let (s, _) = call(
592            svc,
593            scoped(),
594            "GET",
595            "/v1/records/memory/col/x",
596            Some("wtok"),
597            None,
598        )
599        .await;
600        assert_eq!(s, StatusCode::NOT_FOUND);
601
602        let (svc, _d) = fs_service();
603        // Out-of-scope read → 403.
604        let (s, _) = call(
605            svc,
606            scoped(),
607            "GET",
608            "/v1/records/secrets/col/x",
609            Some("wtok"),
610            None,
611        )
612        .await;
613        assert_eq!(s, StatusCode::FORBIDDEN);
614
615        let (svc, _d) = fs_service();
616        // No token → 401.
617        let (s, _) = call(svc, scoped(), "GET", "/v1/records/memory/col/x", None, None).await;
618        assert_eq!(s, StatusCode::UNAUTHORIZED);
619    }
620
621    #[tokio::test]
622    async fn write_scope_and_author_stamping() {
623        // Out-of-scope write → 403.
624        let (svc, _d) = fs_service();
625        let (s, _) = call(
626            svc,
627            scoped(),
628            "PUT",
629            "/v1/records/secrets/col/x",
630            Some("wtok"),
631            Some(put_body("secrets", "writer")),
632        )
633        .await;
634        assert_eq!(s, StatusCode::FORBIDDEN);
635
636        // In-scope write commits and stamps the authenticated principal over the
637        // client-claimed "forged" author.
638        let (svc, dir) = fs_service();
639        let auth = scoped();
640        let (s, _) = call(
641            svc.clone(),
642            auth.clone(),
643            "PUT",
644            "/v1/records/memory/col/x",
645            Some("wtok"),
646            Some(put_body("memory", "forged")),
647        )
648        .await;
649        assert_eq!(s, StatusCode::OK);
650        let _ = dir;
651
652        let (s, body) = call(
653            svc,
654            auth,
655            "GET",
656            "/v1/records/memory/col/x",
657            Some("wtok"),
658            None,
659        )
660        .await;
661        assert_eq!(s, StatusCode::OK);
662        let record: Record = serde_json::from_slice(&body).unwrap();
663        assert_eq!(record.meta.author, gonzalo_core::Identity::new("writer"));
664    }
665
666    #[tokio::test]
667    async fn list_without_namespace_requires_admin() {
668        let (svc, _d) = fs_service();
669        let (s, _) = call(svc, scoped(), "GET", "/v1/keys", Some("wtok"), None).await;
670        assert_eq!(s, StatusCode::FORBIDDEN);
671
672        let (svc, _d) = fs_service();
673        let (s, _) = call(svc, scoped(), "GET", "/v1/keys", Some("atok"), None).await;
674        assert_eq!(s, StatusCode::OK);
675    }
676
677    #[tokio::test]
678    async fn put_rejects_url_path_body_key_mismatch() {
679        // Body key is memory/col/x; URL path addresses .../col/y — a disagreement
680        // that must be rejected with 400 before any authz or write (#158).
681        let (svc, _d) = fs_service();
682        let (s, _) = call(
683            svc,
684            scoped(),
685            "PUT",
686            "/v1/records/memory/col/y",
687            Some("wtok"),
688            Some(put_body("memory", "writer")),
689        )
690        .await;
691        assert_eq!(s, StatusCode::BAD_REQUEST);
692
693        // A matching path still commits.
694        let (svc, _d) = fs_service();
695        let (s, _) = call(
696            svc,
697            scoped(),
698            "PUT",
699            "/v1/records/memory/col/x",
700            Some("wtok"),
701            Some(put_body("memory", "writer")),
702        )
703        .await;
704        assert_eq!(s, StatusCode::OK);
705    }
706
707    #[tokio::test]
708    async fn backend_error_is_opaque() {
709        // A forced backend failure yields an opaque 500 body — the leaky
710        // "store unreachable" detail never reaches the client (#148).
711        let dir = TempDir::new().unwrap();
712        let blobs = Arc::new(FsStore::new(dir.path()));
713        let svc = Service::new(Arc::new(DownStore), blobs);
714        let (s, body) = call(
715            svc,
716            scoped(),
717            "GET",
718            "/v1/records/memory/col/x",
719            Some("wtok"),
720            None,
721        )
722        .await;
723        assert_eq!(s, StatusCode::INTERNAL_SERVER_ERROR);
724        let text = String::from_utf8(body).unwrap();
725        assert_eq!(text, "internal error");
726        assert!(!text.contains("unreachable"));
727    }
728
729    #[tokio::test]
730    async fn readyz_is_503_when_store_unreachable() {
731        let dir = TempDir::new().unwrap();
732        // Records via the down store; blobs via fs (readiness only probes records).
733        let blobs = Arc::new(FsStore::new(dir.path()));
734        let svc = Service::new(Arc::new(DownStore), blobs);
735        assert_eq!(
736            status_of(svc, open(), "/readyz").await,
737            StatusCode::SERVICE_UNAVAILABLE
738        );
739    }
740
741    // --- blob routes (#184) ---
742
743    #[tokio::test]
744    async fn blob_put_get_list_delete_roundtrip_open() {
745        let (svc, _d) = fs_service();
746        let auth = open();
747        let content = b"remote blob body".to_vec();
748        let hash = gonzalo_core::ContentHash::of(&content).0;
749
750        // PUT the blob at its hash-addressed URL.
751        let (s, _) = call(
752            svc.clone(),
753            auth.clone(),
754            "PUT",
755            &format!("/v1/blobs/{hash}"),
756            None,
757            Some(content.clone()),
758        )
759        .await;
760        assert_eq!(s, StatusCode::OK);
761
762        // GET returns the raw bytes.
763        let (s, body) = call(
764            svc.clone(),
765            auth.clone(),
766            "GET",
767            &format!("/v1/blobs/{hash}"),
768            None,
769            None,
770        )
771        .await;
772        assert_eq!(s, StatusCode::OK);
773        assert_eq!(body, content);
774
775        // LIST reports the hash.
776        let (s, body) = call(svc.clone(), auth.clone(), "GET", "/v1/blobs", None, None).await;
777        assert_eq!(s, StatusCode::OK);
778        let hashes: Vec<gonzalo_core::ContentHash> = serde_json::from_slice(&body).unwrap();
779        assert_eq!(hashes, vec![gonzalo_core::ContentHash::of(&content)]);
780
781        // DELETE removes it; a follow-up GET is 404.
782        let (s, _) = call(
783            svc.clone(),
784            auth.clone(),
785            "DELETE",
786            &format!("/v1/blobs/{hash}"),
787            None,
788            None,
789        )
790        .await;
791        assert_eq!(s, StatusCode::OK);
792        let (s, _) = call(svc, auth, "GET", &format!("/v1/blobs/{hash}"), None, None).await;
793        assert_eq!(s, StatusCode::NOT_FOUND);
794    }
795
796    #[tokio::test]
797    async fn blob_put_rejects_hash_mismatch_with_400() {
798        let (svc, _d) = fs_service();
799        // Address the PUT with a hash that does NOT match the body.
800        let wrong = gonzalo_core::ContentHash::of(b"a different thing").0;
801        let (s, _) = call(
802            svc,
803            open(),
804            "PUT",
805            &format!("/v1/blobs/{wrong}"),
806            None,
807            Some(b"actual body".to_vec()),
808        )
809        .await;
810        assert_eq!(s, StatusCode::BAD_REQUEST);
811    }
812
813    #[tokio::test]
814    async fn blob_put_over_limit_is_413() {
815        let dir = tempfile::TempDir::new().unwrap();
816        let fs = Arc::new(FsStore::new(dir.path()));
817        // A tiny limit so a small body trips it.
818        let svc = Service::new(fs.clone(), fs).with_max_blob_size(8);
819        let big = vec![b'x'; 64];
820        let hash = gonzalo_core::ContentHash::of(&big).0;
821        let (s, _) = call(
822            svc,
823            open(),
824            "PUT",
825            &format!("/v1/blobs/{hash}"),
826            None,
827            Some(big),
828        )
829        .await;
830        assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE);
831    }
832
833    #[tokio::test]
834    async fn blob_ops_require_blobs_namespace_scope() {
835        // `scoped()` grants read/write on `memory` only — not `_blobs`.
836        let (svc, _d) = fs_service();
837        let content = b"scoped blob".to_vec();
838        let hash = gonzalo_core::ContentHash::of(&content).0;
839
840        // Write without `_blobs` scope → 403.
841        let (s, _) = call(
842            svc.clone(),
843            scoped(),
844            "PUT",
845            &format!("/v1/blobs/{hash}"),
846            Some("wtok"),
847            Some(content.clone()),
848        )
849        .await;
850        assert_eq!(s, StatusCode::FORBIDDEN);
851
852        // Read without `_blobs` scope → 403.
853        let (s, _) = call(
854            svc.clone(),
855            scoped(),
856            "GET",
857            "/v1/blobs",
858            Some("wtok"),
859            None,
860        )
861        .await;
862        assert_eq!(s, StatusCode::FORBIDDEN);
863
864        // Admin (wildcard) may write then read.
865        let (s, _) = call(
866            svc.clone(),
867            scoped(),
868            "PUT",
869            &format!("/v1/blobs/{hash}"),
870            Some("atok"),
871            Some(content),
872        )
873        .await;
874        assert_eq!(s, StatusCode::OK);
875        let (s, _) = call(svc, scoped(), "GET", "/v1/blobs", Some("atok"), None).await;
876        assert_eq!(s, StatusCode::OK);
877    }
878}