• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divviup / divviup-api / 25528952740

08 May 2026 12:06AM UTC coverage: 57.286% (-1.3%) from 58.564%
25528952740

Pull #2247

github

web-flow
Merge 6f819cf00 into 91680da33
Pull Request #2247: Migrate from Trillium [part 7C]: admin routes and Trillium router removal

42 of 63 new or added lines in 5 files covered. (66.67%)

111 existing lines in 12 files now uncovered.

4352 of 7597 relevant lines covered (57.29%)

64.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.86
/src/handler/error.rs
1
use crate::clients::ClientError;
2
use axum::http::StatusCode;
3
use axum::response::{IntoResponse, Response};
4
use axum::Json as AxumJson;
5
use sea_orm::DbErr;
6
use serde_json::json;
7
use std::{backtrace::Backtrace, sync::Arc};
8
use trillium::{async_trait, Conn, Handler, Status};
9
use trillium_api::{ApiConnExt, Error as ApiError};
10
use validator::ValidationErrors;
11

12
pub struct ErrorHandler;
13
#[async_trait]
14
impl Handler for ErrorHandler {
UNCOV
15
    async fn run(&self, conn: Conn) -> Conn {
×
UNCOV
16
        conn
×
UNCOV
17
    }
×
18

19
    async fn before_send(&self, mut conn: Conn) -> Conn {
558✔
20
        if let Some(error) = conn.take_state::<ApiError>() {
279✔
21
            conn.insert_state(Error::from(error));
×
22
        };
279✔
23

24
        let Some(error) = conn.state().cloned() else {
279✔
25
            return conn;
279✔
26
        };
27

28
        match error {
×
29
            Error::AccessDenied => conn.with_status(Status::Forbidden).with_body(""),
×
30

31
            Error::NotFound => conn.with_status(Status::NotFound).with_body(""),
×
32

33
            Error::Json(e @ ApiError::UnsupportedMimeType { .. }) => conn
×
34
                .with_status(Status::NotAcceptable)
×
35
                .with_body(e.to_string()),
×
36

37
            Error::Json(ApiError::ParseError { path, message }) => conn
×
38
                .with_status(Status::BadRequest)
×
39
                .with_json(&json!({"path": path, "message": message})),
×
40

41
            Error::Validation(e) => conn.with_status(Status::BadRequest).with_json(&e),
×
42

43
            e => {
×
44
                let string = e.to_string();
×
45
                log::error!("{e}");
×
46
                let mut conn = conn.with_status(Status::InternalServerError);
×
47
                if cfg!(debug_assertions) {
×
48
                    conn.with_body(string)
×
49
                } else {
50
                    conn.inner_mut().take_response_body();
×
51
                    conn
×
52
                }
53
            }
54
        }
55
    }
558✔
56
}
57

