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

divviup / divviup-api / 25222483819

01 May 2026 04:24PM UTC coverage: 58.828% (+1.0%) from 57.824%
25222483819

push

github

web-flow
Migrate from Trillium [part 7A]: API sub-router and CRUD routes (#2235)

Wire the Axum `/api` sub-router and migrate 17 simple CRUD handlers
for users, accounts, memberships, API tokens, and collector
credentials. Trillium remains primary and handles MIME type
validation, auth gating, and session management during the proxy
transition period.

Key changes:
- Add `extract_entity` helper for permission-checked entity lookups
- Add `Json<T>` extractor using serde_path_to_error for error parity
- Add test-user injection shim so integration tests work through proxy
- Ensure `User::from_parts` always populates admin flag
- Return 404 (not 403) on permission failures to hide resource existence

157 of 177 new or added lines in 11 files covered. (88.7%)

22 existing lines in 4 files now uncovered.

4518 of 7680 relevant lines covered (58.83%)

62.59 hits per line

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

69.49
/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 {
15
    async fn run(&self, conn: Conn) -> Conn {
198✔
16
        conn
99✔
17
    }
198✔
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;
267✔
26
        };
27

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

31
            Error::NotFound => conn.with_status(Status::NotFound).with_body(""),
3✔
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),
9✔
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 {
24✔
148
        conn.with_state(self.clone())
12✔
149
            .with_state(Backtrace::capture())
12✔
150
    }
24✔
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 {
52✔
157
        match self {
2✔
158
            Error::AccessDenied => StatusCode::FORBIDDEN.into_response(),
3✔
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(),
38✔
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(),
4✔
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
    }
52✔
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