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    /// The change event could not be published after storage had already
31    /// taken the write (D55). Answered like indexer backpressure: 503 with
32    /// `Retry-After`, so the client retries the idempotent write.
33    #[error("change event not published after the object was {after}")]
34    EventPublishFailed {
35        /// What storage had already done.
36        after: notedthat_write::WriteEffect,
37    },
38    /// A reconciliation pass for this knowledge base is already running (D67).
39    #[error(
40        "a reconciliation pass is already running for this knowledge base; retry once last_reconcile advances"
41    )]
42    ReconcileInProgress,
43    /// The storage backend has no on-demand reconciliation pass (D67).
44    #[error("on-demand reconciliation is available on the s3 backend only")]
45    ReconcileUnsupported,
46    /// A `Last-Event-ID` older than what the event log retains.
47    #[error("events after {requested} are no longer retained; oldest retained is {oldest}")]
48    EventsGone {
49        /// The position the subscriber asked to resume after.
50        requested: notedthat_core::EventId,
51        /// The oldest position still held.
52        oldest: notedthat_core::EventId,
53    },
54    /// The event log could not be subscribed to.
55    #[error("event backend unavailable: {message}")]
56    EventsUnavailable {
57        /// What the adapter reported.
58        message: String,
59    },
60    /// A domain error from `notedthat-core`.
61    ///
62    /// Built through the manual `From<CoreError>` rather than `#[from]`, so a
63    /// `CoreError::Storage(BucketNotFound)` — which core's own `From<StorageError>`
64    /// produces — is caught on the way in and answered like every other missing
65    /// bucket, with no bucket name on the wire.
66    #[error(transparent)]
67    Core(CoreError),
68    /// A storage-layer error not otherwise promoted to a top-level variant.
69    ///
70    /// Note: `StorageError::NotModified`, `StorageError::PreconditionFailed`, and
71    /// `StorageError::RangeNotSatisfiable` are promoted via the manual `From<StorageError>`
72    /// impl to top-level `ApiError` variants so that `IntoResponse` can emit the
73    /// RFC-required headers (e.g. `Content-Range: bytes */N` for 416).
74    #[error(transparent)]
75    Storage(StorageError),
76    /// A conditional PUT/DELETE failed because a precondition was not met.
77    ///
78    /// Maps to HTTP 412 Precondition Failed.
79    #[error("precondition failed")]
80    PreconditionFailed,
81    /// The requested byte range could not be satisfied.
82    ///
83    /// `complete_length` is the total object size in bytes.  The `IntoResponse`
84    /// impl emits `Content-Range: bytes */complete_length` per RFC 7233 §4.4.
85    #[error("range not satisfiable")]
86    RangeNotSatisfiable {
87        /// Total object size in bytes.
88        complete_length: u64,
89    },
90    /// The backend returned 304 Not Modified for a conditional GET/HEAD.
91    ///
92    /// Maps to HTTP 304 with no body, as required by RFC 7232 §4.1.
93    #[error("not modified")]
94    NotModified,
95    /// The `Range:` header value could not be parsed per RFC 7233.
96    ///
97    /// Maps to HTTP 400 Bad Request.
98    #[error("malformed range: {0}")]
99    MalformedRange(String),
100    /// A `Range: lines=…` header specified a range that exceeds the object's line count.
101    ///
102    /// Maps to HTTP 416 with `Content-Range: lines */<line_total>` and
103    /// `X-Content-Range-Bytes: */<byte_total>` per the line-range extension.
104    #[error("line range not satisfiable")]
105    LineRangeNotSatisfiable {
106        /// Total line count in the object.
107        line_total: u64,
108        /// Total byte count in the object.
109        byte_total: u64,
110    },
111    /// A single-replace operation found no occurrence of `old_string`.
112    #[error("no match found for old_string")]
113    ReplaceNoMatch,
114    /// A single-replace operation found multiple occurrences of `old_string`.
115    #[error("multiple matches found ({count}); use replace_all to replace them all")]
116    ReplaceAmbiguous {
117        /// Number of occurrences found.
118        count: u64,
119    },
120}
121
122/// Promote specific `StorageError` variants to top-level `ApiError` variants so
123/// that `IntoResponse` can emit the RFC-mandated response headers.
124impl From<CoreError> for ApiError {
125    fn from(e: CoreError) -> Self {
126        match e {
127            CoreError::Storage(StorageError::BucketNotFound { bucket }) => {
128                Self::bucket_not_found(&bucket)
129            }
130            other => Self::Core(other),
131        }
132    }
133}
134
135impl From<StorageError> for ApiError {
136    fn from(e: StorageError) -> Self {
137        match e {
138            StorageError::NotModified => Self::NotModified,
139            StorageError::PreconditionFailed => Self::PreconditionFailed,
140            StorageError::RangeNotSatisfiable { complete_length } => {
141                Self::RangeNotSatisfiable { complete_length }
142            }
143            StorageError::BucketNotFound { bucket } => Self::bucket_not_found(&bucket),
144            other => Self::Storage(other),
145        }
146    }
147}
148
149impl ApiError {
150    /// A declared knowledge base whose bucket is gone: `404 not_found` (D43) with a
151    /// message that names neither the bucket nor the tenant.
152    ///
153    /// `StorageError::BucketNotFound`'s own text is `bucket not found: nt-default-notes`,
154    /// which would tell every caller who reaches storage — an anonymous one on a
155    /// public knowledge base included — how buckets are named, and would make this
156    /// `404` distinguishable from the others on the wire. The bucket name goes to the
157    /// log, where the operator who has to put it back will look; the response stays
158    /// as uninformative as the concealed denial and the undeclared slug.
159    pub(crate) fn bucket_not_found(bucket: &str) -> Self {
160        tracing::warn!(bucket = %bucket, "BUCKET_NOT_FOUND: knowledge base storage is missing");
161        Self::Core(CoreError::NotFound {
162            resource: "knowledge base storage".to_string(),
163        })
164    }
165
166    /// The `message` of the JSON body: the error's own text, except for a
167    /// `BucketNotFound` that was built by hand around either wrapper rather than
168    /// through [`Self::bucket_not_found`] — that one still says only what the
169    /// helper would have said. Belt and braces: every `From` funnels the variant
170    /// through the helper, and this keeps a future `ApiError::Core(…)` literal
171    /// from undoing it.
172    fn message(&self) -> String {
173        match self {
174            Self::Core(CoreError::Storage(StorageError::BucketNotFound { bucket }))
175            | Self::Storage(StorageError::BucketNotFound { bucket }) => {
176                tracing::warn!(bucket = %bucket, "BUCKET_NOT_FOUND: knowledge base storage is missing");
177                "not found: knowledge base storage".to_string()
178            }
179            other => other.to_string(),
180        }
181    }
182}
183
184impl From<notedthat_write::WriteError> for ApiError {
185    fn from(e: notedthat_write::WriteError) -> Self {
186        match e {
187            notedthat_write::WriteError::Storage(e) => Self::from(e),
188            notedthat_write::WriteError::TooLarge { size, limit }
189            | notedthat_write::WriteError::PatchTooLarge { size, limit } => {
190                Self::Core(CoreError::PayloadTooLarge { size, limit })
191            }
192            notedthat_write::WriteError::Path(e) => Self::Core(e),
193            notedthat_write::WriteError::IndexerBackpressureUpsert => {
194                Self::IndexerBackpressureUpsert
195            }
196            notedthat_write::WriteError::IndexerBackpressureTombstone => {
197                Self::IndexerBackpressureTombstone
198            }
199            notedthat_write::WriteError::EventPublishFailed { after } => {
200                Self::EventPublishFailed { after }
201            }
202            notedthat_write::WriteError::PatchLineOutOfRange {
203                total_lines,
204                total_bytes,
205                ..
206            } => Self::LineRangeNotSatisfiable {
207                line_total: total_lines,
208                byte_total: total_bytes,
209            },
210            notedthat_write::WriteError::PatchInvalidRange { message }
211            | notedthat_write::WriteError::InvalidManifest { message } => {
212                Self::Core(CoreError::InvalidInput { message })
213            }
214            notedthat_write::WriteError::ReplaceNoMatch => Self::ReplaceNoMatch,
215            notedthat_write::WriteError::ReplaceAmbiguous { count } => {
216                Self::ReplaceAmbiguous { count }
217            }
218        }
219    }
220}
221
222/// JSON error response body shape: `{ "error": "code", "message": "...", "request_id": "..." }`.
223#[derive(Serialize)]
224struct ErrorBody<'a> {
225    error: &'a str,
226    message: String,
227    request_id: String,
228}
229
230/// A refusal made by a layer that may run before its surface assigns a
231/// request id, so the id is carried when there is one and omitted when not.
232#[derive(Serialize)]
233struct RefusalBody<'a> {
234    error: &'a str,
235    message: String,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    request_id: Option<String>,
238}
239
240/// A refusal in the D38 envelope, for a request no handler was allowed to
241/// finish (D71).
242///
243/// A `503` carries `Retry-After: 5`, as every other capacity refusal on this
244/// server does: the same request will succeed once load drops. Nothing else
245/// does, since a request that timed out will not get faster by being repeated.
246pub(crate) fn refusal(
247    status: StatusCode,
248    error: &str,
249    message: String,
250    request_id: Option<String>,
251) -> Response {
252    let body = Json(RefusalBody {
253        error,
254        message,
255        request_id,
256    });
257    if status == StatusCode::SERVICE_UNAVAILABLE {
258        (status, [(RETRY_AFTER, "5")], body).into_response()
259    } else {
260        (status, body).into_response()
261    }
262}
263
264#[derive(Serialize)]
265struct ReplaceAmbiguousBody<'a> {
266    error: &'a str,
267    message: String,
268    request_id: String,
269    match_count: u64,
270}
271
272/// An [`ApiError`] paired with a `request_id` string so the JSON body and the
273/// `x-request-id` response header both contain the same value.
274pub struct ApiErrorResponse {
275    /// The underlying error.
276    pub error: ApiError,
277    /// The request ID (from the `x-request-id` header via `tower-http`).
278    pub request_id: String,
279}
280
281impl ApiErrorResponse {
282    /// Build an unauthorized response with the provided request ID.
283    #[must_use]
284    pub fn unauthorized(request_id: String) -> Self {
285        Self {
286            error: ApiError::Unauthorized,
287            request_id,
288        }
289    }
290}
291
292impl ApiError {
293    pub(crate) fn status(&self) -> StatusCode {
294        self.status_and_code().0
295    }
296
297    fn status_and_code(&self) -> (StatusCode, &'static str) {
298        match self {
299            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
300            Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
301            Self::IndexerBackpressureUpsert
302            | Self::IndexerBackpressureTombstone
303            | Self::EventPublishFailed { .. }
304            | Self::EventsUnavailable { .. } => {
305                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
306            }
307            Self::EventsGone { .. } => (StatusCode::GONE, "gone"),
308            Self::ReconcileInProgress => (StatusCode::CONFLICT, "conflict"),
309            Self::Core(CoreError::InvalidInput { .. }) => {
310                (StatusCode::BAD_REQUEST, "invalid_request")
311            }
312            Self::ReconcileUnsupported | Self::Core(CoreError::NotFound { .. }) => {
313                (StatusCode::NOT_FOUND, "not_found")
314            }
315            Self::Core(CoreError::PayloadTooLarge { .. }) => {
316                (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
317            }
318            Self::Core(CoreError::MalformedRange(_)) | Self::MalformedRange(_) => {
319                (StatusCode::BAD_REQUEST, "malformed_range")
320            }
321            Self::LineRangeNotSatisfiable { .. }
322            | Self::Core(CoreError::RangeNotSatisfiable { .. })
323            | Self::RangeNotSatisfiable { .. } => {
324                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
325            }
326            Self::Core(CoreError::NotModified) | Self::NotModified => {
327                (StatusCode::NOT_MODIFIED, "not_modified")
328            }
329            Self::Core(CoreError::PreconditionFailed) | Self::PreconditionFailed => {
330                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
331            }
332            Self::ReplaceNoMatch => (StatusCode::UNPROCESSABLE_ENTITY, "no_match"),
333            Self::ReplaceAmbiguous { .. } => (StatusCode::UNPROCESSABLE_ENTITY, "ambiguous_match"),
334            Self::Core(CoreError::BucketNameTooLong { .. } | CoreError::Config { .. }) => {
335                (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
336            }
337            Self::Core(CoreError::Storage(e)) | Self::Storage(e) => Self::storage_status(e),
338        }
339    }
340
341    fn storage_status(e: &StorageError) -> (StatusCode, &'static str) {
342        match e {
343            StorageError::NotFound { .. } | StorageError::BucketNotFound { .. } => {
344                (StatusCode::NOT_FOUND, "not_found")
345            }
346            StorageError::BackendUnavailable { .. } => {
347                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
348            }
349            StorageError::NotModified => (StatusCode::NOT_MODIFIED, "not_modified"),
350            StorageError::PreconditionFailed => {
351                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
352            }
353            StorageError::RangeNotSatisfiable { .. } => {
354                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
355            }
356            StorageError::Other { .. } => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
357        }
358    }
359
360    /// Extract the `complete_length` for a 416 response, regardless of which
361    /// wrapper the `RangeNotSatisfiable` error arrived in.
362    fn range_not_satisfiable_length(&self) -> Option<u64> {
363        match self {
364            Self::RangeNotSatisfiable { complete_length }
365            | Self::Storage(StorageError::RangeNotSatisfiable { complete_length })
366            | Self::Core(CoreError::RangeNotSatisfiable { complete_length }) => {
367                Some(*complete_length)
368            }
369            _ => None,
370        }
371    }
372
373    /// Return `true` for all variants that map to HTTP 304 (empty body required).
374    fn is_not_modified(&self) -> bool {
375        matches!(
376            self,
377            Self::NotModified
378                | Self::Storage(StorageError::NotModified)
379                | Self::Core(CoreError::NotModified)
380        )
381    }
382}
383
384impl ApiErrorResponse {
385    /// The D38 shape: storage already did the work, a queue behind it did not,
386    /// and the idempotent request should simply be repeated.
387    fn retry_later(request_id: String, message: String) -> Response {
388        let body = ErrorBody {
389            error: "backend_unavailable",
390            message,
391            request_id,
392        };
393        (
394            StatusCode::SERVICE_UNAVAILABLE,
395            [(RETRY_AFTER, "5")],
396            Json(body),
397        )
398            .into_response()
399    }
400}
401
402impl IntoResponse for ApiErrorResponse {
403    fn into_response(self) -> Response {
404        // Line-mode 416: emit Content-Range: lines */<total> + X-Content-Range-Bytes: */<total_bytes>.
405        if let ApiError::LineRangeNotSatisfiable {
406            line_total,
407            byte_total,
408        } = &self.error
409        {
410            return (
411                StatusCode::RANGE_NOT_SATISFIABLE,
412                [
413                    (CONTENT_RANGE, format!("lines */{line_total}")),
414                    (
415                        HeaderName::from_static("x-content-range-bytes"),
416                        format!("*/{byte_total}"),
417                    ),
418                ],
419            )
420                .into_response();
421        }
422
423        // 416 Range Not Satisfiable: RFC 7233 §4.4 requires
424        // `Content-Range: bytes */N` and an empty body.
425        if let Some(complete_length) = self.error.range_not_satisfiable_length() {
426            let content_range = format!("bytes */{complete_length}");
427            return (
428                StatusCode::RANGE_NOT_SATISFIABLE,
429                [(CONTENT_RANGE, content_range)],
430            )
431                .into_response();
432        }
433
434        // 304 Not Modified: RFC 7232 §4.1 forbids a message body.
435        if self.error.is_not_modified() {
436            return StatusCode::NOT_MODIFIED.into_response();
437        }
438
439        match &self.error {
440            ApiError::Unauthorized => {
441                let body = ErrorBody {
442                    error: "unauthorized",
443                    message: "provide a valid Bearer token in the Authorization header".to_string(),
444                    request_id: self.request_id,
445                };
446                return (StatusCode::UNAUTHORIZED, Json(body)).into_response();
447            }
448            ApiError::IndexerBackpressureUpsert => {
449                return Self::retry_later(
450                    self.request_id,
451                    "object stored; indexer queue full — retry to re-enqueue".to_string(),
452                );
453            }
454            ApiError::IndexerBackpressureTombstone => {
455                return Self::retry_later(
456                    self.request_id,
457                    "deleted from storage; retry to clear from search index".to_string(),
458                );
459            }
460            ApiError::EventPublishFailed { after } => {
461                let message = match after {
462                    notedthat_write::WriteEffect::Stored => {
463                        "object stored; change event not published — retry to publish"
464                    }
465                    notedthat_write::WriteEffect::Deleted => {
466                        "deleted from storage; change event not published — retry to publish"
467                    }
468                };
469                return Self::retry_later(self.request_id, message.to_string());
470            }
471            ApiError::EventsUnavailable { message } => {
472                return Self::retry_later(
473                    self.request_id,
474                    format!("event backend unavailable: {message}"),
475                );
476            }
477            ApiError::ReplaceAmbiguous { count } => {
478                let body = ReplaceAmbiguousBody {
479                    error: "ambiguous_match",
480                    message: self.error.to_string(),
481                    request_id: self.request_id,
482                    match_count: *count,
483                };
484                return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
485            }
486            _ => {}
487        }
488
489        // All other variants return a JSON error body.
490        let (status, code) = self.error.status_and_code();
491        let message = self.error.message();
492        let body = ErrorBody {
493            error: code,
494            message,
495            request_id: self.request_id,
496        };
497        (status, Json(body)).into_response()
498    }
499}
500
501impl IntoResponse for ApiError {
502    fn into_response(self) -> Response {
503        ApiErrorResponse {
504            error: self,
505            request_id: "unknown".to_string(),
506        }
507        .into_response()
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use axum::body::{Body, to_bytes};
515    use axum::http::Request;
516    use bytes::Bytes;
517    use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage};
518    use notedthat_indexer::IndexEvent;
519    use notedthat_write::WriteError;
520    use std::collections::BTreeMap;
521    use std::sync::Arc;
522    use tower::util::ServiceExt;
523
524    const KB: &str = "notes";
525    const TOKEN: &str = "test-token-abc";
526
527    fn router() -> axum::Router {
528        router_with_max_patchable_size(16 * 1024 * 1024)
529    }
530
531    fn router_with_max_patchable_size(max_patchable_size: u64) -> axum::Router {
532        let kb = KbSlug::try_new(KB).unwrap();
533        let mut kbs = BTreeMap::new();
534        kbs.insert(KB.to_string(), kb);
535        let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
536        tokio::spawn(async move { while rx.recv().await.is_some() {} });
537
538        crate::router::build_router(crate::state::AppState {
539            storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
540            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
541            kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
542            declared_kbs: Arc::new(kbs),
543            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
544            max_body_size: 16 * 1024 * 1024,
545            max_patchable_size,
546            indexer_tx: (&indexer_tx).into(),
547            searcher: Arc::new(crate::testing::NoopSearcher),
548            events: None,
549            index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
550            readiness: crate::testing::ready_receiver(),
551            reconcile: None,
552        })
553    }
554
555    #[allow(clippy::needless_pass_by_value)]
556    fn router_with_storage_and_indexer(
557        storage: Arc<dyn Storage>,
558        kb: KbSlug,
559        max_patchable_size: u64,
560        indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
561    ) -> axum::Router {
562        let mut kbs = BTreeMap::new();
563        kbs.insert(KB.to_string(), kb);
564        crate::router::build_router(crate::state::AppState {
565            storage,
566            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
567            kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
568            declared_kbs: Arc::new(kbs),
569            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
570            max_body_size: 16 * 1024 * 1024,
571            max_patchable_size,
572            indexer_tx: (&indexer_tx).into(),
573            searcher: Arc::new(crate::testing::NoopSearcher),
574            events: None,
575            index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
576            readiness: crate::testing::ready_receiver(),
577            reconcile: None,
578        })
579    }
580
581    async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
582        let response = router
583            .oneshot(
584                Request::builder()
585                    .method("PUT")
586                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
587                    .header("authorization", format!("Bearer {TOKEN}"))
588                    .header(axum::http::header::CONTENT_TYPE, "text/markdown")
589                    .body(Body::from(Bytes::from_static(body)))
590                    .unwrap(),
591            )
592            .await
593            .unwrap();
594
595        assert_eq!(response.status(), StatusCode::CREATED);
596        response
597            .headers()
598            .get(axum::http::header::ETAG)
599            .unwrap()
600            .to_str()
601            .unwrap()
602            .to_string()
603    }
604
605    async fn get_object(router: axum::Router, path: &str) -> Bytes {
606        let response = router
607            .oneshot(
608                Request::builder()
609                    .method("GET")
610                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
611                    .header("authorization", format!("Bearer {TOKEN}"))
612                    .body(Body::empty())
613                    .unwrap(),
614            )
615            .await
616            .unwrap();
617
618        assert_eq!(response.status(), StatusCode::OK);
619        to_bytes(response.into_body(), usize::MAX).await.unwrap()
620    }
621
622    async fn post_replace(
623        router: axum::Router,
624        path: &str,
625        if_match: &str,
626        body: &'static [u8],
627    ) -> Response {
628        router
629            .oneshot(
630                Request::builder()
631                    .method("POST")
632                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
633                    .header("authorization", format!("Bearer {TOKEN}"))
634                    .header(axum::http::header::CONTENT_TYPE, "application/json")
635                    .header(axum::http::header::IF_MATCH, if_match)
636                    .body(Body::from(Bytes::from_static(body)))
637                    .unwrap(),
638            )
639            .await
640            .unwrap()
641    }
642
643    async fn object_with_etag(
644        storage: &crate::testing::InMemoryStorage,
645        kb: &KbSlug,
646        path: &str,
647        body: &'static [u8],
648    ) -> String {
649        storage
650            .put_object(
651                kb,
652                &ObjectPath::try_from_str(path).unwrap(),
653                Bytes::from_static(body),
654                Some("text/markdown"),
655                ConditionalHeaders::default(),
656            )
657            .await
658            .unwrap()
659            .etag
660            .unwrap()
661    }
662
663    async fn assert_invalid_request_response(response: Response) {
664        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
665        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
666        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
667        assert_eq!(json["error"], "invalid_request");
668    }
669
670    #[tokio::test]
671    async fn test_unauthorized_status_and_body() {
672        let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
673        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
674        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
675        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
676        assert_eq!(json["error"], "unauthorized");
677        assert_eq!(json["request_id"], "req-123");
678    }
679
680    #[tokio::test]
681    async fn test_not_found_status() {
682        let err = ApiError::Core(CoreError::NotFound {
683            resource: "foo".into(),
684        });
685        let resp = ApiErrorResponse {
686            error: err,
687            request_id: "rid".into(),
688        }
689        .into_response();
690        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
691    }
692
693    #[tokio::test]
694    async fn test_payload_too_large_status() {
695        let err = ApiError::Core(CoreError::PayloadTooLarge {
696            size: 20_000_000,
697            limit: 16_777_216,
698        });
699        let resp = ApiErrorResponse {
700            error: err,
701            request_id: "rid".into(),
702        }
703        .into_response();
704        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
705    }
706
707    #[tokio::test]
708    async fn test_request_id_in_body() {
709        let err = ApiError::Core(CoreError::InvalidInput {
710            message: "bad".into(),
711        });
712        let resp = ApiErrorResponse {
713            error: err,
714            request_id: "my-req-id".into(),
715        }
716        .into_response();
717        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
718        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
719        assert_eq!(json["request_id"], "my-req-id");
720    }
721
722    #[tokio::test]
723    async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
724        let resp = ApiErrorResponse {
725            error: ApiError::IndexerBackpressureUpsert,
726            request_id: "rid".to_string(),
727        }
728        .into_response();
729
730        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
731        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
732        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
733        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
734        assert_eq!(json["error"], "backend_unavailable");
735        assert_eq!(
736            json["message"],
737            "object stored; indexer queue full — retry to re-enqueue"
738        );
739        assert_eq!(json["request_id"], "rid");
740    }
741
742    #[tokio::test]
743    async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
744        let resp = ApiErrorResponse {
745            error: ApiError::IndexerBackpressureTombstone,
746            request_id: "rid".to_string(),
747        }
748        .into_response();
749
750        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
751        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
752        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
753        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
754        assert_eq!(json["error"], "backend_unavailable");
755        assert_eq!(
756            json["message"],
757            "deleted from storage; retry to clear from search index"
758        );
759        assert_eq!(json["request_id"], "rid");
760    }
761
762    #[test]
763    fn test_from_write_error_indexer_backpressure() {
764        assert!(matches!(
765            ApiError::from(WriteError::IndexerBackpressureUpsert),
766            ApiError::IndexerBackpressureUpsert
767        ));
768        assert!(matches!(
769            ApiError::from(WriteError::IndexerBackpressureTombstone),
770            ApiError::IndexerBackpressureTombstone
771        ));
772    }
773
774    #[test]
775    fn test_from_write_error_patch_too_large() {
776        let api_err = ApiError::from(WriteError::PatchTooLarge {
777            size: 200 * 1024 * 1024,
778            limit: 100 * 1024 * 1024,
779        });
780
781        let (status, code) = api_err.status_and_code();
782        assert_eq!(status.as_u16(), 413);
783        assert_eq!(code, "payload_too_large");
784    }
785
786    #[test]
787    fn test_from_write_error_patch_line_out_of_range() {
788        let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
789            first: 999,
790            last: 1000,
791            total_lines: 20,
792            total_bytes: 100,
793        });
794
795        let (status, code) = api_err.status_and_code();
796        assert_eq!(status.as_u16(), 416);
797        assert_eq!(code, "range_not_satisfiable");
798    }
799
800    #[test]
801    fn test_from_write_error_patch_invalid_range() {
802        let api_err = ApiError::from(WriteError::PatchInvalidRange {
803            message: "test".into(),
804        });
805
806        let (status, code) = api_err.status_and_code();
807        assert_eq!(status.as_u16(), 400);
808        assert_eq!(code, "invalid_request");
809    }
810
811    /// A manifest the write path refused is the boot's own message, as a `400`.
812    #[test]
813    fn test_from_write_error_invalid_manifest() {
814        let api_err = ApiError::from(WriteError::InvalidManifest {
815            message: "description must be a single line without control characters".into(),
816        });
817
818        let (status, code) = api_err.status_and_code();
819        assert_eq!(status.as_u16(), 400);
820        assert_eq!(code, "invalid_request");
821        assert!(api_err.to_string().contains("description"));
822    }
823
824    /// A second pass while one runs is a conflict, not a queue: the running
825    /// pass is already past keys a later change could touch (D67).
826    #[test]
827    fn a_reconcile_in_progress_is_a_conflict() {
828        let (status, code) = ApiError::ReconcileInProgress.status_and_code();
829        assert_eq!(status.as_u16(), 409);
830        assert_eq!(code, "conflict");
831        assert!(
832            ApiError::ReconcileInProgress
833                .to_string()
834                .contains("last_reconcile")
835        );
836    }
837
838    /// A backend with no on-demand pass has no such route to offer.
839    #[test]
840    fn an_unsupported_reconcile_is_not_found_and_names_the_backend_that_has_one() {
841        let (status, code) = ApiError::ReconcileUnsupported.status_and_code();
842        assert_eq!(status.as_u16(), 404);
843        assert_eq!(code, "not_found");
844        assert!(ApiError::ReconcileUnsupported.to_string().contains("s3"));
845    }
846
847    #[test]
848    fn test_from_write_error_replace_no_match() {
849        let api_err = ApiError::from(WriteError::ReplaceNoMatch);
850
851        let (status, code) = api_err.status_and_code();
852        assert_eq!(status.as_u16(), 422);
853        assert_eq!(code, "no_match");
854    }
855
856    #[test]
857    fn test_from_write_error_replace_ambiguous() {
858        let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
859
860        let (status, code) = api_err.status_and_code();
861        assert_eq!(status.as_u16(), 422);
862        assert_eq!(code, "ambiguous_match");
863    }
864
865    #[tokio::test]
866    async fn test_ambiguous_match_body_includes_match_count() {
867        let resp = ApiErrorResponse {
868            error: ApiError::ReplaceAmbiguous { count: 3 },
869            request_id: "req-1".into(),
870        }
871        .into_response();
872
873        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
874        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
875        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
876        assert_eq!(json["error"], "ambiguous_match");
877        assert_eq!(json["match_count"], 3);
878        assert_eq!(json["request_id"], "req-1");
879    }
880
881    #[tokio::test]
882    async fn test_no_match_body_omits_match_count() {
883        let resp = ApiErrorResponse {
884            error: ApiError::ReplaceNoMatch,
885            request_id: "req-1".into(),
886        }
887        .into_response();
888
889        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
890        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
891        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
892        assert_eq!(json["error"], "no_match");
893        assert!(json.get("match_count").is_none());
894    }
895
896    #[tokio::test]
897    async fn replace_missing_if_match_returns_400_invalid_request() {
898        let response = router()
899            .oneshot(
900                Request::builder()
901                    .method("POST")
902                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
903                    .header("authorization", format!("Bearer {TOKEN}"))
904                    .header(axum::http::header::CONTENT_TYPE, "application/json")
905                    .body(Body::from(Bytes::from_static(
906                        br#"{"old_string":"x","new_string":"y"}"#,
907                    )))
908                    .unwrap(),
909            )
910            .await
911            .unwrap();
912
913        assert_invalid_request_response(response).await;
914    }
915
916    #[tokio::test]
917    async fn replace_if_match_star_returns_400() {
918        let response = router()
919            .oneshot(
920                Request::builder()
921                    .method("POST")
922                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
923                    .header("authorization", format!("Bearer {TOKEN}"))
924                    .header(axum::http::header::CONTENT_TYPE, "application/json")
925                    .header(axum::http::header::IF_MATCH, "*")
926                    .body(Body::from(Bytes::from_static(
927                        br#"{"old_string":"x","new_string":"y"}"#,
928                    )))
929                    .unwrap(),
930            )
931            .await
932            .unwrap();
933
934        assert_invalid_request_response(response).await;
935    }
936
937    #[tokio::test]
938    async fn replace_multi_value_if_match_returns_400() {
939        let response = router()
940            .oneshot(
941                Request::builder()
942                    .method("POST")
943                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
944                    .header("authorization", format!("Bearer {TOKEN}"))
945                    .header(axum::http::header::CONTENT_TYPE, "application/json")
946                    .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
947                    .body(Body::from(Bytes::from_static(
948                        br#"{"old_string":"x","new_string":"y"}"#,
949                    )))
950                    .unwrap(),
951            )
952            .await
953            .unwrap();
954
955        assert_invalid_request_response(response).await;
956    }
957
958    #[tokio::test]
959    async fn replace_malformed_json_body_returns_400() {
960        let response = router()
961            .oneshot(
962                Request::builder()
963                    .method("POST")
964                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
965                    .header("authorization", format!("Bearer {TOKEN}"))
966                    .header(axum::http::header::CONTENT_TYPE, "application/json")
967                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
968                    .body(Body::from(Bytes::from_static(b"{invalid json")))
969                    .unwrap(),
970            )
971            .await
972            .unwrap();
973
974        assert_invalid_request_response(response).await;
975    }
976
977    #[tokio::test]
978    async fn replace_missing_old_string_field_returns_400() {
979        let response = router()
980            .oneshot(
981                Request::builder()
982                    .method("POST")
983                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
984                    .header("authorization", format!("Bearer {TOKEN}"))
985                    .header(axum::http::header::CONTENT_TYPE, "application/json")
986                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
987                    .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
988                    .unwrap(),
989            )
990            .await
991            .unwrap();
992
993        assert_invalid_request_response(response).await;
994    }
995
996    #[tokio::test]
997    async fn replace_empty_old_string_returns_400() {
998        let response = router()
999            .oneshot(
1000                Request::builder()
1001                    .method("POST")
1002                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
1003                    .header("authorization", format!("Bearer {TOKEN}"))
1004                    .header(axum::http::header::CONTENT_TYPE, "application/json")
1005                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
1006                    .body(Body::from(Bytes::from_static(
1007                        br#"{"old_string":"","new_string":"y"}"#,
1008                    )))
1009                    .unwrap(),
1010            )
1011            .await
1012            .unwrap();
1013
1014        assert_invalid_request_response(response).await;
1015    }
1016
1017    #[tokio::test]
1018    async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
1019        let router = router();
1020        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
1021
1022        let response = post_replace(
1023            router.clone(),
1024            "hello.md",
1025            &etag,
1026            br#"{"old_string":"world","new_string":"planet"}"#,
1027        )
1028        .await;
1029
1030        assert_eq!(response.status(), StatusCode::OK);
1031        assert!(response.headers().get(axum::http::header::ETAG).is_some());
1032        assert_eq!(
1033            response.headers().get("content-location").unwrap(),
1034            "/api/v1/knowledgebases/notes/hello.md"
1035        );
1036        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1037        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1038        assert!(json["etag"].as_str().is_some());
1039        assert_eq!(json["match_count"], 1);
1040        assert_eq!(json["total_bytes"], 12);
1041        assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
1042    }
1043
1044    #[tokio::test]
1045    async fn replace_no_match_returns_422_no_match() {
1046        let router = router();
1047        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
1048
1049        let response = post_replace(
1050            router.clone(),
1051            "hello.md",
1052            &etag,
1053            br#"{"old_string":"nonexistent","new_string":"x"}"#,
1054        )
1055        .await;
1056
1057        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1058        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1059        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1060        assert_eq!(json["error"], "no_match");
1061        assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
1062    }
1063
1064    #[tokio::test]
1065    async fn replace_ambiguous_returns_422_with_match_count() {
1066        let router = router();
1067        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1068
1069        let response = post_replace(
1070            router.clone(),
1071            "hello.md",
1072            &etag,
1073            br#"{"old_string":"a","new_string":"Z"}"#,
1074        )
1075        .await;
1076
1077        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1078        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1079        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1080        assert_eq!(json["error"], "ambiguous_match");
1081        assert_eq!(json["match_count"], 2);
1082        assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
1083    }
1084
1085    #[tokio::test]
1086    async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
1087        let router = router();
1088        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1089
1090        let response = post_replace(
1091            router.clone(),
1092            "hello.md",
1093            &etag,
1094            br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
1095        )
1096        .await;
1097
1098        assert_eq!(response.status(), StatusCode::OK);
1099        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1100        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1101        assert_eq!(json["match_count"], 2);
1102        assert_eq!(json["total_bytes"], 5);
1103        assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
1104    }
1105
1106    #[tokio::test]
1107    async fn replace_stale_etag_returns_412() {
1108        let router = router();
1109        put_object(router.clone(), "hello.md", b"hello world").await;
1110
1111        let response = post_replace(
1112            router,
1113            "hello.md",
1114            "\"stale\"",
1115            br#"{"old_string":"world","new_string":"planet"}"#,
1116        )
1117        .await;
1118
1119        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
1120    }
1121
1122    #[tokio::test]
1123    async fn replace_post_splice_size_over_cap_returns_413() {
1124        let router = router_with_max_patchable_size(20);
1125        let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
1126
1127        let response = post_replace(
1128            router,
1129            "hello.md",
1130            &etag,
1131            br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
1132        )
1133        .await;
1134
1135        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
1136        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1137        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1138        assert_eq!(json["error"], "payload_too_large");
1139    }
1140
1141    #[tokio::test]
1142    async fn replace_indexer_backpressure_returns_503_with_retry_after() {
1143        let kb = KbSlug::try_new(KB).unwrap();
1144        let storage = crate::testing::InMemoryStorage::with_kbs([&kb]);
1145        let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
1146        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1147        indexer_tx
1148            .try_send(IndexEvent::Upsert {
1149                kb: kb.clone(),
1150                object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1151                etag: "queued".to_string(),
1152                mtime: 0,
1153            })
1154            .unwrap();
1155        let router =
1156            router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
1157
1158        let response = post_replace(
1159            router,
1160            "hello.md",
1161            &etag,
1162            br#"{"old_string":"world","new_string":"planet"}"#,
1163        )
1164        .await;
1165
1166        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1167        assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1168        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1169        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1170        assert_eq!(json["error"], "backend_unavailable");
1171    }
1172
1173    #[tokio::test]
1174    async fn test_precondition_failed_body_shape_unchanged() {
1175        let resp = ApiErrorResponse {
1176            error: ApiError::PreconditionFailed,
1177            request_id: "req-1".into(),
1178        }
1179        .into_response();
1180
1181        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1182        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1183        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1184        let object = json.as_object().unwrap();
1185        assert_eq!(object.len(), 3);
1186        assert!(object.contains_key("error"));
1187        assert!(object.contains_key("message"));
1188        assert!(object.contains_key("request_id"));
1189    }
1190
1191    // ─── New variant tests ────────────────────────────────────────────────────
1192
1193    #[tokio::test]
1194    async fn test_precondition_failed_412() {
1195        let resp = ApiError::PreconditionFailed.into_response();
1196        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1197    }
1198
1199    #[tokio::test]
1200    async fn test_not_modified_304_empty_body() {
1201        let resp = ApiError::NotModified.into_response();
1202        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1203        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1204        assert!(body.is_empty(), "304 must have an empty body");
1205    }
1206
1207    #[tokio::test]
1208    async fn test_malformed_range_400() {
1209        let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1210        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1211    }
1212
1213    /// RFC 7233 §4.4: a 416 response MUST include `Content-Range: bytes */N`.
1214    #[tokio::test]
1215    async fn test_range_not_satisfiable_416_content_range_header() {
1216        let resp = ApiError::RangeNotSatisfiable {
1217            complete_length: 100,
1218        }
1219        .into_response();
1220        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1221        let cr = resp
1222            .headers()
1223            .get("content-range")
1224            .expect("content-range header must be present on 416");
1225        assert_eq!(cr.to_str().unwrap(), "bytes */100");
1226        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1227        assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1228    }
1229
1230    /// Same 416 check via the `ApiError::Storage(...)` wrapper (e.g. from router.rs
1231    /// call sites that use explicit wrapping instead of `Into::into`).
1232    #[tokio::test]
1233    async fn test_storage_range_not_satisfiable_416_content_range_header() {
1234        let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1235            complete_length: 42,
1236        })
1237        .into_response();
1238        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1239        let cr = resp
1240            .headers()
1241            .get("content-range")
1242            .expect("content-range header must be present on 416");
1243        assert_eq!(cr.to_str().unwrap(), "bytes */42");
1244    }
1245
1246    /// 304 from a wrapped `StorageError::NotModified` (explicit wrapping in router.rs).
1247    #[tokio::test]
1248    async fn test_storage_not_modified_304_empty_body() {
1249        let resp = ApiError::Storage(StorageError::NotModified).into_response();
1250        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1251        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1252        assert!(body.is_empty(), "304 must have an empty body");
1253    }
1254
1255    /// `From<StorageError>` promotes `NotModified` to `ApiError::NotModified`.
1256    #[test]
1257    fn test_from_storage_error_not_modified() {
1258        let api_err = ApiError::from(StorageError::NotModified);
1259        assert!(matches!(api_err, ApiError::NotModified));
1260    }
1261
1262    /// `From<StorageError>` promotes `PreconditionFailed` to `ApiError::PreconditionFailed`.
1263    #[test]
1264    fn test_from_storage_error_precondition_failed() {
1265        let api_err = ApiError::from(StorageError::PreconditionFailed);
1266        assert!(matches!(api_err, ApiError::PreconditionFailed));
1267    }
1268
1269    /// `From<StorageError>` promotes `RangeNotSatisfiable` with the correct length.
1270    #[test]
1271    fn test_from_storage_error_range_not_satisfiable() {
1272        let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1273            complete_length: 999,
1274        });
1275        assert!(
1276            matches!(
1277                api_err,
1278                ApiError::RangeNotSatisfiable {
1279                    complete_length: 999
1280                }
1281            ),
1282            "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1283        );
1284    }
1285
1286    /// Other `StorageError` variants must still be wrapped in `ApiError::Storage`.
1287    #[test]
1288    fn test_from_storage_error_other_wrapped() {
1289        let api_err = ApiError::from(StorageError::NotFound {
1290            key: "foo".to_string(),
1291        });
1292        assert!(matches!(api_err, ApiError::Storage(_)));
1293    }
1294
1295    /// Core's own `From<StorageError>` yields `CoreError::Storage(BucketNotFound)`;
1296    /// one `.map_err(CoreError::from)` in a handler must not reopen the bucket name
1297    /// that `From<StorageError>` closes.
1298    #[test]
1299    fn test_from_core_error_bucket_not_found_is_sanitised() {
1300        let api_err = ApiError::from(CoreError::from(StorageError::BucketNotFound {
1301            bucket: "nt-default-notes".to_string(),
1302        }));
1303        assert!(
1304            matches!(&api_err, ApiError::Core(CoreError::NotFound { resource }) if resource == "knowledge base storage"),
1305            "{api_err:?}"
1306        );
1307        // Every other core error is wrapped as it is.
1308        let api_err = ApiError::from(CoreError::from(StorageError::NotFound {
1309            key: "a.md".to_string(),
1310        }));
1311        assert!(matches!(
1312            api_err,
1313            ApiError::Core(CoreError::Storage(StorageError::NotFound { .. }))
1314        ));
1315    }
1316
1317    /// Even a `BucketNotFound` built by hand around either wrapper renders the
1318    /// fixed message: `404 not_found`, and neither the bucket nor the tenant naming
1319    /// scheme in the body.
1320    #[tokio::test]
1321    async fn a_bucket_not_found_reached_through_core_error_names_no_bucket() {
1322        for error in [
1323            ApiError::Core(CoreError::Storage(StorageError::BucketNotFound {
1324                bucket: "nt-acme-notes".to_string(),
1325            })),
1326            ApiError::Storage(StorageError::BucketNotFound {
1327                bucket: "nt-acme-notes".to_string(),
1328            }),
1329        ] {
1330            let resp = ApiErrorResponse {
1331                error,
1332                request_id: "req-1".into(),
1333            }
1334            .into_response();
1335            assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1336            let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1337            let text = String::from_utf8(body.to_vec()).unwrap();
1338            let json: serde_json::Value = serde_json::from_str(&text).unwrap();
1339            assert_eq!(json["error"], "not_found");
1340            assert_eq!(json["message"], "not found: knowledge base storage");
1341            assert!(!text.contains("nt-"), "{text}");
1342            assert!(!text.contains("bucket"), "{text}");
1343        }
1344    }
1345
1346    mod line_range_error {
1347        use super::*;
1348        use axum::body::Body;
1349        use axum::http::Request;
1350        use bytes::Bytes;
1351        use notedthat_core::KbSlug;
1352        use std::collections::BTreeMap;
1353        use std::sync::Arc;
1354        use tower::util::ServiceExt;
1355
1356        const KB: &str = "notes";
1357        const TOKEN: &str = "test-token-abc";
1358
1359        fn twenty_line_markdown() -> String {
1360            let mut body = String::new();
1361            for line in 1..=20 {
1362                std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1363            }
1364            body
1365        }
1366
1367        fn router() -> axum::Router {
1368            let kb = KbSlug::try_new(KB).unwrap();
1369            let mut kbs = BTreeMap::new();
1370            kbs.insert(KB.to_string(), kb);
1371            let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1372            tokio::spawn(async move { while rx.recv().await.is_some() {} });
1373
1374            crate::router::build_router(crate::state::AppState {
1375                storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
1376                access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1377                kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
1378                declared_kbs: Arc::new(kbs),
1379                authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1380                max_body_size: 16 * 1024 * 1024,
1381                max_patchable_size: 16 * 1024 * 1024,
1382                indexer_tx: (&indexer_tx).into(),
1383                searcher: Arc::new(crate::testing::NoopSearcher),
1384                events: None,
1385                index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
1386                readiness: crate::testing::ready_receiver(),
1387                reconcile: None,
1388            })
1389        }
1390
1391        async fn put_ranges_md(router: axum::Router) {
1392            let response = router
1393                .oneshot(
1394                    Request::builder()
1395                        .method("PUT")
1396                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1397                        .header("authorization", format!("Bearer {TOKEN}"))
1398                        .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1399                        .body(Body::from(Bytes::from(twenty_line_markdown())))
1400                        .unwrap(),
1401                )
1402                .await
1403                .unwrap();
1404
1405            assert_eq!(response.status(), StatusCode::CREATED);
1406        }
1407
1408        #[tokio::test]
1409        async fn malformed_line_range_returns_json_400() {
1410            let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1411
1412            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1413            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1414            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1415            assert_eq!(json["error"], "malformed_range");
1416        }
1417
1418        #[tokio::test]
1419        async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1420            let response = ApiError::LineRangeNotSatisfiable {
1421                line_total: 20,
1422                byte_total: 100,
1423            }
1424            .into_response();
1425
1426            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1427            assert_eq!(
1428                response.headers().get("content-range").unwrap(),
1429                "lines */20"
1430            );
1431            assert_eq!(
1432                response.headers().get("x-content-range-bytes").unwrap(),
1433                "*/100"
1434            );
1435            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1436            assert!(body.is_empty());
1437        }
1438
1439        #[tokio::test]
1440        async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1441            let router = router();
1442            put_ranges_md(router.clone()).await;
1443
1444            let response = router
1445                .oneshot(
1446                    Request::builder()
1447                        .method("GET")
1448                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1449                        .header("authorization", format!("Bearer {TOKEN}"))
1450                        .header(axum::http::header::RANGE, "lines=100-200")
1451                        .body(Body::empty())
1452                        .unwrap(),
1453                )
1454                .await
1455                .unwrap();
1456
1457            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1458            assert_eq!(
1459                response.headers().get("content-range").unwrap(),
1460                "lines */20"
1461            );
1462            assert_eq!(
1463                response.headers().get("x-content-range-bytes").unwrap(),
1464                "*/160"
1465            );
1466            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1467            assert!(body.is_empty());
1468        }
1469
1470        #[tokio::test]
1471        async fn byte_range_not_satisfiable_omits_line_byte_header() {
1472            let response = ApiError::RangeNotSatisfiable {
1473                complete_length: 100,
1474            }
1475            .into_response();
1476
1477            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1478            assert_eq!(
1479                response.headers().get("content-range").unwrap(),
1480                "bytes */100"
1481            );
1482            assert!(response.headers().get("x-content-range-bytes").is_none());
1483        }
1484
1485        #[test]
1486        fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1487            let error = ApiError::from(WriteError::PatchLineOutOfRange {
1488                first: 100,
1489                last: 200,
1490                total_lines: 20,
1491                total_bytes: 100,
1492            });
1493
1494            assert!(matches!(
1495                error,
1496                ApiError::LineRangeNotSatisfiable {
1497                    line_total: 20,
1498                    byte_total: 100
1499                }
1500            ));
1501        }
1502    }
1503}