58
#[derive(thiserror::Error, Debug, Clone)]
59
#[non_exhaustive]
60
pub enum Error {
61
    #[error("Access denied")]
62
    AccessDenied,
63
    #[error(transparent)]
64
    Database(#[from] Arc<DbErr>),
65
    #[error("Not found")]
66
    NotFound,
67
    #[error(transparent)]
68
    Json(#[from] ApiError),
69
    #[error(transparent)]
70
    Validation(#[from] ValidationErrors),
71
    #[error(transparent)]
72
    Client(#[from] Arc<ClientError>),
73
    #[error(transparent)]
74
    Other(#[from] Arc<dyn std::error::Error + Send + Sync>),
75
    #[error(transparent)]
76
    UrlParse(#[from] url::ParseError),
77
    #[error(transparent)]
78
    NumericConversion(#[from] std::num::TryFromIntError),
79
    #[error(transparent)]
80
    TimeComponentRange(#[from] time::error::ComponentRange),
81
    #[error(transparent)]
82
    TaskProvisioning(#[from] crate::entity::task::TaskProvisioningError),
83
    #[error(transparent)]
84
    Uuid(#[from] uuid::Error),
85
    #[error("payload too large")]
86
    PayloadTooLarge,
87
    #[error("encryption error")]
88
    Encryption,
89
    #[error(transparent)]
90
    Utf8Error(#[from] std::string::FromUtf8Error),
91
    #[error("{0}")]
92
    String(&'static str),
93
    #[error(transparent)]
94
    Codec(Arc<janus_messages::codec::CodecError>),
95
    #[error("csrf mismatch or missing")]
96
    CallbackCsrfMismatch,
97
    #[error("expected pkce verifier in session")]
98
    CallbackMissingPkce,
99
    #[error("expected code query param")]
100
    CallbackMissingCode,
101
}
102

103
impl From<janus_messages::codec::CodecError> for Error {
104
    fn from(error: janus_messages::codec::CodecError) -> Self {
×
105
        Self::Codec(Arc::new(error))
×
106
    }
×
107
}
108

109
impl From<aes_gcm::Error> for Error {
110
    fn from(_: aes_gcm::Error) -> Self {
×
111
        Self::Encryption
×
112
    }
×
113
}
114

115
impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
116
    fn from(value: Box<dyn std::error::Error + Send + Sync>) -> Self {
×
117
        Self::Other(Arc::from(value))
×
118
    }
×
119
}
120

121
impl From<serde_json::Error> for Error {
122
    fn from(value: serde_json::Error) -> Self {
×
123
        ApiError::from(value).into()
×
124
    }
×
125
}
126

127
impl From<tower_sessions::session::Error> for Error {
128
    fn from(value: tower_sessions::session::Error) -> Self {
×
129
        Self::Other(Arc::new(value))
×
130
    }
×
131
}
132

133
impl From<DbErr> for Error {
134
    fn from(value: DbErr) -> Self {
×
135
        Self::Database(Arc::new(value))
×
136
    }
×
137
}
138

139
impl From<ClientError> for Error {
140
    fn from(value: ClientError) -> Self {
2✔
141
        Self::Client(Arc::new(value))
2✔
142
    }
2✔
143
}
144

145
#[async_trait]
146
impl Handler for Error {
147
    async fn run(&self, conn: Conn) -> Conn {
×
148
        conn.with_state(self.clone())
×
149
            .with_state(Backtrace::capture())
×
150
    }
×
151
}
152

153
/// Axum-side error-to-response conversion, mirroring the Trillium
154
/// [`ErrorHandler::before_send`] logic above.
155
impl IntoResponse for Error {
156
    fn into_response(self) -> Response {
100✔
157
        match self {
2✔
158
            Error::AccessDenied => StatusCode::FORBIDDEN.into_response(),
62✔
159

160
            Error::CallbackCsrfMismatch
161
            | Error::CallbackMissingPkce
162
            | Error::CallbackMissingCode => {
163
                // Preserve the Trillium-side behavior of 403 with a plain-text
164
                // explanatory body for OAuth callback validation failures.
165
                (StatusCode::FORBIDDEN, self.to_string()).into_response()
3✔
166
            }
167

168
            Error::NotFound => StatusCode::NOT_FOUND.into_response(),
18✔
169

170
            Error::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
1✔
171

172
            Error::Json(ApiError::UnsupportedMimeType { .. }) => {
173
                StatusCode::NOT_ACCEPTABLE.into_response()
1✔
174
            }
175

176
            Error::Json(ApiError::ParseError { path, message }) => (
1✔
177
                StatusCode::BAD_REQUEST,
1✔
178
                AxumJson(json!({"path": path, "message": message})),
1✔
179
            )
1✔
180
                .into_response(),
1✔
181

182
            Error::Validation(e) => (StatusCode::BAD_REQUEST, AxumJson(e)).into_response(),
13✔
183

184
            e => {
1✔
185
                log::error!("{e}");
1✔
186
                if cfg!(debug_assertions) {
1✔
187
                    (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
1✔
188
                } else {
189
                    StatusCode::INTERNAL_SERVER_ERROR.into_response()
×
190
                }
191
            }
192
        }
193
    }
100✔
194
}
195

196
#[cfg(test)]
197
mod tests {
198
    use super::*;
199
    use axum::response::IntoResponse;
200

201
    #[test]
202
    fn access_denied_is_403() {
1✔
203
        let resp = Error::AccessDenied.into_response();
1✔
204
        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1✔
205
    }
1✔
206

207
    #[test]
208
    fn not_found_is_404() {
1✔
209
        let resp = Error::NotFound.into_response();
1✔
210
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1✔
211
    }
1✔
212

213
    #[test]
214
    fn unsupported_mime_type_is_406() {
1✔
215
        let err = Error::Json(ApiError::UnsupportedMimeType {
1✔
216
            mime_type: "text/plain".into(),
1✔
217
        });
1✔
218
        let resp = err.into_response();
1✔
219
        assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
1✔
220
    }
1✔
221

222
    #[test]
223
    fn parse_error_is_400() {
1✔
224
        let err = Error::Json(ApiError::ParseError {
1✔
225
            path: ".field".into(),
1✔
226
            message: "expected string".into(),
1✔
227
        });
1✔
228
        let resp = err.into_response();
1✔
229
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1✔
230
    }
1✔
231

232
    #[test]
233
    fn validation_error_is_400() {
1✔
234
        let err = Error::Validation(ValidationErrors::new());
1✔
235
        let resp = err.into_response();
1✔
236
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1✔
237
    }
1✔
238

239
    #[test]
240
    fn payload_too_large_is_413() {
1✔
241
        let resp = Error::PayloadTooLarge.into_response();
1✔
242
        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
1✔
243
    }
1✔
244

245
    #[test]
246
    fn other_error_is_500() {
1✔
247
        let err = Error::String("something broke");
1✔
248
        let resp = err.into_response();
1✔
249
        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1✔
250
    }
1✔
251

252
    #[test]
253
    fn callback_validation_errors_are_403() {
1✔
254
        for err in [
3✔
255
            Error::CallbackCsrfMismatch,
1✔
256
            Error::CallbackMissingPkce,
1✔
257
            Error::CallbackMissingCode,
1✔
258
        ] {
1✔
259
            let resp = err.into_response();
3✔
260
            assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3✔
261
        }
262
    }
1✔
263
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc