Skip to content

Commit c6e79c5

Browse files
testing: Use cargo-mutants to surface gaps in pavex_session's test suite
Mutation testing highlighted a few gaps in the test suite for `pavex_session` that had not been caught by looking at coverage on its own. New tests have been added to catch all discovered "MISSED" mutants, as well as guidance on how to rerun these tools when touching the crate in the future.
1 parent 80b72a1 commit c6e79c5

File tree

9 files changed

+247
-22
lines changed

9 files changed

+247
-22
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
/libs/ui_tests/Cargo.lock
55
/libs/target
66
/libs/vendor
7+
/libs/**/mutants.out*
78
/ci_utils/target
89
/doc_examples/tutorial_envs/
910
/doc_examples/**/target
@@ -12,10 +13,10 @@
1213
/examples/**/.cargo
1314
!/examples/.cargo
1415
/.cargo
16+
/.cache/
1517
/vendor
1618
.DS_Store
1719
*.snap
1820
trace-*.json
19-
/.cache/
2021
/docs/api_reference/
2122
.direnv/

CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [0.1.76](https://github.com/LukeMathWalker/pavex/compare/0.1.75...0.1.76) - 2025-02-24
1111

12-
1312
### ⛰️ Features
13+
1414
- Bump nightly version used by pavexc to generate JSON docs from 'nightly-2025-01-04' to 'nightly-2025-02-22' (by @LukeMathWalker) - #454
1515
- Use Rust 2024 edition in the generated server SDK as well as in the starter project generated by 'pavex new' (by @LukeMathWalker) - #454
1616

17-
1817
### Contributors
1918

20-
* @LukeMathWalker
19+
- @LukeMathWalker
2120

2221
## [0.1.75](https://github.com/LukeMathWalker/pavex/compare/0.1.74...0.1.75) - 2025-02-23
2322

libs/pavex_session/CONTRIBUTING.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Testing
2+
3+
HTTP sessions are security-critical components of a backend application.\
4+
It is therefore important to test the session management system thoroughly.
5+
6+
Whenever you submit a pull request that changes the session management system,\
7+
you must include tests that verify the correctness of the changes.
8+
9+
There are two key tools you can rely on to spot if more tests are needed:
10+
11+
- **Code coverage**:\
12+
The code coverage tool will show you which parts of the code are not tested.\
13+
If you see that some parts of the session management system are not tested,\
14+
you should write tests for them.\
15+
Use [`cargo-llvm-cov`](https://github.com/taiki-e/cargo-llvm-cov) to generate a code coverage report:
16+
17+
```bash
18+
cargo llvm-cov --no-cfg-coverage -p pavex_session --html --open
19+
```
20+
- **Mutation testing**:\
21+
Mutation testing is a technique to evaluate the quality of your tests.\
22+
It works by introducing small changes (mutations) to the code and checking if the tests catch them.\
23+
If the tests do not catch the mutations, it means that the tests are not thorough enough.\
24+
Use [`cargo-mutants`](https://mutants.rs/welcome.html) to run mutation tests:
25+
26+
```bash
27+
# Exclude Debug and Display implementations from mutation testing
28+
cargo mutants -E "impl Debug" -E "impl Display" -p pavex_session
29+
```

libs/pavex_session/src/config/state.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ impl Default for SessionStateConfig {
7373
}
7474

7575
fn default_ttl() -> std::time::Duration {
76+
// 1 day
7677
std::time::Duration::from_secs(60 * 60 * 24)
7778
}
7879

libs/pavex_session/src/session_.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ impl<'store> Session<'store> {
221221

222222
static MAX_N_ATTEMPTS: usize = 16;
223223

224-
let i = 0;
224+
let mut i = 0;
225225
let new = loop {
226226
if i >= MAX_N_ATTEMPTS {
227227
panic!(
@@ -234,6 +234,8 @@ impl<'store> Session<'store> {
234234
let new = SessionId::random();
235235
if Some(new) != old {
236236
break new;
237+
} else {
238+
i += 1;
237239
}
238240
};
239241

libs/pavex_session/tests/session/config/state.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use pavex_session::config::TtlExtensionThreshold;
1+
use pavex_session::{Session, SessionConfig, config::TtlExtensionThreshold};
2+
3+
use crate::fixtures::spy_store;
24

35
#[test]
46
fn test_ttl_extension_threshold_valid() {
@@ -69,3 +71,39 @@ fn test_ttl_extension_threshold_deserialization() {
6971
"Expected JSON value to be invalid"
7072
);
7173
}
74+
75+
#[tokio::test]
76+
async fn default_ttl_can_be_changed() {
77+
let ((store, call_tracker), mut config) = (spy_store(), SessionConfig::default());
78+
let ttl_seconds = 10;
79+
config.state.ttl = std::time::Duration::from_secs(ttl_seconds);
80+
81+
let mut session = Session::new(&store, &config, None);
82+
session
83+
.server_mut()
84+
.set("key".into(), "value")
85+
.await
86+
.unwrap();
87+
session.finalize().await.unwrap().unwrap();
88+
89+
let oplog = call_tracker.operation_log().await;
90+
let last = oplog.last().unwrap();
91+
assert_eq!(last, &format!("create <id> {ttl_seconds}s"));
92+
}
93+
94+
#[tokio::test]
95+
async fn default_ttl() {
96+
let ((store, call_tracker), config) = (spy_store(), SessionConfig::default());
97+
98+
let mut session = Session::new(&store, &config, None);
99+
session
100+
.server_mut()
101+
.set("key".into(), "value")
102+
.await
103+
.unwrap();
104+
session.finalize().await.unwrap().unwrap();
105+
106+
let oplog = call_tracker.operation_log().await;
107+
let last = oplog.last().unwrap();
108+
assert_eq!(last, &format!("create <id> {}s", 60 * 60 * 24));
109+
}

libs/pavex_session/tests/session/fixtures.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ impl CallTracker {
123123
self.0.lock().await.oplog.clone()
124124
}
125125

126+
pub async fn reset_operation_log(&self) {
127+
self.0.lock().await.oplog = Vec::new();
128+
}
129+
126130
async fn push_operation(&self, op: impl Into<String>) {
127131
self.0.lock().await.oplog.push(op.into());
128132
}
@@ -142,7 +146,7 @@ impl<B: SessionStorageBackend> SessionStorageBackend for SpyBackend<B> {
142146
record: SessionRecordRef<'_>,
143147
) -> Result<(), CreateError> {
144148
self.call_tracker
145-
.push_operation(format!("create {}", id.inner()))
149+
.push_operation(format!("create <id> {}s", record.ttl.as_secs()))
146150
.await;
147151
self.backend.create(id, record).await
148152
}
@@ -156,7 +160,7 @@ impl<B: SessionStorageBackend> SessionStorageBackend for SpyBackend<B> {
156160
record: SessionRecordRef<'_>,
157161
) -> Result<(), UpdateError> {
158162
self.call_tracker
159-
.push_operation(format!("update {}", id.inner()))
163+
.push_operation(format!("update <id> {}s", record.ttl.as_secs()))
160164
.await;
161165
self.backend.update(id, record).await
162166
}
@@ -167,29 +171,25 @@ impl<B: SessionStorageBackend> SessionStorageBackend for SpyBackend<B> {
167171
ttl: std::time::Duration,
168172
) -> Result<(), UpdateTtlError> {
169173
self.call_tracker
170-
.push_operation(format!("update-ttl {}", id.inner()))
174+
.push_operation(format!("update-ttl <id> {}s", ttl.as_secs()))
171175
.await;
172176
self.backend.update_ttl(id, ttl).await
173177
}
174178

175179
async fn load(&self, session_id: &SessionId) -> Result<Option<SessionRecord>, LoadError> {
176-
self.call_tracker
177-
.push_operation(format!("load {}", session_id.inner()))
178-
.await;
180+
self.call_tracker.push_operation("load <id>").await;
179181
self.call_tracker.0.lock().await.has_invoked_load = true;
180182
self.backend.load(session_id).await
181183
}
182184

183185
async fn delete(&self, session_id: &SessionId) -> Result<(), DeleteError> {
184-
self.call_tracker
185-
.push_operation(format!("delete {}", session_id.inner()))
186-
.await;
186+
self.call_tracker.push_operation("delete <id>").await;
187187
self.backend.delete(session_id).await
188188
}
189189

190190
async fn change_id(&self, old_id: &SessionId, new_id: &SessionId) -> Result<(), ChangeIdError> {
191191
self.call_tracker
192-
.push_operation(format!("change {} {}", old_id.inner(), new_id.inner()))
192+
.push_operation("change <old_id> <new_id>")
193193
.await;
194194
self.backend.change_id(old_id, new_id).await
195195
}

libs/pavex_session/tests/session/main.rs

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ use assertions::is_removal_cookie;
44
use fixtures::{SessionFixture, spy_store, store};
55
use googletest::{
66
assert_that,
7-
prelude::{eq, none, not},
7+
prelude::{empty, eq, len, none, not},
88
};
99
use helpers::SetCookie;
1010
use itertools::Itertools;
1111
use pavex_session::{
1212
IncomingSession, Session, SessionConfig, SessionId,
13-
config::{MissingServerState, ServerStateCreation},
13+
config::{MissingServerState, ServerStateCreation, TtlExtensionTrigger},
1414
};
1515

1616
mod assertions;
@@ -200,8 +200,109 @@ async fn server_state_can_be_cleared_without_invalidating_the_session() {
200200
assert_eq!(cookie.id(), fixture.id());
201201

202202
// The server state is present, but empty.
203-
let server_state = store.load(&fixture.id).await.unwrap();
204-
assert!(server_state.is_some());
203+
let server_state = store.load(&fixture.id).await.unwrap().unwrap();
204+
assert_that!(server_state.state, empty());
205+
}
206+
207+
#[tokio::test]
208+
async fn store_is_not_touched_if_you_clear_an_empty_server_state_and_ttl_is_configured_to_update_on_changes()
209+
{
210+
let ((store, call_tracker), mut config) = (spy_store(), SessionConfig::default());
211+
config.state.extend_ttl = TtlExtensionTrigger::OnStateChanges;
212+
213+
let fixture = SessionFixture::default();
214+
let incoming = fixture.setup(&store).await;
215+
let mut session = Session::new(&store, &config, Some(incoming));
216+
assert!(session.server_mut().is_empty().await.unwrap());
217+
// Otherwise `create` and `load` will show up in the operation log.
218+
call_tracker.reset_operation_log().await;
219+
220+
session.server_mut().clear().await.unwrap();
221+
222+
let cookie = session.finalize().await.unwrap().unwrap();
223+
224+
// It's not a removal cookie!
225+
let cookie = SetCookie::parse(cookie);
226+
assert_eq!(cookie.id(), fixture.id());
227+
228+
call_tracker.assert_store_was_untouched().await;
229+
}
230+
231+
#[tokio::test]
232+
async fn ttl_is_updated_if_server_state_is_loaded_but_unchanged() {
233+
let ((store, call_tracker), mut config) = (spy_store(), SessionConfig::default());
234+
// Always extend TTL
235+
config.state.ttl_extension_threshold = None;
236+
237+
let mut fixture = SessionFixture::default();
238+
fixture.server_ttl = Some(config.state.ttl);
239+
let incoming = fixture.setup(&store).await;
240+
let mut session = Session::new(&store, &config, Some(incoming));
241+
assert!(session.server_mut().is_empty().await.unwrap());
242+
// Otherwise `create` and `load` will show up in the operation log.
243+
call_tracker.reset_operation_log().await;
244+
245+
let cookie = session.finalize().await.unwrap().unwrap();
246+
247+
// It's not a removal cookie!
248+
let cookie = SetCookie::parse(cookie);
249+
assert_eq!(cookie.id(), fixture.id());
250+
251+
let oplog = call_tracker.operation_log().await;
252+
assert_that!(oplog, len(eq(1)));
253+
assert!(oplog[0].starts_with("update-ttl"));
254+
}
255+
256+
#[tokio::test]
257+
async fn ttl_is_not_updated_if_server_state_is_unchanged_but_ttl_threshold_is_not_met() {
258+
let ((store, call_tracker), config) = (spy_store(), SessionConfig::default());
259+
let ttl_extension_threshold = config.state.ttl_extension_threshold.unwrap();
260+
assert!(ttl_extension_threshold.inner() < 0.9);
261+
262+
let mut fixture = SessionFixture::default();
263+
// We start at full TTL
264+
fixture.server_ttl = Some(config.state.ttl);
265+
let incoming = fixture.setup(&store).await;
266+
let mut session = Session::new(&store, &config, Some(incoming));
267+
assert!(session.server_mut().is_empty().await.unwrap());
268+
// Otherwise `create` and `load` will show up in the operation log.
269+
call_tracker.reset_operation_log().await;
270+
271+
let cookie = session.finalize().await.unwrap().unwrap();
272+
273+
// It's not a removal cookie!
274+
let cookie = SetCookie::parse(cookie);
275+
assert_eq!(cookie.id(), fixture.id());
276+
277+
let oplog = call_tracker.operation_log().await;
278+
assert_that!(oplog, empty());
279+
}
280+
281+
#[tokio::test]
282+
async fn ttl_is_updated_if_server_state_is_unchanged_and_ttl_threshold_is_met() {
283+
let ((store, call_tracker), config) = (spy_store(), SessionConfig::default());
284+
let ttl_extension_threshold = config.state.ttl_extension_threshold.unwrap();
285+
286+
let mut fixture = SessionFixture::default();
287+
// We start below the threshold
288+
let ttl = config.state.ttl.as_secs_f32() * (ttl_extension_threshold.inner() - 0.1);
289+
assert!(ttl > 0.);
290+
fixture.server_ttl = Some(std::time::Duration::from_secs_f32(ttl));
291+
let incoming = fixture.setup(&store).await;
292+
let mut session = Session::new(&store, &config, Some(incoming));
293+
assert!(session.server_mut().is_empty().await.unwrap());
294+
// Otherwise `create` and `load` will show up in the operation log.
295+
call_tracker.reset_operation_log().await;
296+
297+
let cookie = session.finalize().await.unwrap().unwrap();
298+
299+
// It's not a removal cookie!
300+
let cookie = SetCookie::parse(cookie);
301+
assert_eq!(cookie.id(), fixture.id());
302+
303+
let oplog = call_tracker.operation_log().await;
304+
assert_that!(oplog, len(eq(1)));
305+
assert!(oplog[0].starts_with("update-ttl"));
205306
}
206307

207308
#[tokio::test]

0 commit comments

Comments
 (0)