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    fn status_and_code(&self) -> (StatusCode, &'static str) {
294        match self {
295            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
296            Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
297            Self::IndexerBackpressureUpsert
298            | Self::IndexerBackpressureTombstone
299            | Self::EventPublishFailed { .. }
300            | Self::EventsUnavailable { .. } => {
301                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
302            }
303            Self::EventsGone { .. } => (StatusCode::GONE, "gone"),
304            Self::ReconcileInProgress => (StatusCode::CONFLICT, "conflict"),
305            Self::Core(CoreError::InvalidInput { .. }) => {
306                (StatusCode::BAD_REQUEST, "invalid_request")
307            }
308            Self::ReconcileUnsupported | Self::Core(CoreError::NotFound { .. }) => {
309                (StatusCode::NOT_FOUND, "not_found")
310            }
311            Self::Core(CoreError::PayloadTooLarge { .. }) => {
312                (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
313            }
314            Self::Core(CoreError::MalformedRange(_)) | Self::MalformedRange(_) => {
315                (StatusCode::BAD_REQUEST, "malformed_range")
316            }
317            Self::LineRangeNotSatisfiable { .. }
318            | Self::Core(CoreError::RangeNotSatisfiable { .. })
319            | Self::RangeNotSatisfiable { .. } => {
320                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
321            }
322            Self::Core(CoreError::NotModified) | Self::NotModified => {
323                (StatusCode::NOT_MODIFIED, "not_modified")
324            }
325            Self::Core(CoreError::PreconditionFailed) | Self::PreconditionFailed => {
326                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
327            }
328            Self::ReplaceNoMatch => (StatusCode::UNPROCESSABLE_ENTITY, "no_match"),
329            Self::ReplaceAmbiguous { .. } => (StatusCode::UNPROCESSABLE_ENTITY, "ambiguous_match"),
330            Self::Core(CoreError::BucketNameTooLong { .. } | CoreError::Config { .. }) => {
331                (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
332            }
333            Self::Core(CoreError::Storage(e)) | Self::Storage(e) => Self::storage_status(e),
334        }
335    }
336
337    fn storage_status(e: &StorageError) -> (StatusCode, &'static str) {
338        match e {
339            StorageError::NotFound { .. } | StorageError::BucketNotFound { .. } => {
340                (StatusCode::NOT_FOUND, "not_found")
341            }
342            StorageError::BackendUnavailable { .. } => {
343                (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
344            }
345            StorageError::NotModified => (StatusCode::NOT_MODIFIED, "not_modified"),
346            StorageError::PreconditionFailed => {
347                (StatusCode::PRECONDITION_FAILED, "precondition_failed")
348            }
349            StorageError::RangeNotSatisfiable { .. } => {
350                (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
351            }
352            StorageError::Other { .. } => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
353        }
354    }
355
356    /// Extract the `complete_length` for a 416 response, regardless of which
357    /// wrapper the `RangeNotSatisfiable` error arrived in.
358    fn range_not_satisfiable_length(&self) -> Option<u64> {
359        match self {
360            Self::RangeNotSatisfiable { complete_length }
361            | Self::Storage(StorageError::RangeNotSatisfiable { complete_length })
362            | Self::Core(CoreError::RangeNotSatisfiable { complete_length }) => {
363                Some(*complete_length)
364            }
365            _ => None,
366        }
367    }
368
369    /// Return `true` for all variants that map to HTTP 304 (empty body required).
370    fn is_not_modified(&self) -> bool {
371        matches!(
372            self,
373            Self::NotModified
374                | Self::Storage(StorageError::NotModified)
375                | Self::Core(CoreError::NotModified)
376        )
377    }
378}
379
380impl ApiErrorResponse {
381    /// The D38 shape: storage already did the work, a queue behind it did not,
382    /// and the idempotent request should simply be repeated.
383    fn retry_later(request_id: String, message: String) -> Response {
384        let body = ErrorBody {
385            error: "backend_unavailable",
386            message,
387            request_id,
388        };
389        (
390            StatusCode::SERVICE_UNAVAILABLE,
391            [(RETRY_AFTER, "5")],
392            Json(body),
393        )
394            .into_response()
395    }
396}
397
398impl IntoResponse for ApiErrorResponse {
399    fn into_response(self) -> Response {
400        // Line-mode 416: emit Content-Range: lines */<total> + X-Content-Range-Bytes: */<total_bytes>.
401        if let ApiError::LineRangeNotSatisfiable {
402            line_total,
403            byte_total,
404        } = &self.error
405        {
406            return (
407                StatusCode::RANGE_NOT_SATISFIABLE,
408                [
409                    (CONTENT_RANGE, format!("lines */{line_total}")),
410                    (
411                        HeaderName::from_static("x-content-range-bytes"),
412                        format!("*/{byte_total}"),
413                    ),
414                ],
415            )
416                .into_response();
417        }
418
419        // 416 Range Not Satisfiable: RFC 7233 §4.4 requires
420        // `Content-Range: bytes */N` and an empty body.
421        if let Some(complete_length) = self.error.range_not_satisfiable_length() {
422            let content_range = format!("bytes */{complete_length}");
423            return (
424                StatusCode::RANGE_NOT_SATISFIABLE,
425                [(CONTENT_RANGE, content_range)],
426            )
427                .into_response();
428        }
429
430        // 304 Not Modified: RFC 7232 §4.1 forbids a message body.
431        if self.error.is_not_modified() {
432            return StatusCode::NOT_MODIFIED.into_response();
433        }
434
435        match &self.error {
436            ApiError::Unauthorized => {
437                let body = ErrorBody {
438                    error: "unauthorized",
439                    message: "provide a valid Bearer token in the Authorization header".to_string(),
440                    request_id: self.request_id,
441                };
442                return (StatusCode::UNAUTHORIZED, Json(body)).into_response();
443            }
444            ApiError::IndexerBackpressureUpsert => {
445                return Self::retry_later(
446                    self.request_id,
447                    "object stored; indexer queue full — retry to re-enqueue".to_string(),
448                );
449            }
450            ApiError::IndexerBackpressureTombstone => {
451                return Self::retry_later(
452                    self.request_id,
453                    "deleted from storage; retry to clear from search index".to_string(),
454                );
455            }
456            ApiError::EventPublishFailed { after } => {
457                let message = match after {
458                    notedthat_write::WriteEffect::Stored => {
459                        "object stored; change event not published — retry to publish"
460                    }
461                    notedthat_write::WriteEffect::Deleted => {
462                        "deleted from storage; change event not published — retry to publish"
463                    }
464                };
465                return Self::retry_later(self.request_id, message.to_string());
466            }
467            ApiError::EventsUnavailable { message } => {
468                return Self::retry_later(
469                    self.request_id,
470                    format!("event backend unavailable: {message}"),
471                );
472            }
473            ApiError::ReplaceAmbiguous { count } => {
474                let body = ReplaceAmbiguousBody {
475                    error: "ambiguous_match",
476                    message: self.error.to_string(),
477                    request_id: self.request_id,
478                    match_count: *count,
479                };
480                return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
481            }
482            _ => {}
483        }
484
485        // All other variants return a JSON error body.
486        let (status, code) = self.error.status_and_code();
487        let message = self.error.message();
488        let body = ErrorBody {
489            error: code,
490            message,
491            request_id: self.request_id,
492        };
493        (status, Json(body)).into_response()
494    }
495}
496
497impl IntoResponse for ApiError {
498    fn into_response(self) -> Response {
499        ApiErrorResponse {
500            error: self,
501            request_id: "unknown".to_string(),
502        }
503        .into_response()
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use axum::body::{Body, to_bytes};
511    use axum::http::Request;
512    use bytes::Bytes;
513    use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage};
514    use notedthat_indexer::IndexEvent;
515    use notedthat_write::WriteError;
516    use std::collections::BTreeMap;
517    use std::sync::Arc;
518    use tower::util::ServiceExt;
519
520    const KB: &str = "notes";
521    const TOKEN: &str = "test-token-abc";
522
523    fn router() -> axum::Router {
524        router_with_max_patchable_size(16 * 1024 * 1024)
525    }
526
527    fn router_with_max_patchable_size(max_patchable_size: u64) -> axum::Router {
528        let kb = KbSlug::try_new(KB).unwrap();
529        let mut kbs = BTreeMap::new();
530        kbs.insert(KB.to_string(), kb);
531        let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
532        tokio::spawn(async move { while rx.recv().await.is_some() {} });
533
534        crate::router::build_router(crate::state::AppState {
535            storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
536            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
537            kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
538            declared_kbs: Arc::new(kbs),
539            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
540            max_body_size: 16 * 1024 * 1024,
541            max_patchable_size,
542            indexer_tx,
543            searcher: Arc::new(crate::testing::NoopSearcher),
544            events: None,
545            index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
546            readiness: crate::testing::ready_receiver(),
547            reconcile: None,
548        })
549    }
550
551    fn router_with_storage_and_indexer(
552        storage: Arc<dyn Storage>,
553        kb: KbSlug,
554        max_patchable_size: u64,
555        indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
556    ) -> axum::Router {
557        let mut kbs = BTreeMap::new();
558        kbs.insert(KB.to_string(), kb);
559        crate::router::build_router(crate::state::AppState {
560            storage,
561            access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
562            kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
563            declared_kbs: Arc::new(kbs),
564            authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
565            max_body_size: 16 * 1024 * 1024,
566            max_patchable_size,
567            indexer_tx,
568            searcher: Arc::new(crate::testing::NoopSearcher),
569            events: None,
570            index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
571            readiness: crate::testing::ready_receiver(),
572            reconcile: None,
573        })
574    }
575
576    async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
577        let response = router
578            .oneshot(
579                Request::builder()
580                    .method("PUT")
581                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
582                    .header("authorization", format!("Bearer {TOKEN}"))
583                    .header(axum::http::header::CONTENT_TYPE, "text/markdown")
584                    .body(Body::from(Bytes::from_static(body)))
585                    .unwrap(),
586            )
587            .await
588            .unwrap();
589
590        assert_eq!(response.status(), StatusCode::CREATED);
591        response
592            .headers()
593            .get(axum::http::header::ETAG)
594            .unwrap()
595            .to_str()
596            .unwrap()
597            .to_string()
598    }
599
600    async fn get_object(router: axum::Router, path: &str) -> Bytes {
601        let response = router
602            .oneshot(
603                Request::builder()
604                    .method("GET")
605                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
606                    .header("authorization", format!("Bearer {TOKEN}"))
607                    .body(Body::empty())
608                    .unwrap(),
609            )
610            .await
611            .unwrap();
612
613        assert_eq!(response.status(), StatusCode::OK);
614        to_bytes(response.into_body(), usize::MAX).await.unwrap()
615    }
616
617    async fn post_replace(
618        router: axum::Router,
619        path: &str,
620        if_match: &str,
621        body: &'static [u8],
622    ) -> Response {
623        router
624            .oneshot(
625                Request::builder()
626                    .method("POST")
627                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
628                    .header("authorization", format!("Bearer {TOKEN}"))
629                    .header(axum::http::header::CONTENT_TYPE, "application/json")
630                    .header(axum::http::header::IF_MATCH, if_match)
631                    .body(Body::from(Bytes::from_static(body)))
632                    .unwrap(),
633            )
634            .await
635            .unwrap()
636    }
637
638    async fn object_with_etag(
639        storage: &crate::testing::InMemoryStorage,
640        kb: &KbSlug,
641        path: &str,
642        body: &'static [u8],
643    ) -> String {
644        storage
645            .put_object(
646                kb,
647                &ObjectPath::try_from_str(path).unwrap(),
648                Bytes::from_static(body),
649                Some("text/markdown"),
650                ConditionalHeaders::default(),
651            )
652            .await
653            .unwrap()
654            .etag
655            .unwrap()
656    }
657
658    async fn assert_invalid_request_response(response: Response) {
659        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
660        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
661        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
662        assert_eq!(json["error"], "invalid_request");
663    }
664
665    #[tokio::test]
666    async fn test_unauthorized_status_and_body() {
667        let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
668        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
669        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
670        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
671        assert_eq!(json["error"], "unauthorized");
672        assert_eq!(json["request_id"], "req-123");
673    }
674
675    #[tokio::test]
676    async fn test_not_found_status() {
677        let err = ApiError::Core(CoreError::NotFound {
678            resource: "foo".into(),
679        });
680        let resp = ApiErrorResponse {
681            error: err,
682            request_id: "rid".into(),
683        }
684        .into_response();
685        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
686    }
687
688    #[tokio::test]
689    async fn test_payload_too_large_status() {
690        let err = ApiError::Core(CoreError::PayloadTooLarge {
691            size: 20_000_000,
692            limit: 16_777_216,
693        });
694        let resp = ApiErrorResponse {
695            error: err,
696            request_id: "rid".into(),
697        }
698        .into_response();
699        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
700    }
701
702    #[tokio::test]
703    async fn test_request_id_in_body() {
704        let err = ApiError::Core(CoreError::InvalidInput {
705            message: "bad".into(),
706        });
707        let resp = ApiErrorResponse {
708            error: err,
709            request_id: "my-req-id".into(),
710        }
711        .into_response();
712        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
713        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
714        assert_eq!(json["request_id"], "my-req-id");
715    }
716
717    #[tokio::test]
718    async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
719        let resp = ApiErrorResponse {
720            error: ApiError::IndexerBackpressureUpsert,
721            request_id: "rid".to_string(),
722        }
723        .into_response();
724
725        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
726        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
727        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
728        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
729        assert_eq!(json["error"], "backend_unavailable");
730        assert_eq!(
731            json["message"],
732            "object stored; indexer queue full — retry to re-enqueue"
733        );
734        assert_eq!(json["request_id"], "rid");
735    }
736
737    #[tokio::test]
738    async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
739        let resp = ApiErrorResponse {
740            error: ApiError::IndexerBackpressureTombstone,
741            request_id: "rid".to_string(),
742        }
743        .into_response();
744
745        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
746        assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
747        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
748        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
749        assert_eq!(json["error"], "backend_unavailable");
750        assert_eq!(
751            json["message"],
752            "deleted from storage; retry to clear from search index"
753        );
754        assert_eq!(json["request_id"], "rid");
755    }
756
757    #[test]
758    fn test_from_write_error_indexer_backpressure() {
759        assert!(matches!(
760            ApiError::from(WriteError::IndexerBackpressureUpsert),
761            ApiError::IndexerBackpressureUpsert
762        ));
763        assert!(matches!(
764            ApiError::from(WriteError::IndexerBackpressureTombstone),
765            ApiError::IndexerBackpressureTombstone
766        ));
767    }
768
769    #[test]
770    fn test_from_write_error_patch_too_large() {
771        let api_err = ApiError::from(WriteError::PatchTooLarge {
772            size: 200 * 1024 * 1024,
773            limit: 100 * 1024 * 1024,
774        });
775
776        let (status, code) = api_err.status_and_code();
777        assert_eq!(status.as_u16(), 413);
778        assert_eq!(code, "payload_too_large");
779    }
780
781    #[test]
782    fn test_from_write_error_patch_line_out_of_range() {
783        let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
784            first: 999,
785            last: 1000,
786            total_lines: 20,
787            total_bytes: 100,
788        });
789
790        let (status, code) = api_err.status_and_code();
791        assert_eq!(status.as_u16(), 416);
792        assert_eq!(code, "range_not_satisfiable");
793    }
794
795    #[test]
796    fn test_from_write_error_patch_invalid_range() {
797        let api_err = ApiError::from(WriteError::PatchInvalidRange {
798            message: "test".into(),
799        });
800
801        let (status, code) = api_err.status_and_code();
802        assert_eq!(status.as_u16(), 400);
803        assert_eq!(code, "invalid_request");
804    }
805
806    /// A manifest the write path refused is the boot's own message, as a `400`.
807    #[test]
808    fn test_from_write_error_invalid_manifest() {
809        let api_err = ApiError::from(WriteError::InvalidManifest {
810            message: "description must be a single line without control characters".into(),
811        });
812
813        let (status, code) = api_err.status_and_code();
814        assert_eq!(status.as_u16(), 400);
815        assert_eq!(code, "invalid_request");
816        assert!(api_err.to_string().contains("description"));
817    }
818
819    /// A second pass while one runs is a conflict, not a queue: the running
820    /// pass is already past keys a later change could touch (D67).
821    #[test]
822    fn a_reconcile_in_progress_is_a_conflict() {
823        let (status, code) = ApiError::ReconcileInProgress.status_and_code();
824        assert_eq!(status.as_u16(), 409);
825        assert_eq!(code, "conflict");
826        assert!(
827            ApiError::ReconcileInProgress
828                .to_string()
829                .contains("last_reconcile")
830        );
831    }
832
833    /// A backend with no on-demand pass has no such route to offer.
834    #[test]
835    fn an_unsupported_reconcile_is_not_found_and_names_the_backend_that_has_one() {
836        let (status, code) = ApiError::ReconcileUnsupported.status_and_code();
837        assert_eq!(status.as_u16(), 404);
838        assert_eq!(code, "not_found");
839        assert!(ApiError::ReconcileUnsupported.to_string().contains("s3"));
840    }
841
842    #[test]
843    fn test_from_write_error_replace_no_match() {
844        let api_err = ApiError::from(WriteError::ReplaceNoMatch);
845
846        let (status, code) = api_err.status_and_code();
847        assert_eq!(status.as_u16(), 422);
848        assert_eq!(code, "no_match");
849    }
850
851    #[test]
852    fn test_from_write_error_replace_ambiguous() {
853        let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
854
855        let (status, code) = api_err.status_and_code();
856        assert_eq!(status.as_u16(), 422);
857        assert_eq!(code, "ambiguous_match");
858    }
859
860    #[tokio::test]
861    async fn test_ambiguous_match_body_includes_match_count() {
862        let resp = ApiErrorResponse {
863            error: ApiError::ReplaceAmbiguous { count: 3 },
864            request_id: "req-1".into(),
865        }
866        .into_response();
867
868        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
869        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
870        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
871        assert_eq!(json["error"], "ambiguous_match");
872        assert_eq!(json["match_count"], 3);
873        assert_eq!(json["request_id"], "req-1");
874    }
875
876    #[tokio::test]
877    async fn test_no_match_body_omits_match_count() {
878        let resp = ApiErrorResponse {
879            error: ApiError::ReplaceNoMatch,
880            request_id: "req-1".into(),
881        }
882        .into_response();
883
884        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
885        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
886        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
887        assert_eq!(json["error"], "no_match");
888        assert!(json.get("match_count").is_none());
889    }
890
891    #[tokio::test]
892    async fn replace_missing_if_match_returns_400_invalid_request() {
893        let response = router()
894            .oneshot(
895                Request::builder()
896                    .method("POST")
897                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
898                    .header("authorization", format!("Bearer {TOKEN}"))
899                    .header(axum::http::header::CONTENT_TYPE, "application/json")
900                    .body(Body::from(Bytes::from_static(
901                        br#"{"old_string":"x","new_string":"y"}"#,
902                    )))
903                    .unwrap(),
904            )
905            .await
906            .unwrap();
907
908        assert_invalid_request_response(response).await;
909    }
910
911    #[tokio::test]
912    async fn replace_if_match_star_returns_400() {
913        let response = router()
914            .oneshot(
915                Request::builder()
916                    .method("POST")
917                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
918                    .header("authorization", format!("Bearer {TOKEN}"))
919                    .header(axum::http::header::CONTENT_TYPE, "application/json")
920                    .header(axum::http::header::IF_MATCH, "*")
921                    .body(Body::from(Bytes::from_static(
922                        br#"{"old_string":"x","new_string":"y"}"#,
923                    )))
924                    .unwrap(),
925            )
926            .await
927            .unwrap();
928
929        assert_invalid_request_response(response).await;
930    }
931
932    #[tokio::test]
933    async fn replace_multi_value_if_match_returns_400() {
934        let response = router()
935            .oneshot(
936                Request::builder()
937                    .method("POST")
938                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
939                    .header("authorization", format!("Bearer {TOKEN}"))
940                    .header(axum::http::header::CONTENT_TYPE, "application/json")
941                    .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
942                    .body(Body::from(Bytes::from_static(
943                        br#"{"old_string":"x","new_string":"y"}"#,
944                    )))
945                    .unwrap(),
946            )
947            .await
948            .unwrap();
949
950        assert_invalid_request_response(response).await;
951    }
952
953    #[tokio::test]
954    async fn replace_malformed_json_body_returns_400() {
955        let response = router()
956            .oneshot(
957                Request::builder()
958                    .method("POST")
959                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
960                    .header("authorization", format!("Bearer {TOKEN}"))
961                    .header(axum::http::header::CONTENT_TYPE, "application/json")
962                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
963                    .body(Body::from(Bytes::from_static(b"{invalid json")))
964                    .unwrap(),
965            )
966            .await
967            .unwrap();
968
969        assert_invalid_request_response(response).await;
970    }
971
972    #[tokio::test]
973    async fn replace_missing_old_string_field_returns_400() {
974        let response = router()
975            .oneshot(
976                Request::builder()
977                    .method("POST")
978                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
979                    .header("authorization", format!("Bearer {TOKEN}"))
980                    .header(axum::http::header::CONTENT_TYPE, "application/json")
981                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
982                    .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
983                    .unwrap(),
984            )
985            .await
986            .unwrap();
987
988        assert_invalid_request_response(response).await;
989    }
990
991    #[tokio::test]
992    async fn replace_empty_old_string_returns_400() {
993        let response = router()
994            .oneshot(
995                Request::builder()
996                    .method("POST")
997                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
998                    .header("authorization", format!("Bearer {TOKEN}"))
999                    .header(axum::http::header::CONTENT_TYPE, "application/json")
1000                    .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
1001                    .body(Body::from(Bytes::from_static(
1002                        br#"{"old_string":"","new_string":"y"}"#,
1003                    )))
1004                    .unwrap(),
1005            )
1006            .await
1007            .unwrap();
1008
1009        assert_invalid_request_response(response).await;
1010    }
1011
1012    #[tokio::test]
1013    async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
1014        let router = router();
1015        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
1016
1017        let response = post_replace(
1018            router.clone(),
1019            "hello.md",
1020            &etag,
1021            br#"{"old_string":"world","new_string":"planet"}"#,
1022        )
1023        .await;
1024
1025        assert_eq!(response.status(), StatusCode::OK);
1026        assert!(response.headers().get(axum::http::header::ETAG).is_some());
1027        assert_eq!(
1028            response.headers().get("content-location").unwrap(),
1029            "/api/v1/knowledgebases/notes/hello.md"
1030        );
1031        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1032        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1033        assert!(json["etag"].as_str().is_some());
1034        assert_eq!(json["match_count"], 1);
1035        assert_eq!(json["total_bytes"], 12);
1036        assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
1037    }
1038
1039    #[tokio::test]
1040    async fn replace_no_match_returns_422_no_match() {
1041        let router = router();
1042        let etag = put_object(router.clone(), "hello.md", b"hello world").await;
1043
1044        let response = post_replace(
1045            router.clone(),
1046            "hello.md",
1047            &etag,
1048            br#"{"old_string":"nonexistent","new_string":"x"}"#,
1049        )
1050        .await;
1051
1052        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1053        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1054        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1055        assert_eq!(json["error"], "no_match");
1056        assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
1057    }
1058
1059    #[tokio::test]
1060    async fn replace_ambiguous_returns_422_with_match_count() {
1061        let router = router();
1062        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1063
1064        let response = post_replace(
1065            router.clone(),
1066            "hello.md",
1067            &etag,
1068            br#"{"old_string":"a","new_string":"Z"}"#,
1069        )
1070        .await;
1071
1072        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1073        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1074        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1075        assert_eq!(json["error"], "ambiguous_match");
1076        assert_eq!(json["match_count"], 2);
1077        assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
1078    }
1079
1080    #[tokio::test]
1081    async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
1082        let router = router();
1083        let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1084
1085        let response = post_replace(
1086            router.clone(),
1087            "hello.md",
1088            &etag,
1089            br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
1090        )
1091        .await;
1092
1093        assert_eq!(response.status(), StatusCode::OK);
1094        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1095        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1096        assert_eq!(json["match_count"], 2);
1097        assert_eq!(json["total_bytes"], 5);
1098        assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
1099    }
1100
1101    #[tokio::test]
1102    async fn replace_stale_etag_returns_412() {
1103        let router = router();
1104        put_object(router.clone(), "hello.md", b"hello world").await;
1105
1106        let response = post_replace(
1107            router,
1108            "hello.md",
1109            "\"stale\"",
1110            br#"{"old_string":"world","new_string":"planet"}"#,
1111        )
1112        .await;
1113
1114        assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
1115    }
1116
1117    #[tokio::test]
1118    async fn replace_post_splice_size_over_cap_returns_413() {
1119        let router = router_with_max_patchable_size(20);
1120        let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
1121
1122        let response = post_replace(
1123            router,
1124            "hello.md",
1125            &etag,
1126            br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
1127        )
1128        .await;
1129
1130        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
1131        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1132        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1133        assert_eq!(json["error"], "payload_too_large");
1134    }
1135
1136    #[tokio::test]
1137    async fn replace_indexer_backpressure_returns_503_with_retry_after() {
1138        let kb = KbSlug::try_new(KB).unwrap();
1139        let storage = crate::testing::InMemoryStorage::with_kbs([&kb]);
1140        let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
1141        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1142        indexer_tx
1143            .try_send(IndexEvent::Upsert {
1144                kb: kb.clone(),
1145                object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1146                etag: "queued".to_string(),
1147                mtime: 0,
1148            })
1149            .unwrap();
1150        let router =
1151            router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
1152
1153        let response = post_replace(
1154            router,
1155            "hello.md",
1156            &etag,
1157            br#"{"old_string":"world","new_string":"planet"}"#,
1158        )
1159        .await;
1160
1161        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1162        assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1163        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1164        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1165        assert_eq!(json["error"], "backend_unavailable");
1166    }
1167
1168    #[tokio::test]
1169    async fn test_precondition_failed_body_shape_unchanged() {
1170        let resp = ApiErrorResponse {
1171            error: ApiError::PreconditionFailed,
1172            request_id: "req-1".into(),
1173        }
1174        .into_response();
1175
1176        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1177        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1178        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1179        let object = json.as_object().unwrap();
1180        assert_eq!(object.len(), 3);
1181        assert!(object.contains_key("error"));
1182        assert!(object.contains_key("message"));
1183        assert!(object.contains_key("request_id"));
1184    }
1185
1186    // ─── New variant tests ────────────────────────────────────────────────────
1187
1188    #[tokio::test]
1189    async fn test_precondition_failed_412() {
1190        let resp = ApiError::PreconditionFailed.into_response();
1191        assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1192    }
1193
1194    #[tokio::test]
1195    async fn test_not_modified_304_empty_body() {
1196        let resp = ApiError::NotModified.into_response();
1197        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1198        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1199        assert!(body.is_empty(), "304 must have an empty body");
1200    }
1201
1202    #[tokio::test]
1203    async fn test_malformed_range_400() {
1204        let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1205        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1206    }
1207
1208    /// RFC 7233 §4.4: a 416 response MUST include `Content-Range: bytes */N`.
1209    #[tokio::test]
1210    async fn test_range_not_satisfiable_416_content_range_header() {
1211        let resp = ApiError::RangeNotSatisfiable {
1212            complete_length: 100,
1213        }
1214        .into_response();
1215        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1216        let cr = resp
1217            .headers()
1218            .get("content-range")
1219            .expect("content-range header must be present on 416");
1220        assert_eq!(cr.to_str().unwrap(), "bytes */100");
1221        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1222        assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1223    }
1224
1225    /// Same 416 check via the `ApiError::Storage(...)` wrapper (e.g. from router.rs
1226    /// call sites that use explicit wrapping instead of `Into::into`).
1227    #[tokio::test]
1228    async fn test_storage_range_not_satisfiable_416_content_range_header() {
1229        let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1230            complete_length: 42,
1231        })
1232        .into_response();
1233        assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1234        let cr = resp
1235            .headers()
1236            .get("content-range")
1237            .expect("content-range header must be present on 416");
1238        assert_eq!(cr.to_str().unwrap(), "bytes */42");
1239    }
1240
1241    /// 304 from a wrapped `StorageError::NotModified` (explicit wrapping in router.rs).
1242    #[tokio::test]
1243    async fn test_storage_not_modified_304_empty_body() {
1244        let resp = ApiError::Storage(StorageError::NotModified).into_response();
1245        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1246        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1247        assert!(body.is_empty(), "304 must have an empty body");
1248    }
1249
1250    /// `From<StorageError>` promotes `NotModified` to `ApiError::NotModified`.
1251    #[test]
1252    fn test_from_storage_error_not_modified() {
1253        let api_err = ApiError::from(StorageError::NotModified);
1254        assert!(matches!(api_err, ApiError::NotModified));
1255    }
1256
1257    /// `From<StorageError>` promotes `PreconditionFailed` to `ApiError::PreconditionFailed`.
1258    #[test]
1259    fn test_from_storage_error_precondition_failed() {
1260        let api_err = ApiError::from(StorageError::PreconditionFailed);
1261        assert!(matches!(api_err, ApiError::PreconditionFailed));
1262    }
1263
1264    /// `From<StorageError>` promotes `RangeNotSatisfiable` with the correct length.
1265    #[test]
1266    fn test_from_storage_error_range_not_satisfiable() {
1267        let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1268            complete_length: 999,
1269        });
1270        assert!(
1271            matches!(
1272                api_err,
1273                ApiError::RangeNotSatisfiable {
1274                    complete_length: 999
1275                }
1276            ),
1277            "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1278        );
1279    }
1280
1281    /// Other `StorageError` variants must still be wrapped in `ApiError::Storage`.
1282    #[test]
1283    fn test_from_storage_error_other_wrapped() {
1284        let api_err = ApiError::from(StorageError::NotFound {
1285            key: "foo".to_string(),
1286        });
1287        assert!(matches!(api_err, ApiError::Storage(_)));
1288    }
1289
1290    /// Core's own `From<StorageError>` yields `CoreError::Storage(BucketNotFound)`;
1291    /// one `.map_err(CoreError::from)` in a handler must not reopen the bucket name
1292    /// that `From<StorageError>` closes.
1293    #[test]
1294    fn test_from_core_error_bucket_not_found_is_sanitised() {
1295        let api_err = ApiError::from(CoreError::from(StorageError::BucketNotFound {
1296            bucket: "nt-default-notes".to_string(),
1297        }));
1298        assert!(
1299            matches!(&api_err, ApiError::Core(CoreError::NotFound { resource }) if resource == "knowledge base storage"),
1300            "{api_err:?}"
1301        );
1302        // Every other core error is wrapped as it is.
1303        let api_err = ApiError::from(CoreError::from(StorageError::NotFound {
1304            key: "a.md".to_string(),
1305        }));
1306        assert!(matches!(
1307            api_err,
1308            ApiError::Core(CoreError::Storage(StorageError::NotFound { .. }))
1309        ));
1310    }
1311
1312    /// Even a `BucketNotFound` built by hand around either wrapper renders the
1313    /// fixed message: `404 not_found`, and neither the bucket nor the tenant naming
1314    /// scheme in the body.
1315    #[tokio::test]
1316    async fn a_bucket_not_found_reached_through_core_error_names_no_bucket() {
1317        for error in [
1318            ApiError::Core(CoreError::Storage(StorageError::BucketNotFound {
1319                bucket: "nt-acme-notes".to_string(),
1320            })),
1321            ApiError::Storage(StorageError::BucketNotFound {
1322                bucket: "nt-acme-notes".to_string(),
1323            }),
1324        ] {
1325            let resp = ApiErrorResponse {
1326                error,
1327                request_id: "req-1".into(),
1328            }
1329            .into_response();
1330            assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1331            let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1332            let text = String::from_utf8(body.to_vec()).unwrap();
1333            let json: serde_json::Value = serde_json::from_str(&text).unwrap();
1334            assert_eq!(json["error"], "not_found");
1335            assert_eq!(json["message"], "not found: knowledge base storage");
1336            assert!(!text.contains("nt-"), "{text}");
1337            assert!(!text.contains("bucket"), "{text}");
1338        }
1339    }
1340
1341    mod line_range_error {
1342        use super::*;
1343        use axum::body::Body;
1344        use axum::http::Request;
1345        use bytes::Bytes;
1346        use notedthat_core::KbSlug;
1347        use std::collections::BTreeMap;
1348        use std::sync::Arc;
1349        use tower::util::ServiceExt;
1350
1351        const KB: &str = "notes";
1352        const TOKEN: &str = "test-token-abc";
1353
1354        fn twenty_line_markdown() -> String {
1355            let mut body = String::new();
1356            for line in 1..=20 {
1357                std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1358            }
1359            body
1360        }
1361
1362        fn router() -> axum::Router {
1363            let kb = KbSlug::try_new(KB).unwrap();
1364            let mut kbs = BTreeMap::new();
1365            kbs.insert(KB.to_string(), kb);
1366            let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1367            tokio::spawn(async move { while rx.recv().await.is_some() {} });
1368
1369            crate::router::build_router(crate::state::AppState {
1370                storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
1371                access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1372                kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
1373                declared_kbs: Arc::new(kbs),
1374                authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1375                max_body_size: 16 * 1024 * 1024,
1376                max_patchable_size: 16 * 1024 * 1024,
1377                indexer_tx,
1378                searcher: Arc::new(crate::testing::NoopSearcher),
1379                events: None,
1380                index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
1381                readiness: crate::testing::ready_receiver(),
1382                reconcile: None,
1383            })
1384        }
1385
1386        async fn put_ranges_md(router: axum::Router) {
1387            let response = router
1388                .oneshot(
1389                    Request::builder()
1390                        .method("PUT")
1391                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1392                        .header("authorization", format!("Bearer {TOKEN}"))
1393                        .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1394                        .body(Body::from(Bytes::from(twenty_line_markdown())))
1395                        .unwrap(),
1396                )
1397                .await
1398                .unwrap();
1399
1400            assert_eq!(response.status(), StatusCode::CREATED);
1401        }
1402
1403        #[tokio::test]
1404        async fn malformed_line_range_returns_json_400() {
1405            let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1406
1407            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1408            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1409            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1410            assert_eq!(json["error"], "malformed_range");
1411        }
1412
1413        #[tokio::test]
1414        async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1415            let response = ApiError::LineRangeNotSatisfiable {
1416                line_total: 20,
1417                byte_total: 100,
1418            }
1419            .into_response();
1420
1421            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1422            assert_eq!(
1423                response.headers().get("content-range").unwrap(),
1424                "lines */20"
1425            );
1426            assert_eq!(
1427                response.headers().get("x-content-range-bytes").unwrap(),
1428                "*/100"
1429            );
1430            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1431            assert!(body.is_empty());
1432        }
1433
1434        #[tokio::test]
1435        async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1436            let router = router();
1437            put_ranges_md(router.clone()).await;
1438
1439            let response = router
1440                .oneshot(
1441                    Request::builder()
1442                        .method("GET")
1443                        .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1444                        .header("authorization", format!("Bearer {TOKEN}"))
1445                        .header(axum::http::header::RANGE, "lines=100-200")
1446                        .body(Body::empty())
1447                        .unwrap(),
1448                )
1449                .await
1450                .unwrap();
1451
1452            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1453            assert_eq!(
1454                response.headers().get("content-range").unwrap(),
1455                "lines */20"
1456            );
1457            assert_eq!(
1458                response.headers().get("x-content-range-bytes").unwrap(),
1459                "*/160"
1460            );
1461            let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1462            assert!(body.is_empty());
1463        }
1464
1465        #[tokio::test]
1466        async fn byte_range_not_satisfiable_omits_line_byte_header() {
1467            let response = ApiError::RangeNotSatisfiable {
1468                complete_length: 100,
1469            }
1470            .into_response();
1471
1472            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1473            assert_eq!(
1474                response.headers().get("content-range").unwrap(),
1475                "bytes */100"
1476            );
1477            assert!(response.headers().get("x-content-range-bytes").is_none());
1478        }
1479
1480        #[test]
1481        fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1482            let error = ApiError::from(WriteError::PatchLineOutOfRange {
1483                first: 100,
1484                last: 200,
1485                total_lines: 20,
1486                total_bytes: 100,
1487            });
1488
1489            assert!(matches!(
1490                error,
1491                ApiError::LineRangeNotSatisfiable {
1492                    line_total: 20,
1493                    byte_total: 100
1494                }
1495            ));
1496        }
1497    }
1498}