Skip to main content

max / makenotwork

7.3 KB · 239 lines History Blame Raw
1 //! Adversarial coverage for `db::idempotency` (the POST-retry cache).
2 //!
3 //! Run #1 graded `db/idempotency.rs` Testing=C (zero tests). These probe the
4 //! invariants the module is supposed to hold: (key, user, method, path) scoping,
5 //! first-writer-wins under ON CONFLICT, and the 24-hour cleanup boundary.
6
7 use crate::harness::db::TestDb;
8 use makenotwork::db::{UserId, idempotency};
9 use sqlx::PgPool;
10
11 /// Seed a minimal verified user and return its id (the cache FKs `users(id)`).
12 async fn seed_user(pool: &PgPool, username: &str) -> UserId {
13 let hash = makenotwork::auth::hash_password("password123").expect("hash");
14 sqlx::query_scalar::<_, UserId>(
15 "INSERT INTO users (username, email, password_hash, email_verified)
16 VALUES ($1, $2, $3, true) RETURNING id",
17 )
18 .bind(username)
19 .bind(format!("{username}@test.com"))
20 .bind(&hash)
21 .fetch_one(pool)
22 .await
23 .expect("seed user")
24 }
25
26 #[tokio::test]
27 async fn store_then_get_roundtrip() {
28 let db = TestDb::new().await;
29 let user = seed_user(&db.pool, "idem_roundtrip").await;
30
31 assert!(
32 idempotency::get_cached_response(&db.pool, "k1", user, "POST", "/checkout")
33 .await
34 .expect("get miss")
35 .is_none(),
36 "cold key must miss"
37 );
38
39 idempotency::store_response(
40 &db.pool,
41 "k1",
42 user,
43 "POST",
44 "/checkout",
45 201,
46 "{\"ok\":true}",
47 )
48 .await
49 .expect("store");
50
51 let hit = idempotency::get_cached_response(&db.pool, "k1", user, "POST", "/checkout")
52 .await
53 .expect("get hit")
54 .expect("must hit after store");
55 assert_eq!(hit.status_code, 201);
56 assert_eq!(hit.response_body, "{\"ok\":true}");
57 }
58
59 #[tokio::test]
60 async fn scope_isolates_key_across_user_method_path() {
61 let db = TestDb::new().await;
62 let alice = seed_user(&db.pool, "idem_alice").await;
63 let bob = seed_user(&db.pool, "idem_bob").await;
64
65 // Same key string, four distinct scopes, none may leak into another.
66 idempotency::store_response(&db.pool, "shared", alice, "POST", "/a", 200, "alice-a")
67 .await
68 .unwrap();
69 idempotency::store_response(&db.pool, "shared", alice, "POST", "/b", 200, "alice-b")
70 .await
71 .unwrap();
72 idempotency::store_response(
73 &db.pool,
74 "shared",
75 alice,
76 "DELETE",
77 "/a",
78 200,
79 "alice-del-a",
80 )
81 .await
82 .unwrap();
83 idempotency::store_response(&db.pool, "shared", bob, "POST", "/a", 200, "bob-a")
84 .await
85 .unwrap();
86
87 let cases = [
88 (alice, "POST", "/a", "alice-a"),
89 (alice, "POST", "/b", "alice-b"),
90 (alice, "DELETE", "/a", "alice-del-a"),
91 (bob, "POST", "/a", "bob-a"),
92 ];
93 for (user, method, path, want) in cases {
94 let got = idempotency::get_cached_response(&db.pool, "shared", user, method, path)
95 .await
96 .unwrap()
97 .expect("each scope is stored independently");
98 assert_eq!(got.response_body, want, "scope {method} {path} leaked");
99 }
100
101 // A scope nobody wrote (bob, DELETE, /a) must still miss.
102 assert!(
103 idempotency::get_cached_response(&db.pool, "shared", bob, "DELETE", "/a")
104 .await
105 .unwrap()
106 .is_none()
107 );
108 }
109
110 #[tokio::test]
111 async fn second_store_is_a_no_op_first_writer_wins() {
112 let db = TestDb::new().await;
113 let user = seed_user(&db.pool, "idem_firstwriter").await;
114
115 idempotency::store_response(&db.pool, "k", user, "POST", "/x", 201, "first")
116 .await
117 .unwrap();
118 // ON CONFLICT DO NOTHING: a second store on the same scope must not clobber.
119 idempotency::store_response(&db.pool, "k", user, "POST", "/x", 500, "second")
120 .await
121 .unwrap();
122
123 let got = idempotency::get_cached_response(&db.pool, "k", user, "POST", "/x")
124 .await
125 .unwrap()
126 .unwrap();
127 assert_eq!(got.status_code, 201, "first writer's status must stand");
128 assert_eq!(got.response_body, "first", "first writer's body must stand");
129 }
130
131 #[tokio::test]
132 async fn concurrent_stores_keep_exactly_one_row() {
133 let db = TestDb::new().await;
134 let user = seed_user(&db.pool, "idem_concurrent").await;
135
136 // 16 concurrent deliveries of the "same" retry, each with a distinct body.
137 let mut handles = Vec::new();
138 for i in 0..16 {
139 let pool = db.pool.clone();
140 handles.push(tokio::spawn(async move {
141 idempotency::store_response(
142 &pool,
143 "race",
144 user,
145 "POST",
146 "/checkout",
147 201,
148 &format!("body-{i}"),
149 )
150 .await
151 }));
152 }
153 for h in handles {
154 h.await.expect("join").expect("store ok");
155 }
156
157 let count: i64 = sqlx::query_scalar(
158 "SELECT COUNT(*) FROM idempotency_keys WHERE key = 'race' AND user_id = $1",
159 )
160 .bind(user)
161 .fetch_one(&db.pool)
162 .await
163 .unwrap();
164 assert_eq!(count, 1, "exactly one row survives the race");
165
166 // And the cached read is stable (some single winner's body).
167 let got = idempotency::get_cached_response(&db.pool, "race", user, "POST", "/checkout")
168 .await
169 .unwrap()
170 .unwrap();
171 assert!(got.response_body.starts_with("body-"));
172 }
173
174 #[tokio::test]
175 async fn cleanup_expired_respects_24h_boundary() {
176 let db = TestDb::new().await;
177 let user = seed_user(&db.pool, "idem_cleanup").await;
178
179 idempotency::store_response(&db.pool, "fresh", user, "POST", "/x", 200, "fresh")
180 .await
181 .unwrap();
182 idempotency::store_response(&db.pool, "stale", user, "POST", "/x", 200, "stale")
183 .await
184 .unwrap();
185 // Backdate the stale row just past the 24h window.
186 sqlx::query(
187 "UPDATE idempotency_keys SET created_at = NOW() - INTERVAL '25 hours' \
188 WHERE key = 'stale' AND user_id = $1",
189 )
190 .bind(user)
191 .execute(&db.pool)
192 .await
193 .unwrap();
194
195 let deleted = idempotency::cleanup_expired(&db.pool).await.unwrap();
196 assert_eq!(deleted, 1, "only the >24h row is purged");
197
198 assert!(
199 idempotency::get_cached_response(&db.pool, "fresh", user, "POST", "/x")
200 .await
201 .unwrap()
202 .is_some(),
203 "fresh row survives cleanup"
204 );
205 assert!(
206 idempotency::get_cached_response(&db.pool, "stale", user, "POST", "/x")
207 .await
208 .unwrap()
209 .is_none(),
210 "stale row is gone"
211 );
212 }
213
214 #[tokio::test]
215 async fn empty_key_is_stored_and_scoped_independently() {
216 let db = TestDb::new().await;
217 let user = seed_user(&db.pool, "idem_emptykey").await;
218
219 // An empty key is a benign, distinct key value, it must round-trip and not
220 // collide with a non-empty key in the same scope.
221 idempotency::store_response(&db.pool, "", user, "POST", "/x", 202, "empty")
222 .await
223 .unwrap();
224 idempotency::store_response(&db.pool, "k", user, "POST", "/x", 200, "nonempty")
225 .await
226 .unwrap();
227
228 let empty = idempotency::get_cached_response(&db.pool, "", user, "POST", "/x")
229 .await
230 .unwrap()
231 .unwrap();
232 assert_eq!(empty.response_body, "empty");
233 let nonempty = idempotency::get_cached_response(&db.pool, "k", user, "POST", "/x")
234 .await
235 .unwrap()
236 .unwrap();
237 assert_eq!(nonempty.response_body, "nonempty");
238 }
239