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

divviup / divviup-api / 25222136258

01 May 2026 04:15PM UTC coverage: 57.824% (+0.6%) from 57.194%
25222136258

push

github

web-flow
Migrate from Trillium [part 6]: auth routes (#2234)

* Migrate from Trillium [part 6]: auth routes

Move `/login`, `/logout`, and `/callback` from the Trillium router to
Axum handlers. I've pulled in `tower-sessions` 0.15 with the
existing `divviup.sid` cookie name, backed by the same `session`
database table via the `TowerSessionStore` added in part 3.

- `oauth2::redirect` / `oauth2::callback` / `misc::logout` rewritten as
  Axum handlers; the Trillium versions are removed.
- `User` gains `FromRequestParts` and `OptionalFromRequestParts` impls;
  `from_parts` now returns `Option<User>` and the `PermissionsActor`
  extractor is updated to match.
- `Error` gains `CallbackCsrfMismatch`, `CallbackMissingPkce`, and
  `CallbackMissingCode` variants, all mapped to 403 to match the
  previous Trillium behavior.
- `OauthClient` is added to `AxumAppState` via `FromRef`.
- `AxumProxy` disables reqwest's default redirect-following so that 302
  responses from Axum handlers (e.g. `/login` redirecting to Auth0) are
  passed back to the caller instead of followed by the proxy.
- When `debug_assertions` is enabled, it compiles in an Axum middleware that reads an
  `X-Integration-Testing-User` header and injects the decoded `User`
  into request extensions, letting integration tests simulate a
  logged-in user through the proxy path. `TestExt::with_user(&user)`
  replaces `with_state(user)` for routes that have migrated.
- The logout test no longer asserts `is_destroyed()`; the cookie
  clearing is tower-sessions's responsibility and will be covered
  end-to-end in part 8 when Trillium is removed.

95 of 136 new or added lines in 9 files covered. (69.85%)

8 existing lines in 2 files now uncovered.

4412 of 7630 relevant lines covered (57.82%)

60.48 hits per line

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

69.03
/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 {
352✔
16
        conn
176✔
17
    }
352✔
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;
259✔
26
        };
27

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

31
            Error::NotFound => conn.with_status(Status::NotFound).with_body(""),
6✔
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),
12✔
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("encryption error")]
86
    Encryption,
87
    #[error(transparent)]
88
    Utf8Error(#[from] std::string::FromUtf8Error),
89
    #[error("{0}")]
90
    String(&'static str),
91
    #[error(transparent)]
92
    Codec(Arc<janus_messages::codec::CodecError>),
93
    #[error("csrf mismatch or missing")]
94
    CallbackCsrfMismatch,
95
    #[error("expected pkce verifier in session")]
96
    CallbackMissingPkce,
97
    #[error("expected code query param")]
98
    CallbackMissingCode,
99
}
100

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

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

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

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

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

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

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

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

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

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

166
            Error::NotFound => StatusCode::NOT_FOUND.into_response(),
1✔
167

168
            Error::Json(ApiError::UnsupportedMimeType { .. }) => {
169
                StatusCode::NOT_ACCEPTABLE.into_response()
1✔
170
            }
171

172
            Error::Json(ApiError::ParseError { path, message }) => (
1✔
173
                StatusCode::BAD_REQUEST,
1✔
174
                AxumJson(json!({"path": path, "message": message})),
1✔
175
            )
1✔
176
                .into_response(),
1✔
177

178
            Error::Validation(e) => (StatusCode::BAD_REQUEST, AxumJson(e)).into_response(),
1✔
179

180
            e => {
1✔
181
                log::error!("{e}");
1✔
182
                if cfg!(debug_assertions) {
1✔
183
                    (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
1✔
184
                } else {
185
                    StatusCode::INTERNAL_SERVER_ERROR.into_response()
×
186
                }
187
            }
188
        }
189
    }
9✔
190
}
191

192
#[cfg(test)]
193
mod tests {
194
    use super::*;
195
    use axum::response::IntoResponse;
196

197
    #[test]
198
    fn access_denied_is_403() {
1✔
199
        let resp = Error::AccessDenied.into_response();
1✔
200
        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1✔
201
    }
1✔
202

203
    #[test]
204
    fn not_found_is_404() {
1✔
205
        let resp = Error::NotFound.into_response();
1✔
206
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1✔
207
    }
1✔
208

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

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

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

235
    #[test]
236
    fn other_error_is_500() {
1✔
237
        let err = Error::String("something broke");
1✔
238
        let resp = err.into_response();
1✔
239
        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1✔
240
    }
1✔
241

242
    #[test]
243
    fn callback_validation_errors_are_403() {
1✔
244
        for err in [
3✔
245
            Error::CallbackCsrfMismatch,
1✔
246
            Error::CallbackMissingPkce,
1✔
247
            Error::CallbackMissingCode,
1✔
248
        ] {
1✔
249
            let resp = err.into_response();
3✔
250
            assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3✔
251
        }
252
    }
1✔
253
}
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