Skip to main content

notedthat_api_http/
error.rs

1//! HTTP API error types and JSON envelope.
2
3use axum::Json;
4use axum::http::header::{CONTENT_RANGE, RETRY_AFTER};
5use axum::http::{HeaderName, StatusCode};
6use axum::response::{IntoResponse, Response};
7use notedthat_core::{Error as CoreError, StorageError};
8use serde::Serialize;
9
10/// HTTP-layer error — maps domain and storage errors to HTTP status codes.
11#[derive(Debug, thiserror::Error)]
12pub enum ApiError {
13    /// The request lacked valid Bearer credentials.
14    #[error("unauthorized")]
15    Unauthorized,
16    /// The credential is valid, but the knowledge base's access policy does not
17    /// grant this operation on this key.
18    ///
19    /// Distinct from [`Self::Unauthorized`] on purpose: `401` tells a caller
20    /// that credentials might help, and `403` tells them that theirs are fine
21    /// and the answer is still no. D43 reserved this code for exactly this.
22    #[error("forbidden")]
23    Forbidden,
24    /// Indexer queue was full while enqueueing an upsert (PUT/COPY).
25    #[error("indexer upsert backpressure")]
26    IndexerBackpressureUpsert,
27    /// Indexer queue was full while enqueueing a tombstone (DELETE).
28    #[error("indexer tombstone backpressure")]
29    IndexerBackpressureTombstone,
30    /// A domain error from `notedthat-core`.
31    #[error(transparent)]
32    Core(#[from] CoreError),
33    /// A storage-layer error not otherwise promoted to a top-level variant.
34    ///
35    /// Note: `StorageError::NotModified`, `StorageError::PreconditionFailed`, and
36    /// `StorageError::RangeNotSatisfiable` are promoted via the manual `From<StorageError>`
37    /// impl to top-level `ApiError` variants so that `IntoResponse` can emit the
38    /// RFC-required headers (e.g. `Content-Range: bytes */N` for 416).
39    #[error(transparent)]
40    Storage(StorageError),
41    /// A conditional PUT/DELETE failed because a precondition was not met.
42    ///
43    /// Maps to HTTP 412 Precondition Failed.
44    #[error("precondition failed")]
45    PreconditionFailed,
46    /// The requested byte range could not be satisfied.
47    ///
48    /// `complete_length` is the total object size in bytes.  The `IntoResponse`
49    /// impl emits `Content-Range: bytes */complete_length` per RFC 7233 §4.4.
50    #[error("range not satisfiable")]
51    RangeNotSatisfiable {
52        /// Total object size in bytes.
53        complete_length: u64,
54    },
55    /// The backend returned 304 Not Modified for a conditional GET/HEAD.
56    ///
57    /// Maps to HTTP 304 with no body, as required by RFC 7232 §4.1.
58    #[error("not modified")]
59    NotModified,
60    /// The `Range:` header value could not be parsed per RFC 7233.
61    ///
62    /// Maps to HTTP 400 Bad Request.
63    #[error("malformed range: {0}")]
64    MalformedRange(String),
65    /// A `Range: lines=…` header specified a range that exceeds the object's line count.
66    ///
67    /// Maps to HTTP 416 with `Content-Range: lines */<line_total>` and
68    /// `X-Content-Range-Bytes: */<byte_total>` per the line-range extension.
69    #[error("line range not satisfiable")]
70    LineRangeNotSatisfiable {
71        /// Total line count in the object.
72        line_total: u64,
73        /// Total byte count in the object.
74        byte_total: u64,
75    },
76    /// A single-replace operation found no occurrence of `old_string`.
77    #[error("no match found for old_string")]
78    ReplaceNoMatch,
79    /// A single-replace operation found multiple occurrences of `old_string`.
80    #[error("multiple matches found ({count}); use replace_all to replace them all")]
81    ReplaceAmbiguous {
82        /// Number of occurrences found.
83        count: u64,
84    },
85}
86
87/// Promote specific `StorageError` variants to top-level `ApiError` variants so
88/// that `IntoResponse` can emit the RFC-mandated response headers.
89impl From<StorageError> for ApiError {
90    fn from(e: StorageError) -> Self {
91        match e {
92            StorageError::NotModified => Self::NotModified,
93            StorageError::PreconditionFailed => Self::PreconditionFailed,
94            StorageError::RangeNotSatisfiable { complete_length } => {
95                Self::RangeNotSatisfiable { complete_length }
96            }
97            other => Self::Storage(other),
98        }
99    }
100}
101
102impl From<notedthat_write::WriteError> for ApiError {
103    fn from(e: notedthat_write::WriteError) -> Self {
104        match e {
105            notedthat_write::WriteError::Storage(e) => Self::Storage(e),
106            notedthat_write::WriteError::TooLarge { size, limit }
107            | notedthat_write::WriteError::PatchTooLarge { size, limit } => {
108                Self::Core(CoreError::PayloadTooLarge { size, limit })
109            }
110            notedthat_write::WriteError::Path(e) => Self::Core(e),
111            notedthat_write::WriteError::IndexerBackpressureUpsert => {
112                Self::IndexerBackpressureUpsert
113            }
114            notedthat_write::WriteError::IndexerBackpressureTombstone => {
115                Self::IndexerBackpressureTombstone
116            }
117            notedthat_write::WriteError::PatchLineOutOfRange {
118                total_lines,
119                total_bytes,
120                ..
121            } => Self::LineRangeNotSatisfiable {
122                line_total: total_lines,
123                byte_total: total_bytes,
124            },
125            notedthat_write::WriteError::PatchInvalidRange { message } => {
126                Self::Core(CoreError::InvalidInput { message })
127            }
128            notedthat_write::WriteError::ReplaceNoMatch => Self::ReplaceNoMatch,
129            notedthat_write::WriteError::ReplaceAmbiguous { count } => {
130                Self::ReplaceAmbiguous { count }
131            }
132        }
133    }
134}
135
136/// JSON error response body shape: `{ "error": "code", "message": "...", "request_id": "..." }`.
137#[derive(Serialize)]
138struct ErrorBody<'a> {
139    error: &'a str,
140    message: String,
141    request_id: String,
142}
143
144#[derive(Serialize)]
145struct ReplaceAmbiguousBody<'a> {
146    error: &'a str,
147    message: String,
148    request_id: String,
149    match_count: u64,
150}
151
152/// An [`ApiError`] paired with a `request_id` string so the JSON body and the
153/// `x-request-id` response header both contain the same value.
154pub struct ApiErrorResponse {
155    /// The underlying error.
156    pub error: ApiError,
157    /// The request ID (from the `x-request-id` header via `tower-http`).
158    pub request_id: String,
159}
160
161impl ApiErrorResponse {
162    /// Build an unauthorized response with the provided request ID.
163    #[must_use]
164    pub fn unauthorized(request_id: String) -> Self {
165        Self {
166            error: ApiError::Unauthorized,
167            request_id,
168        }
169    }
170}
171
172impl ApiError {
173    fn status_and_code(&self) -> (StatusCode, &'static str) {
174        match self {
175            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
176            Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
177            Self::IndexerBackpressureUpsert | Self::IndexerBackpressureTombstone => {
178                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
179            }
180            Self::Core(CoreError::InvalidInput { .. }) => {
181                (StatusCode::BAD_REQUEST, "invalid_request")
182            }
183            Self::Core(CoreError::NotFound { .. }) => (StatusCode::NOT_FOUND, "not_found"),
184            Self::Core(CoreError::PayloadTooLarge { .. }) => {
185                (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
186            }
187            Self::Core(CoreError::MalformedRange(_)) | Self::MalformedRange(_) => {
188                (StatusCode::BAD_REQUEST, "malformed_range")
189            }
190            Self::LineRangeNotSatisfiable { .. }
191            | Self::Core(CoreError::RangeNotSatisfiable { .. })
192            | Self::RangeNotSatisfiable { .. } => {
193                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
194            }
195            Self::Core(CoreError::NotModified) | Self::NotModified => {
196                (StatusCode::NOT_MODIFIED, "not_modified")
197            }
198            Self::Core(CoreError::PreconditionFailed) | Self::PreconditionFailed => {
199                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
200            }
201            Self::ReplaceNoMatch => (StatusCode::UNPROCESSABLE_ENTITY, "no_match"),
202            Self::ReplaceAmbiguous { .. } => (StatusCode::UNPROCESSABLE_ENTITY, "ambiguous_match"),
203            Self::Core(CoreError::BucketNameTooLong { .. } | CoreError::Config { .. }) => {
204                (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
205            }
206            Self::Core(CoreError::Storage(e)) | Self::Storage(e) => Self::storage_status(e),
207        }
208    }
209
210    fn storage_status(e: &StorageError) -> (StatusCode, &'static str) {
211        match e {
212            StorageError::NotFound { .. } | StorageError::BucketNotFound { .. } => {
213                (StatusCode::NOT_FOUND, "not_found")
214            }
215            StorageError::BackendUnavailable { .. } => {
216                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
217            }
218            StorageError::NotModified => (StatusCode::NOT_MODIFIED, "not_modified"),
219            StorageError::PreconditionFailed => {
220                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
221            }
222            StorageError::RangeNotSatisfiable { .. } => {
223                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
224            }
225            StorageError::Other { .. } => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
226        }
227    }
228
229    /// Extract the `complete_length` for a 416 response, regardless of which
230    /// wrapper the `RangeNotSatisfiable` error arrived in.
231    fn range_not_satisfiable_length(&self) -> Option<u64> {
232        match self {
233            Self::RangeNotSatisfiable { complete_length }
234            | Self::Storage(StorageError::RangeNotSatisfiable { complete_length })
235            | Self::Core(CoreError::RangeNotSatisfiable { complete_length }) => {
236                Some(*complete_length)
237            }
238            _ => None,
239        }
240    }
241
242    /// Return `true` for all variants that map to HTTP 304 (empty body required).
243    fn is_not_modified(&self) -> bool {
244        matches!(
245            self,
246            Self::NotModified
247                | Self::Storage(StorageError::NotModified)
248                | Self::Core(CoreError::NotModified)
249        )
250    }
251}
252
253impl IntoResponse for ApiErrorResponse {
254    fn into_response(self) -> Response {
255        // Line-mode 416: emit Content-Range: lines */<total> + X-Content-Range-Bytes: */<total_bytes>.
256        if let ApiError::LineRangeNotSatisfiable {
257            line_total,
258            byte_total,
259        } = &self.error
260        {
261            return (
262                StatusCode::RANGE_NOT_SATISFIABLE,
263                [
264                    (CONTENT_RANGE, format!("lines */{line_total}")),
265                    (
266                        HeaderName::from_static("x-content-range-bytes"),
267                        format!("*/{byte_total}"),
268                    ),
269                ],
270            )
271                .into_response();
272        }
273
274        // 416 Range Not Satisfiable: RFC 7233 §4.4 requires
275        // `Content-Range: bytes */N` and an empty body.
276        if let Some(complete_length) = self.error.range_not_satisfiable_length() {
277            let content_range = format!("bytes */{complete_length}");
278            return (
279                StatusCode::RANGE_NOT_SATISFIABLE,
280                [(CONTENT_RANGE, content_range)],
281            )
282                .into_response();
283        }
284
285        // 304 Not Modified: RFC 7232 §4.1 forbids a message body.
286        if self.error.is_not_modified() {
287            return StatusCode::NOT_MODIFIED.into_response();
288        }
289
290        match &self.error {
291            ApiError::Unauthorized => {
292                let body = ErrorBody {
293                    error: "unauthorized",
294                    message: "provide a valid Bearer token in the Authorization header".to_string(),
295                    request_id: self.request_id,
296                };
297                return (StatusCode::UNAUTHORIZED, Json(body)).into_response();
298            }
299            ApiError::IndexerBackpressureUpsert => {
300                let body = ErrorBody {
301                    error: "backend_unavailable",
302                    message: "object stored; indexer queue full — retry to re-enqueue".to_string(),
303                    request_id: self.request_id,
304                };
305                return (
306                    StatusCode::SERVICE_UNAVAILABLE,
307                    [(RETRY_AFTER, "5")],
308                    Json(body),
309                )
310                    .into_response();
311            }
312            ApiError::IndexerBackpressureTombstone => {
313                let body = ErrorBody {
314                    error: "backend_unavailable",
315                    message: "deleted from storage; retry to clear from search index".to_string(),
316                    request_id: self.request_id,
317                };
318                return (
319                    StatusCode::SERVICE_UNAVAILABLE,
320                    [(RETRY_AFTER, "5")],
321                    Json(body),
322                )
323                    .into_response();
324            }
325            ApiError::ReplaceAmbiguous { count } => {
326                let body = ReplaceAmbiguousBody {
327                    error: "ambiguous_match",
328                    message: self.error.to_string(),
329                    request_id: self.request_id,
330                    match_count: *count,
331                };
332                return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
333            }
334            _ => {}
335        }
336
337        // All other variants return a JSON error body.
338        let (status, code) = self.error.status_and_code();
339        let message = self.error.to_string();
340        let body = ErrorBody {
341            error: code,
342            message,
343            request_id: self.request_id,
344        };
345        (status, Json(body)).into_response()
346    }
347}
348
349impl IntoResponse for ApiError {
350    fn into_response(self) -> Response {
351        ApiErrorResponse {
352            error: self,
353            request_id: "unknown".to_string(),
354        }
355        .into_response()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use axum::body::{Body, to_bytes};
363    use axum::http::Request;
364    use bytes::Bytes;
365    use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage};
366    use notedthat_indexer::IndexEvent;
367    use notedthat_write::WriteError;
368    use std::collections::BTreeMap;
369    use std::sync::Arc;
370    use tower::util::ServiceExt;
371
372    const KB: &str = "notes";
373    const TOKEN: &str = "test-token-abc";
374
375    fn router() -> axum::Router {
376        router_with_max_patchable_size(16 * 1024 * 1024)
377    }
378
379    fn router_with_max_patchable_size(max_patchable_size: u64) -> axum::Router {
380        let kb = KbSlug::try_new(KB).unwrap();
381        let mut kbs = BTreeMap::new();
382        kbs.insert(KB.to_string(), kb);
383        let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
384        tokio::spawn(async move { while rx.recv().await.is_some() {} });
385
386        crate::router::build_router(crate::state::AppState {
387            storage: Arc::new(crate::testing::InMemoryStorage::default()),
388            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
389            declared_kbs: Arc::new(kbs),
390            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
391            max_body_size: 16 * 1024 * 1024,
392            max_patchable_size,
393            indexer_tx,
394            searcher: Arc::new(crate::testing::NoopSearcher),
395        })
396    }
397
398    fn router_with_storage_and_indexer(
399        storage: Arc<dyn Storage>,
400        kb: KbSlug,
401        max_patchable_size: u64,
402        indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
403    ) -> axum::Router {
404        let mut kbs = BTreeMap::new();
405        kbs.insert(KB.to_string(), kb);
406        crate::router::build_router(crate::state::AppState {
407            storage,
408            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
409            declared_kbs: Arc::new(kbs),
410            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
411            max_body_size: 16 * 1024 * 1024,
412            max_patchable_size,
413            indexer_tx,
414            searcher: Arc::new(crate::testing::NoopSearcher),
415        })
416    }
417
418    async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
419        let response = router
420            .oneshot(
421                Request::builder()
422                    .method("PUT")
423                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
424                    .header("authorization", format!("Bearer {TOKEN}"))
425                    .header(axum::http::header::CONTENT_TYPE, "text/markdown")
426                    .body(Body::from(Bytes::from_static(body)))
427                    .unwrap(),
428            )
429            .await
430            .unwrap();
431
432        assert_eq!(response.status(), StatusCode::CREATED);
433        response
434            .headers()
435            .get(axum::http::header::ETAG)
436            .unwrap()
437            .to_str()
438            .unwrap()
439            .to_string()
440    }
441
442    async fn get_object(router: axum::Router, path: &str) -> Bytes {
443        let response = router
444            .oneshot(
445                Request::builder()
446                    .method("GET")
447                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
448                    .header("authorization", format!("Bearer {TOKEN}"))
449                    .body(Body::empty())
450                    .unwrap(),
451            )
452            .await
453            .unwrap();
454
455        assert_eq!(response.status(), StatusCode::OK);
456        to_bytes(response.into_body(), usize::MAX).await.unwrap()
457    }
458
459    async fn post_replace(
460        router: axum::Router,
461        path: &str,
462        if_match: &str,
463        body: &'static [u8],
464    ) -> Response {
465        router
466            .oneshot(
467                Request::builder()
468                    .method("POST")
469                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
470                    .header("authorization", format!("Bearer {TOKEN}"))
471                    .header(axum::http::header::CONTENT_TYPE, "application/json")
472                    .header(axum::http::header::IF_MATCH, if_match)
473                    .body(Body::from(Bytes::from_static(body)))
474                    .unwrap(),
475            )
476            .await
477            .unwrap()
478    }
479
480    async fn object_with_etag(
481        storage: &crate::testing::InMemoryStorage,
482        kb: &KbSlug,
483        path: &str,
484        body: &'static [u8],
485    ) -> String {
486        storage
487            .put_object(
488                kb,
489                &ObjectPath::try_from_str(path).unwrap(),
490                Bytes::from_static(body),
491                Some("text/markdown"),
492                ConditionalHeaders::default(),
493            )
494            .await
495            .unwrap()
496            .etag
497            .unwrap()
498    }
499
500    async fn assert_invalid_request_response(response: Response) {
501        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
502        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
503        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
504        assert_eq!(json["error"], "invalid_request");
505    }
506
507    #[tokio::test]
508    async fn test_unauthorized_status_and_body() {
509        let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
510        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
511        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
512        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
513        assert_eq!(json["error"], "unauthorized");
514        assert_eq!(json["request_id"], "req-123");
515    }
516
517    #[tokio::test]
518    async fn test_not_found_status() {
519        let err = ApiError::Core(CoreError::NotFound {
520            resource: "foo".into(),
521        });
522        let resp = ApiErrorResponse {
523            error: err,
524            request_id: "rid".into(),
525        }
526        .into_response();
527        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
528    }
529
530    #[tokio::test]
531    async fn test_payload_too_large_status() {
532        let err = ApiError::Core(CoreError::PayloadTooLarge {
533            size: 20_000_000,
534            limit: 16_777_216,
535        });
536        let resp = ApiErrorResponse {
537            error: err,
538            request_id: "rid".into(),
539        }
540        .into_response();
541        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
542    }
543
544    #[tokio::test]
545    async fn test_request_id_in_body() {
546        let err = ApiError::Core(CoreError::InvalidInput {
547            message: "bad".into(),
548        });
549        let resp = ApiErrorResponse {
550            error: err,
551            request_id: "my-req-id".into(),
552        }
553        .into_response();
554        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
555        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
556        assert_eq!(json["request_id"], "my-req-id");
557    }
558
559    #[tokio::test]
560    async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
561        let resp = ApiErrorResponse {
562            error: ApiError::IndexerBackpressureUpsert,
563            request_id: "rid".to_string(),
564        }
565        .into_response();
566
567        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
568        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
569        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
570        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
571        assert_eq!(json["error"], "backend_unavailable");
572        assert_eq!(
573            json["message"],
574            "object stored; indexer queue full — retry to re-enqueue"
575        );
576        assert_eq!(json["request_id"], "rid");
577    }
578
579    #[tokio::test]
580    async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
581        let resp = ApiErrorResponse {
582            error: ApiError::IndexerBackpressureTombstone,
583            request_id: "rid".to_string(),
584        }
585        .into_response();
586
587        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
588        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
589        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
590        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
591        assert_eq!(json["error"], "backend_unavailable");
592        assert_eq!(
593            json["message"],
594            "deleted from storage; retry to clear from search index"
595        );
596        assert_eq!(json["request_id"], "rid");
597    }
598
599    #[test]
600    fn test_from_write_error_indexer_backpressure() {
601        assert!(matches!(
602            ApiError::from(WriteError::IndexerBackpressureUpsert),
603            ApiError::IndexerBackpressureUpsert
604        ));
605        assert!(matches!(
606            ApiError::from(WriteError::IndexerBackpressureTombstone),
607            ApiError::IndexerBackpressureTombstone
608        ));
609    }
610
611    #[test]
612    fn test_from_write_error_patch_too_large() {
613        let api_err = ApiError::from(WriteError::PatchTooLarge {
614            size: 200 * 1024 * 1024,
615            limit: 100 * 1024 * 1024,
616        });
617
618        let (status, code) = api_err.status_and_code();
619        assert_eq!(status.as_u16(), 413);
620        assert_eq!(code, "payload_too_large");
621    }
622
623    #[test]
624    fn test_from_write_error_patch_line_out_of_range() {
625        let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
626            first: 999,
627            last: 1000,
628            total_lines: 20,
629            total_bytes: 100,
630        });
631
632        let (status, code) = api_err.status_and_code();
633        assert_eq!(status.as_u16(), 416);
634        assert_eq!(code, "range_not_satisfiable");
635    }
636
637    #[test]
638    fn test_from_write_error_patch_invalid_range() {
639        let api_err = ApiError::from(WriteError::PatchInvalidRange {
640            message: "test".into(),
641        });
642
643        let (status, code) = api_err.status_and_code();
644        assert_eq!(status.as_u16(), 400);
645        assert_eq!(code, "invalid_request");
646    }
647
648    #[test]
649    fn test_from_write_error_replace_no_match() {
650        let api_err = ApiError::from(WriteError::ReplaceNoMatch);
651
652        let (status, code) = api_err.status_and_code();
653        assert_eq!(status.as_u16(), 422);
654        assert_eq!(code, "no_match");
655    }
656
657    #[test]
658    fn test_from_write_error_replace_ambiguous() {
659        let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
660
661        let (status, code) = api_err.status_and_code();
662        assert_eq!(status.as_u16(), 422);
663        assert_eq!(code, "ambiguous_match");
664    }
665
666    #[tokio::test]
667    async fn test_ambiguous_match_body_includes_match_count() {
668        let resp = ApiErrorResponse {
669            error: ApiError::ReplaceAmbiguous { count: 3 },
670            request_id: "req-1".into(),
671        }
672        .into_response();
673
674        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
675        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
676        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
677        assert_eq!(json["error"], "ambiguous_match");
678        assert_eq!(json["match_count"], 3);
679        assert_eq!(json["request_id"], "req-1");
680    }
681
682    #[tokio::test]
683    async fn test_no_match_body_omits_match_count() {
684        let resp = ApiErrorResponse {
685            error: ApiError::ReplaceNoMatch,
686            request_id: "req-1".into(),
687        }
688        .into_response();
689
690        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
691        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
692        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
693        assert_eq!(json["error"], "no_match");
694        assert!(json.get("match_count").is_none());
695    }
696
697    #[tokio::test]
698    async fn replace_missing_if_match_returns_400_invalid_request() {
699        let response = router()
700            .oneshot(
701                Request::builder()
702                    .method("POST")
703                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
704                    .header("authorization", format!("Bearer {TOKEN}"))
705                    .header(axum::http::header::CONTENT_TYPE, "application/json")
706                    .body(Body::from(Bytes::from_static(
707                        br#"{"old_string":"x","new_string":"y"}"#,
708                    )))
709                    .unwrap(),
710            )
711            .await
712            .unwrap();
713
714        assert_invalid_request_response(response).await;
715    }
716
717    #[tokio::test]
718    async fn replace_if_match_star_returns_400() {
719        let response = router()
720            .oneshot(
721                Request::builder()
722                    .method("POST")
723                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
724                    .header("authorization", format!("Bearer {TOKEN}"))
725                    .header(axum::http::header::CONTENT_TYPE, "application/json")
726                    .header(axum::http::header::IF_MATCH, "*")
727                    .body(Body::from(Bytes::from_static(
728                        br#"{"old_string":"x","new_string":"y"}"#,
729                    )))
730                    .unwrap(),
731            )
732            .await
733            .unwrap();
734
735        assert_invalid_request_response(response).await;
736    }
737
738    #[tokio::test]
739    async fn replace_multi_value_if_match_returns_400() {
740        let response = router()
741            .oneshot(
742                Request::builder()
743                    .method("POST")
744                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
745                    .header("authorization", format!("Bearer {TOKEN}"))
746                    .header(axum::http::header::CONTENT_TYPE, "application/json")
747                    .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
748                    .body(Body::from(Bytes::from_static(
749                        br#"{"old_string":"x","new_string":"y"}"#,
750                    )))
751                    .unwrap(),
752            )
753            .await
754            .unwrap();
755
756        assert_invalid_request_response(response).await;
757    }
758
759    #[tokio::test]
760    async fn replace_malformed_json_body_returns_400() {
761        let response = router()
762            .oneshot(
763                Request::builder()
764                    .method("POST")
765                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
766                    .header("authorization", format!("Bearer {TOKEN}"))
767                    .header(axum::http::header::CONTENT_TYPE, "application/json")
768                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
769                    .body(Body::from(Bytes::from_static(b"{invalid json")))
770                    .unwrap(),
771            )
772            .await
773            .unwrap();
774
775        assert_invalid_request_response(response).await;
776    }
777
778    #[tokio::test]
779    async fn replace_missing_old_string_field_returns_400() {
780        let response = router()
781            .oneshot(
782                Request::builder()
783                    .method("POST")
784                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
785                    .header("authorization", format!("Bearer {TOKEN}"))
786                    .header(axum::http::header::CONTENT_TYPE, "application/json")
787                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
788                    .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
789                    .unwrap(),
790            )
791            .await
792            .unwrap();
793
794        assert_invalid_request_response(response).await;
795    }
796
797    #[tokio::test]
798    async fn replace_empty_old_string_returns_400() {
799        let response = router()
800            .oneshot(
801                Request::builder()
802                    .method("POST")
803                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
804                    .header("authorization", format!("Bearer {TOKEN}"))
805                    .header(axum::http::header::CONTENT_TYPE, "application/json")
806                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
807                    .body(Body::from(Bytes::from_static(
808                        br#"{"old_string":"","new_string":"y"}"#,
809                    )))
810                    .unwrap(),
811            )
812            .await
813            .unwrap();
814
815        assert_invalid_request_response(response).await;
816    }
817
818    #[tokio::test]
819    async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
820        let router = router();
821        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
822
823        let response = post_replace(
824            router.clone(),
825            "hello.md",
826            &etag,
827            br#"{"old_string":"world","new_string":"planet"}"#,
828        )
829        .await;
830
831        assert_eq!(response.status(), StatusCode::OK);
832        assert!(response.headers().get(axum::http::header::ETAG).is_some());
833        assert_eq!(
834            response.headers().get("content-location").unwrap(),
835            "/api/v1/knowledgebases/notes/hello.md"
836        );
837        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
838        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
839        assert!(json["etag"].as_str().is_some());
840        assert_eq!(json["match_count"], 1);
841        assert_eq!(json["total_bytes"], 12);
842        assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
843    }
844
845    #[tokio::test]
846    async fn replace_no_match_returns_422_no_match() {
847        let router = router();
848        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
849
850        let response = post_replace(
851            router.clone(),
852            "hello.md",
853            &etag,
854            br#"{"old_string":"nonexistent","new_string":"x"}"#,
855        )
856        .await;
857
858        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
859        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
860        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
861        assert_eq!(json["error"], "no_match");
862        assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
863    }
864
865    #[tokio::test]
866    async fn replace_ambiguous_returns_422_with_match_count() {
867        let router = router();
868        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
869
870        let response = post_replace(
871            router.clone(),
872            "hello.md",
873            &etag,
874            br#"{"old_string":"a","new_string":"Z"}"#,
875        )
876        .await;
877
878        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
879        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
880        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
881        assert_eq!(json["error"], "ambiguous_match");
882        assert_eq!(json["match_count"], 2);
883        assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
884    }
885
886    #[tokio::test]
887    async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
888        let router = router();
889        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
890
891        let response = post_replace(
892            router.clone(),
893            "hello.md",
894            &etag,
895            br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
896        )
897        .await;
898
899        assert_eq!(response.status(), StatusCode::OK);
900        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
901        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
902        assert_eq!(json["match_count"], 2);
903        assert_eq!(json["total_bytes"], 5);
904        assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
905    }
906
907    #[tokio::test]
908    async fn replace_stale_etag_returns_412() {
909        let router = router();
910        put_object(router.clone(), "hello.md", b"hello world").await;
911
912        let response = post_replace(
913            router,
914            "hello.md",
915            "\"stale\"",
916            br#"{"old_string":"world","new_string":"planet"}"#,
917        )
918        .await;
919
920        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
921    }
922
923    #[tokio::test]
924    async fn replace_post_splice_size_over_cap_returns_413() {
925        let router = router_with_max_patchable_size(20);
926        let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
927
928        let response = post_replace(
929            router,
930            "hello.md",
931            &etag,
932            br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
933        )
934        .await;
935
936        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
937        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
938        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
939        assert_eq!(json["error"], "payload_too_large");
940    }
941
942    #[tokio::test]
943    async fn replace_indexer_backpressure_returns_503_with_retry_after() {
944        let kb = KbSlug::try_new(KB).unwrap();
945        let storage = crate::testing::InMemoryStorage::default();
946        let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
947        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
948        indexer_tx
949            .try_send(IndexEvent::Upsert {
950                kb: kb.clone(),
951                object_key: ObjectPath::try_from_str("queued.md").unwrap(),
952                etag: "queued".to_string(),
953                mtime: 0,
954            })
955            .unwrap();
956        let router =
957            router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
958
959        let response = post_replace(
960            router,
961            "hello.md",
962            &etag,
963            br#"{"old_string":"world","new_string":"planet"}"#,
964        )
965        .await;
966
967        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
968        assert_eq!(response.headers().get("retry-after").unwrap(), "5");
969        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
970        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
971        assert_eq!(json["error"], "backend_unavailable");
972    }
973
974    #[tokio::test]
975    async fn test_precondition_failed_body_shape_unchanged() {
976        let resp = ApiErrorResponse {
977            error: ApiError::PreconditionFailed,
978            request_id: "req-1".into(),
979        }
980        .into_response();
981
982        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
983        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
984        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
985        let object = json.as_object().unwrap();
986        assert_eq!(object.len(), 3);
987        assert!(object.contains_key("error"));
988        assert!(object.contains_key("message"));
989        assert!(object.contains_key("request_id"));
990    }
991
992    // ─── New variant tests ────────────────────────────────────────────────────
993
994    #[tokio::test]
995    async fn test_precondition_failed_412() {
996        let resp = ApiError::PreconditionFailed.into_response();
997        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
998    }
999
1000    #[tokio::test]
1001    async fn test_not_modified_304_empty_body() {
1002        let resp = ApiError::NotModified.into_response();
1003        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1004        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1005        assert!(body.is_empty(), "304 must have an empty body");
1006    }
1007
1008    #[tokio::test]
1009    async fn test_malformed_range_400() {
1010        let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1011        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1012    }
1013
1014    /// RFC 7233 §4.4: a 416 response MUST include `Content-Range: bytes */N`.
1015    #[tokio::test]
1016    async fn test_range_not_satisfiable_416_content_range_header() {
1017        let resp = ApiError::RangeNotSatisfiable {
1018            complete_length: 100,
1019        }
1020        .into_response();
1021        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1022        let cr = resp
1023            .headers()
1024            .get("content-range")
1025            .expect("content-range header must be present on 416");
1026        assert_eq!(cr.to_str().unwrap(), "bytes */100");
1027        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1028        assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1029    }
1030
1031    /// Same 416 check via the `ApiError::Storage(...)` wrapper (e.g. from router.rs
1032    /// call sites that use explicit wrapping instead of `Into::into`).
1033    #[tokio::test]
1034    async fn test_storage_range_not_satisfiable_416_content_range_header() {
1035        let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1036            complete_length: 42,
1037        })
1038        .into_response();
1039        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1040        let cr = resp
1041            .headers()
1042            .get("content-range")
1043            .expect("content-range header must be present on 416");
1044        assert_eq!(cr.to_str().unwrap(), "bytes */42");
1045    }
1046
1047    /// 304 from a wrapped `StorageError::NotModified` (explicit wrapping in router.rs).
1048    #[tokio::test]
1049    async fn test_storage_not_modified_304_empty_body() {
1050        let resp = ApiError::Storage(StorageError::NotModified).into_response();
1051        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1052        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1053        assert!(body.is_empty(), "304 must have an empty body");
1054    }
1055
1056    /// `From<StorageError>` promotes `NotModified` to `ApiError::NotModified`.
1057    #[test]
1058    fn test_from_storage_error_not_modified() {
1059        let api_err = ApiError::from(StorageError::NotModified);
1060        assert!(matches!(api_err, ApiError::NotModified));
1061    }
1062
1063    /// `From<StorageError>` promotes `PreconditionFailed` to `ApiError::PreconditionFailed`.
1064    #[test]
1065    fn test_from_storage_error_precondition_failed() {
1066        let api_err = ApiError::from(StorageError::PreconditionFailed);
1067        assert!(matches!(api_err, ApiError::PreconditionFailed));
1068    }
1069
1070    /// `From<StorageError>` promotes `RangeNotSatisfiable` with the correct length.
1071    #[test]
1072    fn test_from_storage_error_range_not_satisfiable() {
1073        let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1074            complete_length: 999,
1075        });
1076        assert!(
1077            matches!(
1078                api_err,
1079                ApiError::RangeNotSatisfiable {
1080                    complete_length: 999
1081                }
1082            ),
1083            "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1084        );
1085    }
1086
1087    /// Other `StorageError` variants must still be wrapped in `ApiError::Storage`.
1088    #[test]
1089    fn test_from_storage_error_other_wrapped() {
1090        let api_err = ApiError::from(StorageError::NotFound {
1091            key: "foo".to_string(),
1092        });
1093        assert!(matches!(api_err, ApiError::Storage(_)));
1094    }
1095
1096    mod line_range_error {
1097        use super::*;
1098        use axum::body::Body;
1099        use axum::http::Request;
1100        use bytes::Bytes;
1101        use notedthat_core::KbSlug;
1102        use std::collections::BTreeMap;
1103        use std::sync::Arc;
1104        use tower::util::ServiceExt;
1105
1106        const KB: &str = "notes";
1107        const TOKEN: &str = "test-token-abc";
1108
1109        fn twenty_line_markdown() -> String {
1110            let mut body = String::new();
1111            for line in 1..=20 {
1112                std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1113            }
1114            body
1115        }
1116
1117        fn router() -> axum::Router {
1118            let kb = KbSlug::try_new(KB).unwrap();
1119            let mut kbs = BTreeMap::new();
1120            kbs.insert(KB.to_string(), kb);
1121            let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1122            tokio::spawn(async move { while rx.recv().await.is_some() {} });
1123
1124            crate::router::build_router(crate::state::AppState {
1125                storage: Arc::new(crate::testing::InMemoryStorage::default()),
1126                access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1127                declared_kbs: Arc::new(kbs),
1128                authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1129                max_body_size: 16 * 1024 * 1024,
1130                max_patchable_size: 16 * 1024 * 1024,
1131                indexer_tx,
1132                searcher: Arc::new(crate::testing::NoopSearcher),
1133            })
1134        }
1135
1136        async fn put_ranges_md(router: axum::Router) {
1137            let response = router
1138                .oneshot(
1139                    Request::builder()
1140                        .method("PUT")
1141                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1142                        .header("authorization", format!("Bearer {TOKEN}"))
1143                        .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1144                        .body(Body::from(Bytes::from(twenty_line_markdown())))
1145                        .unwrap(),
1146                )
1147                .await
1148                .unwrap();
1149
1150            assert_eq!(response.status(), StatusCode::CREATED);
1151        }
1152
1153        #[tokio::test]
1154        async fn malformed_line_range_returns_json_400() {
1155            let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1156
1157            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1158            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1159            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1160            assert_eq!(json["error"], "malformed_range");
1161        }
1162
1163        #[tokio::test]
1164        async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1165            let response = ApiError::LineRangeNotSatisfiable {
1166                line_total: 20,
1167                byte_total: 100,
1168            }
1169            .into_response();
1170
1171            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1172            assert_eq!(
1173                response.headers().get("content-range").unwrap(),
1174                "lines */20"
1175            );
1176            assert_eq!(
1177                response.headers().get("x-content-range-bytes").unwrap(),
1178                "*/100"
1179            );
1180            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1181            assert!(body.is_empty());
1182        }
1183
1184        #[tokio::test]
1185        async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1186            let router = router();
1187            put_ranges_md(router.clone()).await;
1188
1189            let response = router
1190                .oneshot(
1191                    Request::builder()
1192                        .method("GET")
1193                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1194                        .header("authorization", format!("Bearer {TOKEN}"))
1195                        .header(axum::http::header::RANGE, "lines=100-200")
1196                        .body(Body::empty())
1197                        .unwrap(),
1198                )
1199                .await
1200                .unwrap();
1201
1202            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1203            assert_eq!(
1204                response.headers().get("content-range").unwrap(),
1205                "lines */20"
1206            );
1207            assert_eq!(
1208                response.headers().get("x-content-range-bytes").unwrap(),
1209                "*/160"
1210            );
1211            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1212            assert!(body.is_empty());
1213        }
1214
1215        #[tokio::test]
1216        async fn byte_range_not_satisfiable_omits_line_byte_header() {
1217            let response = ApiError::RangeNotSatisfiable {
1218                complete_length: 100,
1219            }
1220            .into_response();
1221
1222            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1223            assert_eq!(
1224                response.headers().get("content-range").unwrap(),
1225                "bytes */100"
1226            );
1227            assert!(response.headers().get("x-content-range-bytes").is_none());
1228        }
1229
1230        #[test]
1231        fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1232            let error = ApiError::from(WriteError::PatchLineOutOfRange {
1233                first: 100,
1234                last: 200,
1235                total_lines: 20,
1236                total_bytes: 100,
1237            });
1238
1239            assert!(matches!(
1240                error,
1241                ApiError::LineRangeNotSatisfiable {
1242                    line_total: 20,
1243                    byte_total: 100
1244                }
1245            ));
1246        }
1247    }
1248}