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::{
15 ContentHash, CoreError, DeleteResult, Identity, KeyPrefix, PutResult, RecordKey,
16};
17use gonzalo_proto::http::{
18 DeleteBody, DeleteOutcome, PurgeBody, PutBody, PutOutcome, RawRecordBody,
19};
20use serde::Deserialize;
21use std::sync::Arc;
22
23const BLOB_NS: &str = "_blobs";
27
28pub const SERVED_OPERATIONS: &[(&str, &str)] = &[
37 ("GET", "/healthz"),
38 ("GET", "/readyz"),
39 ("GET", "/v1/records/{ns}/{col}/{id}"),
40 ("PUT", "/v1/records/{ns}/{col}/{id}"),
41 ("DELETE", "/v1/records/{ns}/{col}/{id}"),
42 ("GET", "/v1/keys"),
43 ("GET", "/v1/raw/records/{ns}/{col}/{id}"),
44 ("PUT", "/v1/raw/records/{ns}/{col}/{id}"),
45 ("GET", "/v1/raw/keys"),
46 ("POST", "/v1/purge/{ns}/{col}/{id}"),
47 ("POST", "/v1/tickets/sync"),
48 ("GET", "/v1/graph/definitions"),
49 ("GET", "/v1/graph/references"),
50 ("GET", "/v1/graph/callers"),
51 ("GET", "/v1/graph/callees"),
52 ("GET", "/v1/graph/impact"),
53 ("GET", "/v1/blobs"),
54 ("GET", "/v1/blobs/{hash}"),
55 ("HEAD", "/v1/blobs/{hash}"),
56 ("PUT", "/v1/blobs/{hash}"),
57 ("DELETE", "/v1/blobs/{hash}"),
58];
59
60pub fn router(service: Service, auth: Arc<Auth>) -> Router {
65 let max_blob = service.max_blob_size();
66 let blob_routes = Router::new()
67 .route(
68 "/v1/blobs/{hash}",
69 get(get_blob)
70 .head(head_blob)
71 .put(put_blob)
72 .delete(delete_blob),
73 )
74 .route("/v1/blobs", get(list_blobs))
75 .layer(DefaultBodyLimit::max(max_blob));
76
77 let app = Router::new()
78 .route("/healthz", get(healthz))
79 .route("/readyz", get(readyz))
80 .route(
81 "/v1/records/{ns}/{col}/{id}",
82 get(get_record).put(put_record).delete(delete_record),
83 )
84 .route("/v1/keys", get(list_keys))
85 .route(
87 "/v1/raw/records/{ns}/{col}/{id}",
88 get(get_raw_record).put(put_raw_record),
89 )
90 .route("/v1/raw/keys", get(list_raw_keys))
91 .route(
92 "/v1/purge/{ns}/{col}/{id}",
93 axum::routing::post(purge_record),
94 )
95 .route("/v1/tickets/sync", axum::routing::post(ticket_sync))
96 .route("/v1/graph/definitions", get(graph_definitions))
97 .route("/v1/graph/references", get(graph_references_to))
98 .route("/v1/graph/callers", get(graph_callers_of))
99 .route("/v1/graph/callees", get(graph_callees))
100 .route("/v1/graph/impact", get(graph_impact))
101 .merge(blob_routes)
102 .with_state(Arc::new(service));
103 app.layer(from_fn(move |mut req: Request, next: Next| {
104 let auth = auth.clone();
105 async move {
106 if is_probe_path(req.uri().path()) {
110 return next.run(req).await;
111 }
112 match auth.authenticate(bearer(req.headers())) {
113 Some(principal) => {
114 req.extensions_mut().insert(principal);
115 next.run(req).await
116 }
117 None => StatusCode::UNAUTHORIZED.into_response(),
118 }
119 }
120 }))
121}
122
123fn forbidden(principal: &Principal, access: Access, namespace: &str) -> Response {
125 (
126 StatusCode::FORBIDDEN,
127 format!(
128 "principal {:?} lacks {access:?} on namespace {namespace:?}",
129 principal.name()
130 ),
131 )
132 .into_response()
133}
134
135fn forbidden_admin(principal: &Principal, operation: &str) -> Response {
137 (
138 StatusCode::FORBIDDEN,
139 format!(
140 "principal {:?} is not an admin; {operation} requires admin",
141 principal.name()
142 ),
143 )
144 .into_response()
145}
146
147fn path_key_mismatch(path: &(String, String, String), key: &RecordKey) -> Option<Response> {
150 let (ns, col, id) = path;
151 (*ns != key.namespace || *col != key.collection || *id != key.id).then(|| {
152 (
153 StatusCode::BAD_REQUEST,
154 "URL path does not match record key",
155 )
156 .into_response()
157 })
158}
159
160fn put_outcome_response(result: gonzalo_core::Result<PutResult>) -> Response {
165 match result {
166 Ok(PutResult::Committed(revision)) => {
167 (StatusCode::OK, Json(PutOutcome::Committed { revision })).into_response()
168 }
169 Ok(PutResult::Conflict(conflict)) => (
170 StatusCode::CONFLICT,
171 Json(PutOutcome::Conflict { conflict }),
172 )
173 .into_response(),
174 Err(CoreError::NotFound(key)) => (
175 StatusCode::PRECONDITION_FAILED,
176 format!("record not found: {key}"),
177 )
178 .into_response(),
179 Err(CoreError::Invalid(reason)) => (StatusCode::BAD_REQUEST, reason).into_response(),
183 Err(e) => server_error(e),
184 }
185}
186
187fn delete_outcome_response(result: gonzalo_core::Result<DeleteResult>) -> Response {
191 match result {
192 Ok(DeleteResult::Deleted) => (StatusCode::OK, Json(DeleteOutcome::Deleted)).into_response(),
193 Ok(DeleteResult::Conflict(conflict)) => (
194 StatusCode::CONFLICT,
195 Json(DeleteOutcome::Conflict { conflict }),
196 )
197 .into_response(),
198 Err(CoreError::Invalid(reason)) => (StatusCode::BAD_REQUEST, reason).into_response(),
202 Err(e) => server_error(e),
203 }
204}
205
206fn is_probe_path(path: &str) -> bool {
208 path == "/healthz" || path == "/readyz"
209}
210
211async fn healthz() -> Response {
215 (StatusCode::OK, "ok").into_response()
216}
217
218async fn readyz(State(svc): State<Arc<Service>>) -> Response {
221 if svc.ready().await {
222 (StatusCode::OK, "ready").into_response()
223 } else {
224 (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response()
225 }
226}
227
228fn bearer(h: &HeaderMap) -> Option<&str> {
229 h.get("authorization")?
230 .to_str()
231 .ok()?
232 .strip_prefix("Bearer ")
233}
234
235fn server_error<E: std::fmt::Display>(e: E) -> Response {
240 eprintln!("gonzalod: internal error: {e}");
241 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
242}
243
244async fn get_record(
245 State(svc): State<Arc<Service>>,
246 Extension(principal): Extension<Principal>,
247 Path((ns, col, id)): Path<(String, String, String)>,
248) -> Response {
249 if !principal.allows(Access::Read, &ns) {
250 return forbidden(&principal, Access::Read, &ns);
251 }
252 match svc.get(&RecordKey::new(ns, col, id)).await {
253 Ok(Some(rec)) => (StatusCode::OK, Json(rec)).into_response(),
254 Ok(None) => StatusCode::NOT_FOUND.into_response(),
255 Err(e) => server_error(e),
256 }
257}
258
259async fn put_record(
260 State(svc): State<Arc<Service>>,
261 Extension(principal): Extension<Principal>,
262 Path(path): Path<(String, String, String)>,
263 Json(mut body): Json<PutBody>,
264) -> Response {
265 if let Some(bad) = path_key_mismatch(&path, &body.record.key) {
270 return bad;
271 }
272 let ns = &body.record.key.namespace;
273 if !principal.allows(Access::Write, ns) {
274 return forbidden(&principal, Access::Write, &ns.clone());
275 }
276 if principal.is_authenticated() {
279 body.record.meta.author = Identity::new(principal.name());
280 }
281 put_outcome_response(svc.put(body.record, body.expected).await)
282}
283
284async fn delete_record(
296 State(svc): State<Arc<Service>>,
297 Extension(principal): Extension<Principal>,
298 Path((ns, col, id)): Path<(String, String, String)>,
299 body: Option<Json<DeleteBody>>,
300) -> Response {
301 if !principal.allows(Access::Write, &ns) {
302 return forbidden(&principal, Access::Write, &ns);
303 }
304 let (expected, claimed) = body
305 .map(|Json(b)| (b.expected, b.author))
306 .unwrap_or_default();
307 let author = principal.delete_author(claimed);
308 let key = RecordKey::new(ns, col, id);
309 delete_outcome_response(svc.delete_as(&key, expected, author).await)
310}
311
312#[derive(Deserialize)]
313struct ListQuery {
314 namespace: Option<String>,
315 collection: Option<String>,
316}
317
318#[allow(clippy::result_large_err)]
323fn list_prefix(principal: &Principal, q: ListQuery) -> Result<KeyPrefix, Response> {
324 let ns = q.namespace.as_deref().unwrap_or("*");
326 if !principal.allows(Access::Read, ns) {
327 return Err(forbidden(principal, Access::Read, ns));
328 }
329 Ok(KeyPrefix {
330 namespace: q.namespace,
331 collection: q.collection,
332 })
333}
334
335async fn list_keys(
336 State(svc): State<Arc<Service>>,
337 Extension(principal): Extension<Principal>,
338 Query(q): Query<ListQuery>,
339) -> Response {
340 let prefix = match list_prefix(&principal, q) {
341 Ok(p) => p,
342 Err(denied) => return denied,
343 };
344 match svc.list(&prefix).await {
345 Ok(keys) => (StatusCode::OK, Json(keys)).into_response(),
346 Err(e) => server_error(e),
347 }
348}
349
350async fn get_raw_record(
354 State(svc): State<Arc<Service>>,
355 Extension(principal): Extension<Principal>,
356 Path((ns, col, id)): Path<(String, String, String)>,
357) -> Response {
358 if !principal.allows(Access::Read, &ns) {
359 return forbidden(&principal, Access::Read, &ns);
360 }
361 match svc.get_raw(&RecordKey::new(ns, col, id)).await {
362 Ok(record) => (StatusCode::OK, Json(RawRecordBody { record })).into_response(),
363 Err(e) => server_error(e),
364 }
365}
366
367async fn put_raw_record(
390 State(svc): State<Arc<Service>>,
391 Extension(principal): Extension<Principal>,
392 Path(path): Path<(String, String, String)>,
393 Json(body): Json<PutBody>,
394) -> Response {
395 if !principal.is_admin() {
398 return forbidden_admin(&principal, "a raw record write");
399 }
400 if let Some(bad) = path_key_mismatch(&path, &body.record.key) {
401 return bad;
402 }
403 put_outcome_response(svc.put_raw(body.record, body.expected).await)
404}
405
406async fn list_raw_keys(
410 State(svc): State<Arc<Service>>,
411 Extension(principal): Extension<Principal>,
412 Query(q): Query<ListQuery>,
413) -> Response {
414 let prefix = match list_prefix(&principal, q) {
415 Ok(p) => p,
416 Err(denied) => return denied,
417 };
418 match svc.list_raw(&prefix).await {
419 Ok(keys) => (StatusCode::OK, Json(keys)).into_response(),
420 Err(e) => server_error(e),
421 }
422}
423
424async fn purge_record(
429 State(svc): State<Arc<Service>>,
430 Extension(principal): Extension<Principal>,
431 Path((ns, col, id)): Path<(String, String, String)>,
432 body: Bytes,
433) -> Response {
434 if !principal.is_admin() {
435 return forbidden_admin(&principal, "purge");
436 }
437 let PurgeBody { expected } = match serde_json::from_slice(&body) {
438 Ok(b) => b,
439 Err(e) => {
440 return (StatusCode::BAD_REQUEST, format!("invalid purge body: {e}")).into_response();
441 }
442 };
443 let key = RecordKey::new(ns, col, id);
444 delete_outcome_response(svc.purge(&key, expected).await)
445}
446
447async fn get_blob(
450 State(svc): State<Arc<Service>>,
451 Extension(principal): Extension<Principal>,
452 Path(hash): Path<String>,
453) -> Response {
454 if !principal.allows(Access::Read, BLOB_NS) {
455 return forbidden(&principal, Access::Read, BLOB_NS);
456 }
457 match svc.get_blob(&ContentHash(hash)).await {
458 Ok(Some(bytes)) => (
459 StatusCode::OK,
460 [(header::CONTENT_TYPE, "application/octet-stream")],
461 bytes,
462 )
463 .into_response(),
464 Ok(None) => StatusCode::NOT_FOUND.into_response(),
465 Err(e) => server_error(e),
466 }
467}
468
469async fn head_blob(
480 State(svc): State<Arc<Service>>,
481 Extension(principal): Extension<Principal>,
482 Path(hash): Path<String>,
483) -> Response {
484 if !principal.allows(Access::Read, BLOB_NS) {
485 return forbidden(&principal, Access::Read, BLOB_NS);
486 }
487 match svc.has_blob(&ContentHash(hash)).await {
488 Ok(true) => StatusCode::OK.into_response(),
489 Ok(false) => StatusCode::NOT_FOUND.into_response(),
490 Err(e) => server_error(e),
491 }
492}
493
494async fn put_blob(
500 State(svc): State<Arc<Service>>,
501 Extension(principal): Extension<Principal>,
502 Path(hash): Path<String>,
503 body: Bytes,
504) -> Response {
505 if !principal.allows(Access::Write, BLOB_NS) {
506 return forbidden(&principal, Access::Write, BLOB_NS);
507 }
508 let computed = ContentHash::of(&body);
509 if computed.0 != hash {
510 return (
511 StatusCode::BAD_REQUEST,
512 "blob content does not match the URL hash",
513 )
514 .into_response();
515 }
516 match svc.put_blob(&body).await {
517 Ok(_) => StatusCode::OK.into_response(),
518 Err(e) => server_error(e),
519 }
520}
521
522async fn delete_blob(
524 State(svc): State<Arc<Service>>,
525 Extension(principal): Extension<Principal>,
526 Path(hash): Path<String>,
527) -> Response {
528 if !principal.allows(Access::Write, BLOB_NS) {
529 return forbidden(&principal, Access::Write, BLOB_NS);
530 }
531 match svc.delete_blob(&ContentHash(hash)).await {
532 Ok(()) => StatusCode::OK.into_response(),
533 Err(e) => server_error(e),
534 }
535}
536
537async fn list_blobs(
540 State(svc): State<Arc<Service>>,
541 Extension(principal): Extension<Principal>,
542) -> Response {
543 if !principal.allows(Access::Read, BLOB_NS) {
544 return forbidden(&principal, Access::Read, BLOB_NS);
545 }
546 match svc.list_blobs().await {
547 Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
548 Err(e) => server_error(e),
549 }
550}
551
552async fn ticket_sync(
553 State(svc): State<Arc<Service>>,
554 Extension(principal): Extension<Principal>,
555 Json(conn): Json<gonzalo_ticket_config::Connection>,
556) -> Response {
557 if !principal.allows(Access::Write, "tickets") {
559 return forbidden(&principal, Access::Write, "tickets");
560 }
561 match svc.ticket_sync(&conn, "gonzalod").await {
562 Ok(summary) => (StatusCode::OK, Json(summary)).into_response(),
563 Err(crate::service::TicketSyncError::BadRequest(m)) => {
567 (StatusCode::BAD_REQUEST, m).into_response()
568 }
569 Err(crate::service::TicketSyncError::Internal(m)) => server_error(m),
570 }
571}
572
573#[derive(Deserialize)]
576struct GraphQuery {
577 repo: String,
578 view: String,
579 name: String,
580}
581
582fn graph_authz(principal: &Principal, repo: &str) -> Option<Response> {
585 (!principal.allows(Access::Read, repo)).then(|| forbidden(principal, Access::Read, repo))
586}
587
588async fn graph_definitions(
589 State(svc): State<Arc<Service>>,
590 Extension(principal): Extension<Principal>,
591 Query(q): Query<GraphQuery>,
592) -> Response {
593 if let Some(denied) = graph_authz(&principal, &q.repo) {
594 return denied;
595 }
596 match svc.graph_definitions(&q.repo, &q.view, &q.name).await {
597 Ok(items) => (StatusCode::OK, Json(items)).into_response(),
598 Err(e) => server_error(e),
599 }
600}
601
602async fn graph_references_to(
603 State(svc): State<Arc<Service>>,
604 Extension(principal): Extension<Principal>,
605 Query(q): Query<GraphQuery>,
606) -> Response {
607 if let Some(denied) = graph_authz(&principal, &q.repo) {
608 return denied;
609 }
610 match svc.graph_references_to(&q.repo, &q.view, &q.name).await {
611 Ok(items) => (StatusCode::OK, Json(items)).into_response(),
612 Err(e) => server_error(e),
613 }
614}
615
616async fn graph_callers_of(
617 State(svc): State<Arc<Service>>,
618 Extension(principal): Extension<Principal>,
619 Query(q): Query<GraphQuery>,
620) -> Response {
621 if let Some(denied) = graph_authz(&principal, &q.repo) {
622 return denied;
623 }
624 match svc.graph_callers_of(&q.repo, &q.view, &q.name).await {
625 Ok(names) => (StatusCode::OK, Json(names)).into_response(),
626 Err(e) => server_error(e),
627 }
628}
629
630async fn graph_callees(
631 State(svc): State<Arc<Service>>,
632 Extension(principal): Extension<Principal>,
633 Query(q): Query<GraphQuery>,
634) -> Response {
635 if let Some(denied) = graph_authz(&principal, &q.repo) {
636 return denied;
637 }
638 match svc.graph_callees(&q.repo, &q.view, &q.name).await {
639 Ok(names) => (StatusCode::OK, Json(names)).into_response(),
640 Err(e) => server_error(e),
641 }
642}
643
644async fn graph_impact(
645 State(svc): State<Arc<Service>>,
646 Extension(principal): Extension<Principal>,
647 Query(q): Query<GraphQuery>,
648) -> Response {
649 if let Some(denied) = graph_authz(&principal, &q.repo) {
650 return denied;
651 }
652 match svc.graph_impact_names(&q.repo, &q.view, &q.name).await {
653 Ok(names) => (StatusCode::OK, Json(names)).into_response(),
654 Err(e) => server_error(e),
655 }
656}
657
658pub async fn serve_http(
660 listener: tokio::net::TcpListener,
661 service: Service,
662 auth: Arc<Auth>,
663) -> std::io::Result<()> {
664 axum::serve(listener, router(service, auth)).await
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use axum::body::Body;
671 use axum::http::Request as HttpRequest;
672 use gonzalo_core::{
673 BlobStore, ContentHash, CoreError, Record, Result as CoreResult, Revision, Store,
674 };
675 use gonzalo_store_fs::FsStore;
676 use std::collections::BTreeSet;
677 use tempfile::TempDir;
678 use tower::ServiceExt; #[test]
690 fn served_operations_is_exactly_what_the_router_declares() {
691 let declared: BTreeSet<(String, String)> = routes_in_router_source().into_iter().collect();
692 let listed: BTreeSet<(String, String)> = SERVED_OPERATIONS
693 .iter()
694 .map(|(m, p)| (m.to_string(), p.to_string()))
695 .collect();
696
697 let unrouted: Vec<_> = listed.difference(&declared).collect();
698 let unlisted: Vec<_> = declared.difference(&listed).collect();
699 assert!(
700 unrouted.is_empty(),
701 "in SERVED_OPERATIONS but not routed: {unrouted:?}"
702 );
703 assert!(
704 unlisted.is_empty(),
705 "routed but missing from SERVED_OPERATIONS: {unlisted:?}"
706 );
707 }
708
709 #[test]
715 fn openapi_document_matches_the_served_surface() {
716 const NOT_OPERATIONS: &[&str] =
718 &["parameters", "summary", "description", "servers", "$ref"];
719
720 let spec: serde_json::Value =
721 serde_json::from_str(include_str!("../../../docs/api/openapi.json"))
722 .expect("docs/api/openapi.json is valid JSON");
723
724 let mut documented = BTreeSet::new();
725 for (path, item) in spec["paths"].as_object().expect("paths object") {
726 for key in item.as_object().expect("path item object").keys() {
727 if NOT_OPERATIONS.contains(&key.as_str()) {
728 continue;
729 }
730 documented.insert((key.to_uppercase(), path.clone()));
731 }
732 }
733 let served: BTreeSet<(String, String)> = SERVED_OPERATIONS
734 .iter()
735 .map(|(m, p)| (m.to_string(), p.to_string()))
736 .collect();
737
738 let undocumented: Vec<_> = served.difference(&documented).collect();
739 let unserved: Vec<_> = documented.difference(&served).collect();
740 assert!(
741 undocumented.is_empty(),
742 "served but absent from docs/api/openapi.json: {undocumented:?}"
743 );
744 assert!(
745 unserved.is_empty(),
746 "documented in docs/api/openapi.json but not served: {unserved:?}"
747 );
748 }
749
750 fn routes_in_router_source() -> Vec<(String, String)> {
761 let file = include_str!("http.rs");
762 let body_start = file
763 .find("pub fn router(")
764 .expect("router() is defined in this file");
765 let body_end = file[body_start..]
767 .find("#[cfg(test)]")
768 .map(|i| body_start + i)
769 .unwrap_or(file.len());
770 let body: String = file[body_start..body_end]
771 .lines()
772 .filter(|l| !l.trim_start().starts_with("//"))
773 .collect::<Vec<_>>()
774 .join("\n");
775
776 let mut out = Vec::new();
777 for (idx, _) in body.match_indices(".route(") {
778 let rest = &body[idx + ".route(".len()..];
779 let Some(open) = rest.find('"') else { continue };
780 let Some(close) = rest[open + 1..].find('"') else {
781 continue;
782 };
783 let path = &rest[open + 1..open + 1 + close];
784 let mut depth = 1usize;
786 let mut end = rest.len();
787 for (i, c) in rest.char_indices() {
788 match c {
789 '(' => depth += 1,
790 ')' => {
791 depth -= 1;
792 if depth == 0 {
793 end = i;
794 break;
795 }
796 }
797 _ => {}
798 }
799 }
800 let chain = &rest[..end];
801 for verb in ["get", "head", "put", "delete", "post", "patch"] {
802 if chain.contains(&format!("{verb}(")) {
803 out.push((verb.to_uppercase(), path.to_string()));
804 }
805 }
806 }
807 out
808 }
809
810 #[test]
812 fn the_route_parser_reads_paths_and_every_chained_method() {
813 let found = routes_in_router_source();
814 assert!(
815 found.contains(&("GET".into(), "/healthz".into())),
816 "a single-method route"
817 );
818 for verb in ["GET", "PUT", "DELETE"] {
820 assert!(
821 found.contains(&(verb.into(), "/v1/records/{ns}/{col}/{id}".into())),
822 "{verb} on the records route"
823 );
824 }
825 assert!(
826 found.contains(&("POST".into(), "/v1/tickets/sync".into())),
827 "a fully-qualified axum::routing::post route"
828 );
829 assert!(
833 found.contains(&("HEAD".into(), "/v1/blobs/{hash}".into())),
834 "HEAD on the hash-addressed blob route"
835 );
836 assert!(
837 !found.iter().any(|(_, p)| p.is_empty() || p.contains('…')),
838 "the parser picked up prose rather than a route: {found:?}"
839 );
840 }
841
842 fn fs_service() -> (Service, TempDir) {
844 let dir = TempDir::new().unwrap();
845 let fs = Arc::new(FsStore::new(dir.path()));
846 (Service::new(fs.clone(), fs), dir)
847 }
848
849 fn open() -> Arc<Auth> {
850 Arc::new(Auth::Disabled)
851 }
852
853 fn admin_token(token: &str) -> Arc<Auth> {
855 Arc::new(Auth::Enabled(std::collections::HashMap::from([(
856 token.to_string(),
857 Principal::admin("admin"),
858 )])))
859 }
860
861 async fn status_of(service: Service, auth: Arc<Auth>, path: &str) -> StatusCode {
862 router(service, auth)
863 .oneshot(
864 HttpRequest::builder()
865 .uri(path)
866 .body(Body::empty())
867 .unwrap(),
868 )
869 .await
870 .unwrap()
871 .status()
872 }
873
874 #[tokio::test]
875 async fn healthz_is_ok() {
876 let (svc, _dir) = fs_service();
877 assert_eq!(status_of(svc, open(), "/healthz").await, StatusCode::OK);
878 }
879
880 #[tokio::test]
881 async fn readyz_is_ok_when_store_reachable() {
882 let (svc, _dir) = fs_service();
883 assert_eq!(status_of(svc, open(), "/readyz").await, StatusCode::OK);
884 }
885
886 #[tokio::test]
887 async fn probes_bypass_auth_but_other_routes_do_not() {
888 let (svc, _d1) = fs_service();
889 assert_eq!(
890 status_of(svc, admin_token("secret"), "/healthz").await,
891 StatusCode::OK,
892 "healthz must not require a token"
893 );
894 let (svc, _d2) = fs_service();
895 assert_eq!(
896 status_of(svc, admin_token("secret"), "/readyz").await,
897 StatusCode::OK,
898 "readyz must not require a token"
899 );
900 let (svc, _d3) = fs_service();
902 assert_eq!(
903 status_of(svc, admin_token("secret"), "/v1/keys").await,
904 StatusCode::UNAUTHORIZED
905 );
906 }
907
908 struct DownStore;
910
911 fn down() -> CoreError {
912 CoreError::Backend("store unreachable".into())
913 }
914
915 #[async_trait::async_trait]
916 impl Store for DownStore {
917 async fn get(&self, _key: &RecordKey) -> CoreResult<Option<Record>> {
918 Err(down())
919 }
920 async fn put(&self, _record: Record, _expected: Option<Revision>) -> CoreResult<PutResult> {
921 Err(down())
922 }
923 async fn list(&self, _prefix: &KeyPrefix) -> CoreResult<Vec<RecordKey>> {
924 Err(down())
925 }
926 async fn delete_as(
927 &self,
928 _key: &RecordKey,
929 _expected: Option<Revision>,
930 _author: Option<Identity>,
931 ) -> CoreResult<DeleteResult> {
932 Err(down())
933 }
934 async fn get_raw(&self, _key: &RecordKey) -> CoreResult<Option<Record>> {
935 Err(down())
936 }
937 async fn list_raw(&self, _prefix: &KeyPrefix) -> CoreResult<Vec<RecordKey>> {
938 Err(down())
939 }
940 async fn put_raw(
941 &self,
942 _record: Record,
943 _expected: Option<Revision>,
944 ) -> CoreResult<PutResult> {
945 Err(down())
946 }
947 async fn purge(&self, _key: &RecordKey, _expected: Revision) -> CoreResult<DeleteResult> {
948 Err(down())
949 }
950 }
951
952 fn scoped() -> Arc<Auth> {
956 Arc::new(Auth::Enabled(std::collections::HashMap::from([
957 (
958 "wtok".to_string(),
959 Principal::new("writer", vec!["memory".into()], vec!["memory".into()]),
960 ),
961 ("atok".to_string(), Principal::admin("admin")),
962 ])))
963 }
964
965 async fn call(
966 service: Service,
967 auth: Arc<Auth>,
968 method: &str,
969 path: &str,
970 token: Option<&str>,
971 body: Option<Vec<u8>>,
972 ) -> (StatusCode, Vec<u8>) {
973 let mut b = HttpRequest::builder().method(method).uri(path);
974 if let Some(t) = token {
975 b = b.header("authorization", format!("Bearer {t}"));
976 }
977 if body.is_some() {
978 b = b.header("content-type", "application/json");
979 }
980 let req = b
981 .body(body.map(Body::from).unwrap_or_else(Body::empty))
982 .unwrap();
983 let resp = router(service, auth).oneshot(req).await.unwrap();
984 let status = resp.status();
985 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
986 .await
987 .unwrap()
988 .to_vec();
989 (status, bytes)
990 }
991
992 fn put_body(namespace: &str, author: &str) -> Vec<u8> {
993 let record = Record {
994 revision: Revision::initial(b"{}"),
995 parent: None,
996 body: gonzalo_core::Body::Inline(b"{}".to_vec()),
997 kind: gonzalo_core::RecordKind::MemoryTier,
998 meta: gonzalo_core::Meta {
999 author: gonzalo_core::Identity::new(author),
1000 origin_system: "test".into(),
1001 created: 0,
1002 updated: 0,
1003 labels: std::collections::BTreeMap::new(),
1004 },
1005 links: Vec::new(),
1006 key: RecordKey::new(namespace, "col", "x"),
1007 ancestors: Vec::new(),
1008 deleted_at: None,
1009 deleted_blob: None,
1010 deleted_kind: None,
1011 };
1012 serde_json::to_vec(&PutBody {
1013 record,
1014 expected: None,
1015 })
1016 .unwrap()
1017 }
1018
1019 #[tokio::test]
1020 async fn read_scope_and_missing_token() {
1021 let (svc, _d) = fs_service();
1022 let (s, _) = call(
1024 svc,
1025 scoped(),
1026 "GET",
1027 "/v1/records/memory/col/x",
1028 Some("wtok"),
1029 None,
1030 )
1031 .await;
1032 assert_eq!(s, StatusCode::NOT_FOUND);
1033
1034 let (svc, _d) = fs_service();
1035 let (s, _) = call(
1037 svc,
1038 scoped(),
1039 "GET",
1040 "/v1/records/secrets/col/x",
1041 Some("wtok"),
1042 None,
1043 )
1044 .await;
1045 assert_eq!(s, StatusCode::FORBIDDEN);
1046
1047 let (svc, _d) = fs_service();
1048 let (s, _) = call(svc, scoped(), "GET", "/v1/records/memory/col/x", None, None).await;
1050 assert_eq!(s, StatusCode::UNAUTHORIZED);
1051 }
1052
1053 #[tokio::test]
1054 async fn write_scope_and_author_stamping() {
1055 let (svc, _d) = fs_service();
1057 let (s, _) = call(
1058 svc,
1059 scoped(),
1060 "PUT",
1061 "/v1/records/secrets/col/x",
1062 Some("wtok"),
1063 Some(put_body("secrets", "writer")),
1064 )
1065 .await;
1066 assert_eq!(s, StatusCode::FORBIDDEN);
1067
1068 let (svc, dir) = fs_service();
1071 let auth = scoped();
1072 let (s, _) = call(
1073 svc.clone(),
1074 auth.clone(),
1075 "PUT",
1076 "/v1/records/memory/col/x",
1077 Some("wtok"),
1078 Some(put_body("memory", "forged")),
1079 )
1080 .await;
1081 assert_eq!(s, StatusCode::OK);
1082 let _ = dir;
1083
1084 let (s, body) = call(
1085 svc,
1086 auth,
1087 "GET",
1088 "/v1/records/memory/col/x",
1089 Some("wtok"),
1090 None,
1091 )
1092 .await;
1093 assert_eq!(s, StatusCode::OK);
1094 let record: Record = serde_json::from_slice(&body).unwrap();
1095 assert_eq!(record.meta.author, gonzalo_core::Identity::new("writer"));
1096 }
1097
1098 #[tokio::test]
1099 async fn list_without_namespace_requires_admin() {
1100 let (svc, _d) = fs_service();
1101 let (s, _) = call(svc, scoped(), "GET", "/v1/keys", Some("wtok"), None).await;
1102 assert_eq!(s, StatusCode::FORBIDDEN);
1103
1104 let (svc, _d) = fs_service();
1105 let (s, _) = call(svc, scoped(), "GET", "/v1/keys", Some("atok"), None).await;
1106 assert_eq!(s, StatusCode::OK);
1107 }
1108
1109 #[tokio::test]
1110 async fn put_rejects_url_path_body_key_mismatch() {
1111 let (svc, _d) = fs_service();
1114 let (s, _) = call(
1115 svc,
1116 scoped(),
1117 "PUT",
1118 "/v1/records/memory/col/y",
1119 Some("wtok"),
1120 Some(put_body("memory", "writer")),
1121 )
1122 .await;
1123 assert_eq!(s, StatusCode::BAD_REQUEST);
1124
1125 let (svc, _d) = fs_service();
1127 let (s, _) = call(
1128 svc,
1129 scoped(),
1130 "PUT",
1131 "/v1/records/memory/col/x",
1132 Some("wtok"),
1133 Some(put_body("memory", "writer")),
1134 )
1135 .await;
1136 assert_eq!(s, StatusCode::OK);
1137 }
1138
1139 #[tokio::test]
1140 async fn consumer_put_of_a_tombstone_is_a_bad_request() {
1141 let (svc, _d) = fs_service();
1145 let mut record = record_at("writer", Revision::initial(b"{}"));
1146 record.kind = gonzalo_core::RecordKind::Tombstone;
1147 record.deleted_at = Some(1);
1148 let (s, body) = call(
1149 svc,
1150 scoped(),
1151 "PUT",
1152 "/v1/records/memory/col/x",
1153 Some("wtok"),
1154 Some(put_body_with(record, None)),
1155 )
1156 .await;
1157 assert_eq!(s, StatusCode::BAD_REQUEST);
1158 let body = String::from_utf8(body).unwrap();
1159 assert!(
1160 body.contains("delete_as"),
1161 "should name the API to use: {body}"
1162 );
1163 }
1164
1165 #[tokio::test]
1166 async fn backend_error_is_opaque() {
1167 let dir = TempDir::new().unwrap();
1170 let blobs = Arc::new(FsStore::new(dir.path()));
1171 let svc = Service::new(Arc::new(DownStore), blobs);
1172 let (s, body) = call(
1173 svc,
1174 scoped(),
1175 "GET",
1176 "/v1/records/memory/col/x",
1177 Some("wtok"),
1178 None,
1179 )
1180 .await;
1181 assert_eq!(s, StatusCode::INTERNAL_SERVER_ERROR);
1182 let text = String::from_utf8(body).unwrap();
1183 assert_eq!(text, "internal error");
1184 assert!(!text.contains("unreachable"));
1185 }
1186
1187 #[tokio::test]
1188 async fn readyz_is_503_when_store_unreachable() {
1189 let dir = TempDir::new().unwrap();
1190 let blobs = Arc::new(FsStore::new(dir.path()));
1192 let svc = Service::new(Arc::new(DownStore), blobs);
1193 assert_eq!(
1194 status_of(svc, open(), "/readyz").await,
1195 StatusCode::SERVICE_UNAVAILABLE
1196 );
1197 }
1198
1199 #[tokio::test]
1202 async fn blob_put_get_list_delete_roundtrip_open() {
1203 let (svc, _d) = fs_service();
1204 let auth = open();
1205 let content = b"remote blob body".to_vec();
1206 let hash = gonzalo_core::ContentHash::of(&content).0;
1207
1208 let (s, _) = call(
1210 svc.clone(),
1211 auth.clone(),
1212 "PUT",
1213 &format!("/v1/blobs/{hash}"),
1214 None,
1215 Some(content.clone()),
1216 )
1217 .await;
1218 assert_eq!(s, StatusCode::OK);
1219
1220 let (s, body) = call(
1222 svc.clone(),
1223 auth.clone(),
1224 "GET",
1225 &format!("/v1/blobs/{hash}"),
1226 None,
1227 None,
1228 )
1229 .await;
1230 assert_eq!(s, StatusCode::OK);
1231 assert_eq!(body, content);
1232
1233 let (s, body) = call(svc.clone(), auth.clone(), "GET", "/v1/blobs", None, None).await;
1235 assert_eq!(s, StatusCode::OK);
1236 let entries: serde_json::Value = serde_json::from_slice(&body).unwrap();
1237 let first = &entries.as_array().expect("an array of blob entries")[0];
1238 assert_eq!(
1239 first.get("hash").and_then(|h| h.as_str()),
1240 Some(gonzalo_core::ContentHash::of(&content).0.as_str())
1241 );
1242 let ms = first
1243 .get("modified_unix_ms")
1244 .and_then(|m| m.as_i64())
1245 .expect("modified_unix_ms is present and an integer");
1246 assert_ne!(
1247 ms, 0,
1248 "a zero timestamp means the substrate never reported one"
1249 );
1250
1251 let (s, _) = call(
1253 svc.clone(),
1254 auth.clone(),
1255 "DELETE",
1256 &format!("/v1/blobs/{hash}"),
1257 None,
1258 None,
1259 )
1260 .await;
1261 assert_eq!(s, StatusCode::OK);
1262 let (s, _) = call(svc, auth, "GET", &format!("/v1/blobs/{hash}"), None, None).await;
1263 assert_eq!(s, StatusCode::NOT_FOUND);
1264 }
1265
1266 #[tokio::test]
1267 async fn blob_put_rejects_hash_mismatch_with_400() {
1268 let (svc, _d) = fs_service();
1269 let wrong = gonzalo_core::ContentHash::of(b"a different thing").0;
1271 let (s, _) = call(
1272 svc,
1273 open(),
1274 "PUT",
1275 &format!("/v1/blobs/{wrong}"),
1276 None,
1277 Some(b"actual body".to_vec()),
1278 )
1279 .await;
1280 assert_eq!(s, StatusCode::BAD_REQUEST);
1281 }
1282
1283 #[tokio::test]
1284 async fn blob_put_over_limit_is_413() {
1285 let dir = tempfile::TempDir::new().unwrap();
1286 let fs = Arc::new(FsStore::new(dir.path()));
1287 let svc = Service::new(fs.clone(), fs).with_max_blob_size(8);
1289 let big = vec![b'x'; 64];
1290 let hash = gonzalo_core::ContentHash::of(&big).0;
1291 let (s, _) = call(
1292 svc,
1293 open(),
1294 "PUT",
1295 &format!("/v1/blobs/{hash}"),
1296 None,
1297 Some(big),
1298 )
1299 .await;
1300 assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE);
1301 }
1302
1303 #[tokio::test]
1304 async fn blob_ops_require_blobs_namespace_scope() {
1305 let (svc, _d) = fs_service();
1307 let content = b"scoped blob".to_vec();
1308 let hash = gonzalo_core::ContentHash::of(&content).0;
1309
1310 let (s, _) = call(
1312 svc.clone(),
1313 scoped(),
1314 "PUT",
1315 &format!("/v1/blobs/{hash}"),
1316 Some("wtok"),
1317 Some(content.clone()),
1318 )
1319 .await;
1320 assert_eq!(s, StatusCode::FORBIDDEN);
1321
1322 let (s, _) = call(
1324 svc.clone(),
1325 scoped(),
1326 "GET",
1327 "/v1/blobs",
1328 Some("wtok"),
1329 None,
1330 )
1331 .await;
1332 assert_eq!(s, StatusCode::FORBIDDEN);
1333
1334 let (s, _) = call(
1336 svc.clone(),
1337 scoped(),
1338 "PUT",
1339 &format!("/v1/blobs/{hash}"),
1340 Some("atok"),
1341 Some(content),
1342 )
1343 .await;
1344 assert_eq!(s, StatusCode::OK);
1345 let (s, _) = call(svc, scoped(), "GET", "/v1/blobs", Some("atok"), None).await;
1346 assert_eq!(s, StatusCode::OK);
1347 }
1348
1349 struct PresenceOnlyBlobs(FsStore);
1357
1358 #[async_trait::async_trait]
1359 impl BlobStore for PresenceOnlyBlobs {
1360 async fn put_blob(&self, content: &[u8]) -> CoreResult<ContentHash> {
1361 self.0.put_blob(content).await
1362 }
1363 async fn get_blob(&self, _hash: &ContentHash) -> CoreResult<Option<Vec<u8>>> {
1364 Err(CoreError::Backend("get_blob must not be called".into()))
1365 }
1366 async fn list_blobs(&self) -> CoreResult<Vec<gonzalo_core::BlobEntry>> {
1367 self.0.list_blobs().await
1368 }
1369 async fn has_blob(&self, hash: &ContentHash) -> CoreResult<bool> {
1370 self.0.has_blob(hash).await
1371 }
1372 async fn delete_blob(&self, hash: &ContentHash) -> CoreResult<()> {
1373 self.0.delete_blob(hash).await
1374 }
1375 }
1376
1377 #[tokio::test]
1378 async fn head_blob_answers_presence_without_reading_the_blob() {
1379 let dir = TempDir::new().unwrap();
1380 let blobs = Arc::new(PresenceOnlyBlobs(FsStore::new(dir.path())));
1381 let hash = blobs
1382 .put_blob(b"bytes that must stay on disk")
1383 .await
1384 .unwrap()
1385 .0;
1386 let svc = Service::new(Arc::new(FsStore::new(dir.path())), blobs);
1387
1388 let (s, body) = call(
1389 svc.clone(),
1390 open(),
1391 "HEAD",
1392 &format!("/v1/blobs/{hash}"),
1393 None,
1394 None,
1395 )
1396 .await;
1397 assert_eq!(s, StatusCode::OK, "a stored blob is present");
1398 assert!(body.is_empty(), "HEAD never carries a body");
1399
1400 let absent = ContentHash::of(b"never stored").0;
1401 let (s, _) = call(
1402 svc,
1403 open(),
1404 "HEAD",
1405 &format!("/v1/blobs/{absent}"),
1406 None,
1407 None,
1408 )
1409 .await;
1410 assert_eq!(s, StatusCode::NOT_FOUND, "an unstored blob is absent");
1411 }
1412
1413 #[tokio::test]
1421 async fn head_blob_does_not_report_the_blob_size() {
1422 let (svc, _d) = fs_service();
1423 let content = b"sixteen bytes!!!".to_vec();
1424 let hash = ContentHash::of(&content).0;
1425 let (s, _) = call(
1426 svc.clone(),
1427 open(),
1428 "PUT",
1429 &format!("/v1/blobs/{hash}"),
1430 None,
1431 Some(content),
1432 )
1433 .await;
1434 assert_eq!(s, StatusCode::OK);
1435
1436 let req = HttpRequest::builder()
1437 .method("HEAD")
1438 .uri(format!("/v1/blobs/{hash}"))
1439 .body(Body::empty())
1440 .unwrap();
1441 let resp = router(svc, open()).oneshot(req).await.unwrap();
1442 assert_eq!(resp.status(), StatusCode::OK);
1443 assert_eq!(
1444 resp.headers()
1445 .get(header::CONTENT_LENGTH)
1446 .map(|v| v.to_str().unwrap().to_string()),
1447 Some("0".to_string()),
1448 );
1449 }
1450
1451 #[tokio::test]
1454 async fn head_blob_requires_blobs_read_scope() {
1455 let (svc, _d) = fs_service();
1456 let content = b"scoped presence".to_vec();
1457 let hash = ContentHash::of(&content).0;
1458 let (s, _) = call(
1459 svc.clone(),
1460 scoped(),
1461 "PUT",
1462 &format!("/v1/blobs/{hash}"),
1463 Some("atok"),
1464 Some(content),
1465 )
1466 .await;
1467 assert_eq!(s, StatusCode::OK);
1468
1469 let (s, _) = call(
1471 svc.clone(),
1472 scoped(),
1473 "HEAD",
1474 &format!("/v1/blobs/{hash}"),
1475 Some("wtok"),
1476 None,
1477 )
1478 .await;
1479 assert_eq!(s, StatusCode::FORBIDDEN);
1480
1481 let (s, _) = call(
1482 svc,
1483 scoped(),
1484 "HEAD",
1485 &format!("/v1/blobs/{hash}"),
1486 Some("atok"),
1487 None,
1488 )
1489 .await;
1490 assert_eq!(s, StatusCode::OK);
1491 }
1492
1493 fn tomb_auth() -> Arc<Auth> {
1498 Arc::new(Auth::Enabled(std::collections::HashMap::from([
1499 (
1500 "rtok".to_string(),
1501 Principal::new("reader", vec!["memory".into()], vec![]),
1502 ),
1503 (
1504 "wtok".to_string(),
1505 Principal::new("writer", vec!["memory".into()], vec!["memory".into()]),
1506 ),
1507 ("atok".to_string(), Principal::admin("admin")),
1508 ])))
1509 }
1510
1511 const LIVE: &str = "/v1/records/memory/col/x";
1512 const RAW: &str = "/v1/raw/records/memory/col/x";
1513 const PURGE: &str = "/v1/purge/memory/col/x";
1514
1515 fn record_at(author: &str, revision: Revision) -> Record {
1517 Record {
1518 revision,
1519 parent: None,
1520 body: gonzalo_core::Body::Inline(b"{}".to_vec()),
1521 kind: gonzalo_core::RecordKind::MemoryTier,
1522 meta: gonzalo_core::Meta {
1523 author: gonzalo_core::Identity::new(author),
1524 origin_system: "test".into(),
1525 created: 0,
1526 updated: 0,
1527 labels: std::collections::BTreeMap::new(),
1528 },
1529 links: Vec::new(),
1530 ancestors: Vec::new(),
1531 deleted_at: None,
1532 deleted_blob: None,
1533 key: RecordKey::new("memory", "col", "x"),
1534 deleted_kind: None,
1535 }
1536 }
1537
1538 fn put_body_with(record: Record, expected: Option<Revision>) -> Vec<u8> {
1539 serde_json::to_vec(&PutBody { record, expected }).unwrap()
1540 }
1541
1542 fn delete_body() -> Vec<u8> {
1543 serde_json::to_vec(&DeleteBody::default()).unwrap()
1544 }
1545
1546 fn delete_body_claiming(author: &str) -> Vec<u8> {
1548 serde_json::to_vec(&DeleteBody {
1549 expected: None,
1550 author: Some(gonzalo_core::Identity::new(author)),
1551 })
1552 .unwrap()
1553 }
1554
1555 fn purge_body(expected: &Revision) -> Vec<u8> {
1556 serde_json::to_vec(&PurgeBody {
1557 expected: expected.clone(),
1558 })
1559 .unwrap()
1560 }
1561
1562 async fn seed_tombstone(
1565 svc: &Service,
1566 auth: &Arc<Auth>,
1567 put_token: Option<&str>,
1568 delete_token: Option<&str>,
1569 ) {
1570 let (s, _) = call(
1571 svc.clone(),
1572 auth.clone(),
1573 "PUT",
1574 LIVE,
1575 put_token,
1576 Some(put_body("memory", "client")),
1577 )
1578 .await;
1579 assert_eq!(s, StatusCode::OK);
1580 let (s, _) = call(
1581 svc.clone(),
1582 auth.clone(),
1583 "DELETE",
1584 LIVE,
1585 delete_token,
1586 Some(delete_body()),
1587 )
1588 .await;
1589 assert_eq!(s, StatusCode::OK);
1590 }
1591
1592 async fn raw_get(
1593 svc: &Service,
1594 auth: &Arc<Auth>,
1595 path: &str,
1596 token: Option<&str>,
1597 ) -> (StatusCode, Option<Record>) {
1598 let (s, body) = call(svc.clone(), auth.clone(), "GET", path, token, None).await;
1599 let record = if s == StatusCode::OK {
1600 serde_json::from_slice::<RawRecordBody>(&body)
1601 .unwrap()
1602 .record
1603 } else {
1604 None
1605 };
1606 (s, record)
1607 }
1608
1609 async fn keys(svc: &Service, auth: &Arc<Auth>, path: &str, token: &str) -> Vec<RecordKey> {
1610 let (s, body) = call(svc.clone(), auth.clone(), "GET", path, Some(token), None).await;
1611 assert_eq!(s, StatusCode::OK, "{path}");
1612 serde_json::from_slice(&body).unwrap()
1613 }
1614
1615 #[tokio::test]
1616 async fn delete_over_the_wire_leaves_a_tombstone_for_raw_reads() {
1617 let (svc, _d) = fs_service();
1618 let auth = tomb_auth();
1619 seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
1620 let key = RecordKey::new("memory", "col", "x");
1621
1622 let (s, _) = call(svc.clone(), auth.clone(), "GET", LIVE, Some("rtok"), None).await;
1624 assert_eq!(s, StatusCode::NOT_FOUND);
1625 assert!(
1626 !keys(&svc, &auth, "/v1/keys?namespace=memory", "rtok")
1627 .await
1628 .contains(&key)
1629 );
1630
1631 let (s, tomb) = raw_get(&svc, &auth, RAW, Some("rtok")).await;
1633 assert_eq!(s, StatusCode::OK);
1634 let tomb = tomb.expect("tombstone");
1635 assert_eq!(tomb.kind, gonzalo_core::RecordKind::Tombstone);
1636 assert_eq!(tomb.revision.hash, gonzalo_core::tombstone_hash());
1637 assert!(tomb.deleted_at.is_some());
1638 assert!(
1639 keys(&svc, &auth, "/v1/raw/keys?namespace=memory", "rtok")
1640 .await
1641 .contains(&key)
1642 );
1643 }
1644
1645 #[tokio::test]
1646 async fn delete_stamps_the_deleter_on_the_tombstone() {
1647 let (svc, _d) = fs_service();
1648 let auth = tomb_auth();
1649 seed_tombstone(&svc, &auth, Some("wtok"), Some("atok")).await;
1651 let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
1652 assert_eq!(
1653 tomb.expect("tombstone").meta.author,
1654 gonzalo_core::Identity::new("admin")
1655 );
1656 }
1657
1658 #[tokio::test]
1659 async fn open_mode_delete_keeps_the_prior_author() {
1660 let (svc, _d) = fs_service();
1662 let auth = open();
1663 seed_tombstone(&svc, &auth, None, None).await;
1664 let (_, tomb) = raw_get(&svc, &auth, RAW, None).await;
1665 assert_eq!(
1666 tomb.expect("tombstone").meta.author,
1667 gonzalo_core::Identity::new("client")
1668 );
1669 }
1670
1671 #[tokio::test]
1672 async fn http_open_mode_delete_keeps_a_claimed_author() {
1673 let (svc, _d) = fs_service();
1677 let auth = open();
1678 let (s, _) = call(
1679 svc.clone(),
1680 auth.clone(),
1681 "PUT",
1682 LIVE,
1683 None,
1684 Some(put_body("memory", "client")),
1685 )
1686 .await;
1687 assert_eq!(s, StatusCode::OK);
1688 let (s, _) = call(
1689 svc.clone(),
1690 auth.clone(),
1691 "DELETE",
1692 LIVE,
1693 None,
1694 Some(delete_body_claiming("origin")),
1695 )
1696 .await;
1697 assert_eq!(s, StatusCode::OK);
1698 let (_, tomb) = raw_get(&svc, &auth, RAW, None).await;
1699 assert_eq!(
1700 tomb.expect("tombstone").meta.author,
1701 gonzalo_core::Identity::new("origin")
1702 );
1703 }
1704
1705 #[tokio::test]
1706 async fn http_delete_by_non_admin_ignores_a_claimed_author() {
1707 let (svc, _d) = fs_service();
1718 let auth = tomb_auth();
1719 let (s, _) = call(
1720 svc.clone(),
1721 auth.clone(),
1722 "PUT",
1723 LIVE,
1724 Some("atok"),
1725 Some(put_body("memory", "client")),
1726 )
1727 .await;
1728 assert_eq!(s, StatusCode::OK);
1729 let (s, _) = call(
1730 svc.clone(),
1731 auth.clone(),
1732 "DELETE",
1733 LIVE,
1734 Some("wtok"),
1735 Some(delete_body_claiming("forged")),
1736 )
1737 .await;
1738 assert_eq!(s, StatusCode::OK);
1739 let (_, tomb) = raw_get(&svc, &auth, RAW, Some("wtok")).await;
1740 assert_eq!(
1741 tomb.expect("tombstone").meta.author,
1742 gonzalo_core::Identity::new("writer")
1743 );
1744 }
1745
1746 #[tokio::test]
1747 async fn http_delete_by_admin_keeps_a_claimed_author() {
1748 let (svc, _d) = fs_service();
1751 let auth = tomb_auth();
1752 let (s, _) = call(
1753 svc.clone(),
1754 auth.clone(),
1755 "PUT",
1756 LIVE,
1757 Some("wtok"),
1758 Some(put_body("memory", "client")),
1759 )
1760 .await;
1761 assert_eq!(s, StatusCode::OK);
1762 let (s, _) = call(
1763 svc.clone(),
1764 auth.clone(),
1765 "DELETE",
1766 LIVE,
1767 Some("atok"),
1768 Some(delete_body_claiming("origin")),
1769 )
1770 .await;
1771 assert_eq!(s, StatusCode::OK);
1772 let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
1773 assert_eq!(
1774 tomb.expect("tombstone").meta.author,
1775 gonzalo_core::Identity::new("origin")
1776 );
1777 }
1778
1779 #[tokio::test]
1780 async fn raw_reads_need_read_scope_and_absence_is_200_null() {
1781 let (svc, _d) = fs_service();
1782 let auth = tomb_auth();
1783
1784 let (s, rec) = raw_get(
1787 &svc,
1788 &auth,
1789 "/v1/raw/records/memory/col/absent",
1790 Some("rtok"),
1791 )
1792 .await;
1793 assert_eq!(s, StatusCode::OK);
1794 assert_eq!(rec, None);
1795
1796 let (s, _) = raw_get(&svc, &auth, "/v1/raw/records/secrets/col/x", Some("rtok")).await;
1798 assert_eq!(s, StatusCode::FORBIDDEN);
1799 let (s, _) = call(
1800 svc.clone(),
1801 auth.clone(),
1802 "GET",
1803 "/v1/raw/keys?namespace=secrets",
1804 Some("rtok"),
1805 None,
1806 )
1807 .await;
1808 assert_eq!(s, StatusCode::FORBIDDEN);
1809
1810 let (s, _) = call(svc, auth, "GET", RAW, None, None).await;
1812 assert_eq!(s, StatusCode::UNAUTHORIZED);
1813 }
1814
1815 #[tokio::test]
1816 async fn unscoped_list_raw_requires_admin() {
1817 let (svc, _d) = fs_service();
1818 let auth = tomb_auth();
1819 for token in ["rtok", "wtok"] {
1820 let (s, _) = call(
1821 svc.clone(),
1822 auth.clone(),
1823 "GET",
1824 "/v1/raw/keys",
1825 Some(token),
1826 None,
1827 )
1828 .await;
1829 assert_eq!(s, StatusCode::FORBIDDEN, "{token}");
1830 }
1831 let (s, _) = call(svc, auth, "GET", "/v1/raw/keys", Some("atok"), None).await;
1832 assert_eq!(s, StatusCode::OK);
1833 }
1834
1835 async fn put_raw_author(
1838 svc: &Service,
1839 auth: &Arc<Auth>,
1840 token: Option<&str>,
1841 ) -> gonzalo_core::Identity {
1842 let replica = record_at("origin", Revision::initial(b"{}"));
1843 let (s, body) = call(
1844 svc.clone(),
1845 auth.clone(),
1846 "PUT",
1847 RAW,
1848 token,
1849 Some(put_body_with(replica.clone(), None)),
1850 )
1851 .await;
1852 assert_eq!(s, StatusCode::OK);
1853 assert!(matches!(
1854 serde_json::from_slice::<PutOutcome>(&body).unwrap(),
1855 PutOutcome::Committed { revision } if revision == replica.revision
1856 ));
1857 let (_, stored) = raw_get(svc, auth, RAW, token).await;
1858 stored.expect("stored").meta.author
1859 }
1860
1861 #[tokio::test]
1862 async fn put_raw_needs_admin_and_matching_path() {
1863 let (svc, _d) = fs_service();
1864 let auth = tomb_auth();
1865 let replica = record_at("origin", Revision::initial(b"{}"));
1866
1867 for token in ["rtok", "wtok"] {
1870 let (s, _) = call(
1871 svc.clone(),
1872 auth.clone(),
1873 "PUT",
1874 RAW,
1875 Some(token),
1876 Some(put_body_with(replica.clone(), None)),
1877 )
1878 .await;
1879 assert_eq!(s, StatusCode::FORBIDDEN, "{token}");
1880 }
1881
1882 let (s, _) = call(
1885 svc,
1886 auth,
1887 "PUT",
1888 "/v1/raw/records/memory/col/y",
1889 Some("atok"),
1890 Some(put_body_with(replica, None)),
1891 )
1892 .await;
1893 assert_eq!(s, StatusCode::BAD_REQUEST);
1894 }
1895
1896 #[tokio::test]
1900 async fn put_raw_authorizes_before_checking_the_path() {
1901 let (svc, _d) = fs_service();
1902 let auth = tomb_auth();
1903 let replica = record_at("origin", Revision::initial(b"{}"));
1904 let (s, _) = call(
1905 svc,
1906 auth,
1907 "PUT",
1908 "/v1/raw/records/memory/col/y",
1909 Some("wtok"),
1910 Some(put_body_with(replica, None)),
1911 )
1912 .await;
1913 assert_eq!(s, StatusCode::FORBIDDEN);
1914 }
1915
1916 #[tokio::test]
1917 async fn put_raw_by_admin_keeps_the_incoming_author() {
1918 let (svc, _d) = fs_service();
1921 let auth = tomb_auth();
1922 assert_eq!(
1923 put_raw_author(&svc, &auth, Some("atok")).await,
1924 gonzalo_core::Identity::new("origin")
1925 );
1926 }
1927
1928 #[tokio::test]
1929 async fn put_raw_in_open_mode_keeps_the_incoming_author() {
1930 let (svc, _d) = fs_service();
1932 let auth = open();
1933 assert_eq!(
1934 put_raw_author(&svc, &auth, None).await,
1935 gonzalo_core::Identity::new("origin")
1936 );
1937 }
1938
1939 #[tokio::test]
1940 async fn put_raw_overwrites_a_tombstone_verbatim_and_none_conflicts() {
1941 let (svc, _d) = fs_service();
1942 let auth = tomb_auth();
1943 seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
1944 let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
1945 let tomb = tomb.expect("tombstone");
1946
1947 let peer = record_at("peer", Revision::initial(b"peer"));
1951 let (s, body) = call(
1952 svc.clone(),
1953 auth.clone(),
1954 "PUT",
1955 RAW,
1956 Some("atok"),
1957 Some(put_body_with(peer.clone(), None)),
1958 )
1959 .await;
1960 assert_eq!(s, StatusCode::CONFLICT);
1961 assert!(matches!(
1962 serde_json::from_slice::<PutOutcome>(&body).unwrap(),
1963 PutOutcome::Conflict { conflict } if conflict.current.revision == tomb.revision
1964 ));
1965
1966 let (s, _) = call(
1968 svc.clone(),
1969 auth.clone(),
1970 "PUT",
1971 RAW,
1972 Some("atok"),
1973 Some(put_body_with(peer.clone(), Some(tomb.revision.clone()))),
1974 )
1975 .await;
1976 assert_eq!(s, StatusCode::OK);
1977 let (_, stored) = raw_get(&svc, &auth, RAW, Some("rtok")).await;
1978 let stored = stored.expect("stored");
1979 assert_eq!(stored.revision, peer.revision);
1980 assert_eq!(stored.kind, gonzalo_core::RecordKind::MemoryTier);
1981 }
1982
1983 #[tokio::test]
1984 async fn put_not_found_is_412_on_both_put_routes() {
1985 let (svc, _d) = fs_service();
1986 let auth = tomb_auth();
1987 let never = Revision::initial(b"never current");
1988
1989 let (s, _) = call(
1992 svc.clone(),
1993 auth.clone(),
1994 "PUT",
1995 RAW,
1996 Some("atok"),
1997 Some(put_body_with(
1998 record_at("w", Revision::initial(b"{}")),
1999 Some(never.clone()),
2000 )),
2001 )
2002 .await;
2003 assert_eq!(s, StatusCode::PRECONDITION_FAILED);
2004
2005 seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2007 let (s, _) = call(
2008 svc,
2009 auth,
2010 "PUT",
2011 LIVE,
2012 Some("wtok"),
2013 Some(put_body_with(
2014 record_at("w", Revision::initial(b"{}")),
2015 Some(never),
2016 )),
2017 )
2018 .await;
2019 assert_eq!(s, StatusCode::PRECONDITION_FAILED);
2020 }
2021
2022 #[tokio::test]
2023 async fn purge_requires_admin() {
2024 let (svc, _d) = fs_service();
2025 let auth = tomb_auth();
2026 seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2027 let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2028 let rev = tomb.expect("tombstone").revision;
2029
2030 for token in ["rtok", "wtok"] {
2031 let (s, _) = call(
2032 svc.clone(),
2033 auth.clone(),
2034 "POST",
2035 PURGE,
2036 Some(token),
2037 Some(purge_body(&rev)),
2038 )
2039 .await;
2040 assert_eq!(s, StatusCode::FORBIDDEN, "{token}");
2041 }
2042
2043 let (s, body) = call(
2044 svc.clone(),
2045 auth.clone(),
2046 "POST",
2047 PURGE,
2048 Some("atok"),
2049 Some(purge_body(&rev)),
2050 )
2051 .await;
2052 assert_eq!(s, StatusCode::OK);
2053 assert!(matches!(
2054 serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2055 DeleteOutcome::Deleted
2056 ));
2057 let (s, rec) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2058 assert_eq!(s, StatusCode::OK);
2059 assert_eq!(rec, None);
2060 }
2061
2062 #[tokio::test]
2068 async fn a_namespace_writer_cannot_destroy_a_manifest_restore_window() {
2069 let (svc, _d) = fs_service();
2070 let auth = tomb_auth();
2071
2072 let mut manifest = record_at("client", Revision::initial(b"{}"));
2075 manifest.kind = gonzalo_core::RecordKind::VectorManifest;
2076 manifest.body = gonzalo_core::Body::Inline(br#"{"shards":["blob-a"]}"#.to_vec());
2077 let (s, _) = call(
2078 svc.clone(),
2079 auth.clone(),
2080 "PUT",
2081 LIVE,
2082 Some("wtok"),
2083 Some(put_body_with(manifest, None)),
2084 )
2085 .await;
2086 assert_eq!(s, StatusCode::OK);
2087 let (s, _) = call(
2088 svc.clone(),
2089 auth.clone(),
2090 "DELETE",
2091 LIVE,
2092 Some("wtok"),
2093 Some(delete_body()),
2094 )
2095 .await;
2096 assert_eq!(s, StatusCode::OK);
2097
2098 let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2099 let tomb = tomb.expect("tombstone");
2100 assert!(tomb.is_tombstone());
2101 assert_eq!(
2102 tomb.deleted_kind,
2103 Some(gonzalo_core::RecordKind::VectorManifest)
2104 );
2105
2106 let mut replacement = record_at("writer", Revision::initial(b"empty"));
2109 replacement.kind = gonzalo_core::RecordKind::VectorManifest;
2110 replacement.body = gonzalo_core::Body::Inline(br#"{"shards":[]}"#.to_vec());
2111 let (s, _) = call(
2112 svc.clone(),
2113 auth.clone(),
2114 "PUT",
2115 RAW,
2116 Some("wtok"),
2117 Some(put_body_with(replacement, Some(tomb.revision.clone()))),
2118 )
2119 .await;
2120 assert_eq!(s, StatusCode::FORBIDDEN);
2121
2122 let (_, after) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2124 let after = after.expect("tombstone survives");
2125 assert_eq!(after.revision, tomb.revision);
2126 assert_eq!(after.body, tomb.body);
2127 assert_eq!(after.deleted_kind, tomb.deleted_kind);
2128 }
2129
2130 #[tokio::test]
2134 async fn purge_of_a_live_record_is_refused() {
2135 let (svc, _d) = fs_service();
2136 let auth = tomb_auth();
2137 let (s, _) = call(
2138 svc.clone(),
2139 auth.clone(),
2140 "PUT",
2141 LIVE,
2142 Some("wtok"),
2143 Some(put_body("memory", "client")),
2144 )
2145 .await;
2146 assert_eq!(s, StatusCode::OK);
2147 let (_, live) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2148 let live = live.expect("live record");
2149
2150 let (s, body) = call(
2151 svc.clone(),
2152 auth.clone(),
2153 "POST",
2154 PURGE,
2155 Some("atok"),
2156 Some(purge_body(&live.revision)),
2157 )
2158 .await;
2159 assert_eq!(s, StatusCode::BAD_REQUEST);
2160 let msg = String::from_utf8_lossy(&body);
2161 assert!(msg.contains("gonzalo delete"), "{msg}");
2162
2163 let (_, after) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2165 let after = after.expect("live record survives");
2166 assert_eq!(after.revision, live.revision);
2167 assert!(!after.is_tombstone());
2168 }
2169
2170 #[tokio::test]
2176 async fn purge_of_a_recreated_record_still_conflicts() {
2177 let (svc, _d) = fs_service();
2178 let auth = tomb_auth();
2179 seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2180 let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2181 let tomb_rev = tomb.expect("tombstone").revision;
2182
2183 let (s, _) = call(
2186 svc.clone(),
2187 auth.clone(),
2188 "PUT",
2189 LIVE,
2190 Some("wtok"),
2191 Some(put_body("memory", "client")),
2192 )
2193 .await;
2194 assert_eq!(s, StatusCode::OK);
2195
2196 let (s, body) = call(
2197 svc.clone(),
2198 auth.clone(),
2199 "POST",
2200 PURGE,
2201 Some("atok"),
2202 Some(purge_body(&tomb_rev)),
2203 )
2204 .await;
2205 assert_eq!(s, StatusCode::CONFLICT);
2206 assert!(matches!(
2207 serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2208 DeleteOutcome::Conflict { .. }
2209 ));
2210
2211 let (_, after) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2213 let after = after.expect("recreated record survives");
2214 assert!(!after.is_tombstone());
2215 }
2216
2217 #[tokio::test]
2220 async fn purge_of_an_absent_record_is_still_deleted() {
2221 let (svc, _d) = fs_service();
2222 let auth = tomb_auth();
2223 let (s, body) = call(
2224 svc,
2225 auth,
2226 "POST",
2227 PURGE,
2228 Some("atok"),
2229 Some(purge_body(&Revision::initial(b"anything"))),
2230 )
2231 .await;
2232 assert_eq!(s, StatusCode::OK);
2233 assert!(matches!(
2234 serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2235 DeleteOutcome::Deleted
2236 ));
2237 }
2238
2239 #[tokio::test]
2240 async fn purge_with_stale_expected_is_409() {
2241 let (svc, _d) = fs_service();
2242 let auth = tomb_auth();
2243 seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2244 let (s, body) = call(
2245 svc,
2246 auth,
2247 "POST",
2248 PURGE,
2249 Some("atok"),
2250 Some(purge_body(&Revision::initial(b"not the tombstone"))),
2251 )
2252 .await;
2253 assert_eq!(s, StatusCode::CONFLICT);
2254 assert!(matches!(
2255 serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2256 DeleteOutcome::Conflict { .. }
2257 ));
2258 }
2259
2260 #[tokio::test]
2261 async fn purge_authorizes_before_parsing_the_body() {
2262 let (svc, _d) = fs_service();
2263 let auth = tomb_auth();
2264 let (s, _) = call(
2265 svc.clone(),
2266 auth.clone(),
2267 "POST",
2268 PURGE,
2269 Some("wtok"),
2270 Some(b"not json".to_vec()),
2271 )
2272 .await;
2273 assert_eq!(s, StatusCode::FORBIDDEN);
2274 let (s, _) = call(
2275 svc,
2276 auth,
2277 "POST",
2278 PURGE,
2279 Some("atok"),
2280 Some(b"not json".to_vec()),
2281 )
2282 .await;
2283 assert_eq!(s, StatusCode::BAD_REQUEST);
2284 }
2285
2286 #[tokio::test]
2287 async fn raw_backend_error_is_opaque() {
2288 let dir = TempDir::new().unwrap();
2289 let blobs = Arc::new(FsStore::new(dir.path()));
2290 let svc = Service::new(Arc::new(DownStore), blobs);
2291 let (s, body) = call(svc, scoped(), "GET", RAW, Some("wtok"), None).await;
2292 assert_eq!(s, StatusCode::INTERNAL_SERVER_ERROR);
2293 assert_eq!(String::from_utf8(body).unwrap(), "internal error");
2294 }
2295}