1use 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
19const BLOB_NS: &str = "_blobs";
23
24pub 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 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
74fn 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
86fn is_probe_path(path: &str) -> bool {
88 path == "/healthz" || path == "/readyz"
89}
90
91async fn healthz() -> Response {
95 (StatusCode::OK, "ok").into_response()
96}
97
98async 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
115fn 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 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 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
179async 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 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
230async 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
252async 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
280async 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
295async 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 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 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#[derive(Deserialize)]
334struct GraphQuery {
335 repo: String,
336 view: String,
337 name: String,
338}
339
340fn 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
416pub 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; 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 let (svc, _d) = fs_service();
837 let content = b"scoped blob".to_vec();
838 let hash = gonzalo_core::ContentHash::of(&content).0;
839
840 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 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 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}