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

divviup / divviup-api / 25566159316

08 May 2026 04:10PM UTC coverage: 57.28% (-1.3%) from 58.564%
25566159316

Pull #2247

github

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

44 of 65 new or added lines in 5 files covered. (67.69%)

111 existing lines in 12 files now uncovered.

4351 of 7596 relevant lines covered (57.28%)

64.39 hits per line

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

27.2
/src/handler/session_store.rs
1
use crate::{config::SessionSecrets, entity::session, Db};
2
use async_session::{
3
    async_trait,
4
    chrono::{DateTime, Utc},
5
    Session,
6
};
7
use sea_orm::{
8
    sea_query::{any, OnConflict},
9
    ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
10
};
11
use serde_json::json;
12
use std::collections::HashMap;
13
use time::{Duration, OffsetDateTime};
14
use tower_sessions::{
15
    cookie::{Key, SameSite},
16
    service::SignedCookie,
17
    session::{Id as TowerSessionId, Record},
18
    session_store as tower_store, Expiry, SessionManagerLayer,
19
};
20

21
/// Cookie name shared with the Trillium-side session middleware. Both sides
22
/// set a cookie with this name; the wire formats are incompatible, so each
23
/// side only successfully parses its own.
24
// TODO: remove this comment when Trillium is removed — the incompatibility
25
// note will no longer apply.
26
pub const SESSION_COOKIE_NAME: &str = "divviup.sid";
27

28
#[allow(dead_code)] // TODO: remove in Part 8 (Trillium removal)
29
#[derive(Debug, Clone)]
30
pub struct SessionStore {
31
    db: Db,
32
}
33

34
#[allow(dead_code)] // TODO: remove in Part 8 (Trillium removal)
35
impl SessionStore {
UNCOV
36
    pub fn new(db: Db) -> Self {
×
UNCOV
37
        Self { db }
×
UNCOV
38
    }
×
39
}
40

41
impl TryFrom<&Session> for session::Model {
42
    type Error = serde_json::Error;
43

UNCOV
44
    fn try_from(session: &Session) -> Result<Self, Self::Error> {
×
45
        Ok(Self {
UNCOV
46
            id: session.id().to_string(),
×
47
            // unwrap safety: session object comes from the session handler, and its timestamp
48
            // we made ourselves.
UNCOV
49
            expiry: session
×
UNCOV
50
                .expiry()
×
UNCOV
51
                .map(|e| OffsetDateTime::from_unix_timestamp(e.timestamp()).unwrap()),
×
52
            // unwrap safety: if the serialization is successful, the data element
53
            // will be there.
UNCOV
54
            data: serde_json::from_value(
×
UNCOV
55
                serde_json::to_value(session)?.get("data").unwrap().clone(),
×
56
            )?,
×
57
        })
UNCOV
58
    }
×
59
}
60

61
impl TryFrom<session::Model> for Session {
62
    type Error = serde_json::Error;
63
    fn try_from(db_session: session::Model) -> Result<Session, serde_json::Error> {
×
64
        let mut session: Session = serde_json::from_value(json!({
×
65
            "id": db_session.id,
×
66
            "data": db_session.data,
×
67
        }))?;
×
68
        if let Some(x) = db_session.expiry {
×
69
            // unwrap safety: the expiry time from the database is a timestamp we made
×
70
            // ourselves.
×
71
            session.set_expiry(
×
72
                DateTime::<Utc>::from_timestamp(x.unix_timestamp(), x.nanosecond()).unwrap(),
×
73
            );
×
74
        }
×
75
        Ok(session)
×
76
    }
×
77
}
78

79
#[async_trait]
80
impl async_session::SessionStore for SessionStore {
81
    async fn load_session(&self, cookie_value: String) -> async_session::Result<Option<Session>> {
×
82
        let id = Session::id_from_cookie_value(&cookie_value)?;
×
83
        Ok(session::Entity::find_by_id(id)
×
84
            .filter(any![
×
85
                session::Column::Expiry.is_null(),
×
86
                session::Column::Expiry.gt(OffsetDateTime::now_utc())
×
87
            ])
×
88
            .one(&self.db)
×
89
            .await?
×
90
            .map(Session::try_from)
×
91
            .transpose()?)
×
92
    }
×
93

UNCOV
94
    async fn store_session(&self, session: Session) -> async_session::Result<Option<String>> {
×
UNCOV
95
        let session_model = session::Model::try_from(&session)?.into_active_model();
×
96

UNCOV
97
        session::Entity::insert(session_model)
×
UNCOV
98
            .on_conflict(
×
UNCOV
99
                OnConflict::column(session::Column::Id)
×
UNCOV
100
                    .update_columns([session::Column::Data, session::Column::Expiry])
×
UNCOV
101
                    .clone(),
×
UNCOV
102
            )
×
UNCOV
103
            .exec(&self.db)
×
UNCOV
104
            .await?;
×
105

UNCOV
106
        Ok(session.into_cookie_value())
×
UNCOV
107
    }
×
108

109
    async fn destroy_session(&self, session: Session) -> async_session::Result {
×
110
        session::Entity::delete_by_id(session.id())
×
111
            .exec(&self.db)
×
112
            .await?;
×
113
        Ok(())
×
114
    }
×
115

116
    async fn clear_store(&self) -> async_session::Result {
×
117
        session::Entity::delete_many().exec(&self.db).await?;
×
118
        Ok(())
×
119
    }
×
120
}
121

122
/// Axum-side session store implementing [`tower_sessions_core::SessionStore`].
123
///
124
/// Uses the same `session` database table as the Trillium-side [`SessionStore`],
125
/// but with a different session ID format (`tower-sessions` uses base64-encoded
126
/// `i128` rather than `async_session`'s UUID strings).
127
// TODO: remove the Trillium-side `SessionStore` and the `async_session`
128
// dependency when Trillium is removed.
129
#[derive(Debug, Clone)]
130
pub struct TowerSessionStore {
131
    db: Db,
132
}
133

134
impl TowerSessionStore {
135
    pub fn new(db: Db) -> Self {
278✔
136
        Self { db }
278✔
137
    }
278✔
138
}
139

140
/// Build the Axum-side [`SessionManagerLayer`].
141
///
142
/// `tower-sessions` 0.15 accepts only a single signing key; there is no
143
/// `with_older_secrets` equivalent. Key rotation for the Axum cookie will be
144
/// revisited once the Trillium server is removed in Part 8.
145
pub fn axum_session_layer(
278✔
146
    db: Db,
278✔
147
    secrets: &SessionSecrets,
278✔
148
) -> SessionManagerLayer<TowerSessionStore, SignedCookie> {
278✔
149
    // `cookie::Key::from` panics on keys shorter than 64 bytes. We only
150
    // guarantee 32, so derive a longer key from the configured secret.
151
    let key = Key::derive_from(&secrets.current);
278✔
152
    SessionManagerLayer::new(TowerSessionStore::new(db))
278✔
153
        .with_name(SESSION_COOKIE_NAME)
278✔
154
        .with_secure(true)
278✔
155
        .with_http_only(true)
278✔
156
        .with_same_site(SameSite::Lax)
278✔
157
        .with_path("/")
278✔
158
        .with_signed(key)
278✔
159
        .with_expiry(Expiry::OnInactivity(Duration::days(1)))
278✔
160
}
278✔
161

162
#[async_trait]
163
impl tower_store::SessionStore for TowerSessionStore {
164
    async fn save(&self, record: &Record) -> tower_store::Result<()> {
2✔
165
        let model = session::Model {
1✔
166
            id: record.id.to_string(),
1✔
167
            expiry: Some(record.expiry_date),
1✔
168
            data: serde_json::to_value(&record.data)
1✔
169
                .map_err(|e| tower_store::Error::Encode(e.to_string()))?,
1✔
170
        };
171

172
        session::Entity::insert(model.into_active_model())
1✔
173
            .on_conflict(
1✔
174
                OnConflict::column(session::Column::Id)
1✔
175
                    .update_columns([session::Column::Data, session::Column::Expiry])
1✔
176
                    .clone(),
1✔
177
            )
1✔
178
            .exec(&self.db)
1✔
179
            .await
1✔
180
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
1✔
181

182
        Ok(())
1✔
183
    }
2✔
184

185
    async fn load(&self, session_id: &TowerSessionId) -> tower_store::Result<Option<Record>> {
×
186
        let model = session::Entity::find_by_id(session_id.to_string())
×
187
            .filter(any![
×
188
                session::Column::Expiry.is_null(),
×
189
                session::Column::Expiry.gt(OffsetDateTime::now_utc())
×
190
            ])
×
191
            .one(&self.db)
×
192
            .await
×
193
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
×
194

195
        model
×
196
            .map(|m| {
×
197
                let data: HashMap<String, serde_json::Value> = serde_json::from_value(m.data)
×
198
                    .map_err(|e| tower_store::Error::Decode(e.to_string()))?;
×
199
                let id: TowerSessionId = m.id.parse().map_err(|e: base64::DecodeSliceError| {
×
200
                    tower_store::Error::Decode(e.to_string())
×
201
                })?;
×
202
                Ok(Record {
203
                    id,
×
204
                    data,
×
205
                    expiry_date: m.expiry.unwrap_or_else(|| {
×
206
                        // The DB allows null expiry, but tower-sessions requires a
207
                        // value. Use a far-future sentinel.
208
                        OffsetDateTime::now_utc() + time::Duration::weeks(52)
×
209
                    }),
×
210
                })
211
            })
×
212
            .transpose()
×
213
    }
×
214

215
    async fn delete(&self, session_id: &TowerSessionId) -> tower_store::Result<()> {
×
216
        session::Entity::delete_by_id(session_id.to_string())
×
217
            .exec(&self.db)
×
218
            .await
×
219
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
×
220
        Ok(())
×
221
    }
×
222
}
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