Skip to main content

max / makenotwork

11.2 KB · 326 lines History Blame Raw
1 //! DB-layer contract tests for `db::synckit::rotation`, the SyncKit encryption
2 //! key-rotation state machine.
3 //!
4 //! Audit Run 16 flagged `rotation` as a Concurrency/Testing cold spot (B+), and
5 //! Phase 3 of the Run 16 remediation made `begin_key_rotation` transactional
6 //! (`SELECT ... FOR UPDATE` on the `sync_keys` row) precisely to close a
7 //! check-then-insert race where two devices both observe "no rotation" and both
8 //! INSERT. These tests pin that fix and the rest of the begin/complete/cancel
9 //! lifecycle at the DB layer, calling the `db::synckit::rotation` functions
10 //! directly against real Postgres.
11
12 use crate::harness::db::TestDb;
13 use makenotwork::db::synckit;
14 use makenotwork::db::{SyncAppId, SyncDeviceId, UserId};
15
16 /// Seed a verified user.
17 async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId {
18 let hash = makenotwork::auth::hash_password("password123").expect("hash");
19 sqlx::query_scalar::<_, UserId>(
20 "INSERT INTO users (username, email, password_hash, email_verified)
21 VALUES ($1, $2, $3, true) RETURNING id",
22 )
23 .bind(username)
24 .bind(format!("{username}@test.com"))
25 .bind(&hash)
26 .fetch_one(pool)
27 .await
28 .expect("seed user")
29 }
30
31 /// Seed a sync app owned by `user`.
32 async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
33 sqlx::query_scalar::<_, SyncAppId>(
34 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix)
35 VALUES ($1, $2, $3, $4) RETURNING id",
36 )
37 .bind(user)
38 .bind(name)
39 .bind(format!("hash_{name}"))
40 .bind(&name[..name.len().min(8)])
41 .fetch_one(pool)
42 .await
43 .expect("seed sync app")
44 }
45
46 /// Seed the app's sync_keys row (key_version=1, key_id=1 by default).
47 async fn seed_key(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) {
48 sqlx::query("INSERT INTO sync_keys (app_id, user_id, encrypted_key) VALUES ($1, $2, 'enc_v1')")
49 .bind(app)
50 .bind(user)
51 .execute(pool)
52 .await
53 .expect("seed sync key");
54 }
55
56 /// Seed a device row (`sync_key_rotations.device_id` FKs `sync_devices`).
57 async fn seed_device(
58 pool: &sqlx::PgPool,
59 app: SyncAppId,
60 user: UserId,
61 name: &str,
62 ) -> SyncDeviceId {
63 sqlx::query_scalar::<_, SyncDeviceId>(
64 "INSERT INTO sync_devices (app_id, user_id, device_name, platform)
65 VALUES ($1, $2, $3, 'macos') RETURNING id",
66 )
67 .bind(app)
68 .bind(user)
69 .bind(name)
70 .fetch_one(pool)
71 .await
72 .expect("seed device")
73 }
74
75 async fn rotation_row_count(pool: &sqlx::PgPool, app: SyncAppId) -> i64 {
76 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sync_key_rotations WHERE app_id = $1")
77 .bind(app)
78 .fetch_one(pool)
79 .await
80 .expect("count rotations")
81 }
82
83 // ── begin_key_rotation preconditions ─────────────────────────────────────────
84
85 #[tokio::test]
86 async fn begin_rotation_creates_a_row_at_matching_version() {
87 let db = TestDb::new().await;
88 let user = seed_user(&db.pool, "rot_begin").await;
89 let app = seed_app(&db.pool, user, "rotbegin").await;
90 seed_key(&db.pool, app, user).await;
91 let device = seed_device(&db.pool, app, user, "dev").await;
92
93 let out = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1)
94 .await
95 .unwrap();
96 let created = out.expect("matching version begins a rotation");
97 // Fresh key was key_id=1, so the rotation targets key_id=2.
98 assert_eq!(created.new_key_id, 2);
99 assert_eq!(created.device_id, device);
100 assert_eq!(rotation_row_count(&db.pool, app).await, 1);
101 }
102
103 #[tokio::test]
104 async fn begin_rotation_rejects_version_mismatch() {
105 let db = TestDb::new().await;
106 let user = seed_user(&db.pool, "rot_ver").await;
107 let app = seed_app(&db.pool, user, "rotver").await;
108 seed_key(&db.pool, app, user).await;
109
110 // Client believes the key is at version 99; server is at 1 -> caller 409s.
111 let out = synckit::begin_key_rotation(&db.pool, app, user, SyncDeviceId::new(), "enc_v2", 99)
112 .await
113 .unwrap();
114 assert_eq!(out.err(), Some("key version mismatch"));
115 assert_eq!(
116 rotation_row_count(&db.pool, app).await,
117 0,
118 "a mismatch inserts nothing"
119 );
120 }
121
122 #[tokio::test]
123 async fn begin_rotation_errors_when_no_key_exists() {
124 let db = TestDb::new().await;
125 let user = seed_user(&db.pool, "rot_nokey").await;
126 let app = seed_app(&db.pool, user, "rotnokey").await;
127 // Deliberately no seed_key.
128
129 let out = synckit::begin_key_rotation(&db.pool, app, user, SyncDeviceId::new(), "enc_v2", 1)
130 .await
131 .unwrap();
132 assert_eq!(out.err(), Some("no encryption key exists"));
133 }
134
135 #[tokio::test]
136 async fn begin_rotation_is_resumable_by_the_same_device() {
137 let db = TestDb::new().await;
138 let user = seed_user(&db.pool, "rot_resume").await;
139 let app = seed_app(&db.pool, user, "rotresume").await;
140 seed_key(&db.pool, app, user).await;
141 let device = seed_device(&db.pool, app, user, "dev").await;
142
143 let first = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1)
144 .await
145 .unwrap()
146 .expect("first begin");
147 // The same device re-issuing begin resumes the identical rotation, not a new one.
148 let resumed = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1)
149 .await
150 .unwrap()
151 .expect("resume");
152 assert_eq!(
153 resumed.id, first.id,
154 "the same device resumes its own rotation"
155 );
156 assert_eq!(
157 rotation_row_count(&db.pool, app).await,
158 1,
159 "resume must not create a second row"
160 );
161 }
162
163 #[tokio::test]
164 async fn begin_rotation_blocks_a_second_device() {
165 let db = TestDb::new().await;
166 let user = seed_user(&db.pool, "rot_block").await;
167 let app = seed_app(&db.pool, user, "rotblock").await;
168 seed_key(&db.pool, app, user).await;
169 let dev_a = seed_device(&db.pool, app, user, "dev-a").await;
170 let dev_b = seed_device(&db.pool, app, user, "dev-b").await;
171
172 synckit::begin_key_rotation(&db.pool, app, user, dev_a, "enc_v2", 1)
173 .await
174 .unwrap()
175 .expect("first device begins");
176 let second = synckit::begin_key_rotation(&db.pool, app, user, dev_b, "enc_v2", 1)
177 .await
178 .unwrap();
179 assert_eq!(
180 second.err(),
181 Some("rotation already in progress by another device")
182 );
183 assert_eq!(rotation_row_count(&db.pool, app).await, 1);
184 }
185
186 /// The Phase 3 remediation's raison d'être: two devices racing `begin` for the
187 /// same (app, user). The `FOR UPDATE` on the sync_keys row serializes the
188 /// check-then-insert, so exactly one wins and exactly one rotation row exists,
189 /// never two rotations, never a unique-constraint 500.
190 #[tokio::test]
191 async fn concurrent_begins_yield_exactly_one_rotation() {
192 let db = TestDb::new().await;
193 let user = seed_user(&db.pool, "rot_race").await;
194 let app = seed_app(&db.pool, user, "rotrace").await;
195 seed_key(&db.pool, app, user).await;
196
197 let mut handles = Vec::new();
198 for i in 0..8 {
199 let pool = db.pool.clone();
200 let device = seed_device(&db.pool, app, user, &format!("dev-{i}")).await;
201 handles.push(tokio::spawn(async move {
202 synckit::begin_key_rotation(&pool, app, user, device, "enc_v2", 1).await
203 }));
204 }
205
206 let mut winners = 0;
207 for h in handles {
208 // No task may error out (a lost race resolves to Ok(Err(..)), not Err).
209 let out = h
210 .await
211 .expect("task panicked")
212 .expect("begin must not hard-error under contention");
213 if out.is_ok() {
214 winners += 1;
215 }
216 }
217
218 assert_eq!(
219 winners, 1,
220 "exactly one racing device may open the rotation"
221 );
222 assert_eq!(
223 rotation_row_count(&db.pool, app).await,
224 1,
225 "the FOR UPDATE serialization must leave exactly one rotation row"
226 );
227 }
228
229 // ── complete + cancel ────────────────────────────────────────────────────────
230
231 #[tokio::test]
232 async fn complete_rotation_with_no_entries_swaps_the_key() {
233 let db = TestDb::new().await;
234 let user = seed_user(&db.pool, "rot_complete").await;
235 let app = seed_app(&db.pool, user, "rotcomp").await;
236 seed_key(&db.pool, app, user).await;
237 let device = seed_device(&db.pool, app, user, "dev").await;
238
239 let started = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1)
240 .await
241 .unwrap()
242 .expect("begin");
243
244 // No sync_log entries exist, so nothing needs re-encryption: complete succeeds.
245 let done = synckit::complete_key_rotation(&db.pool, app, user, started.id)
246 .await
247 .unwrap();
248 assert_eq!(
249 done,
250 Ok(started.new_key_id),
251 "complete returns the new key id"
252 );
253
254 // The rotation is consumed and the live key advanced.
255 assert_eq!(
256 rotation_row_count(&db.pool, app).await,
257 0,
258 "the rotation row is deleted"
259 );
260 let (version, key_id, enc): (i32, i32, String) =
261 sqlx::query_as("SELECT key_version, key_id, encrypted_key FROM sync_keys WHERE app_id = $1 AND user_id = $2")
262 .bind(app)
263 .bind(user)
264 .fetch_one(&db.pool)
265 .await
266 .unwrap();
267 assert_eq!(version, 2, "key_version is bumped on completion");
268 assert_eq!(
269 key_id, started.new_key_id,
270 "the new key id is now the active one"
271 );
272 assert_eq!(enc, "enc_v2", "the new encrypted key is now live");
273 }
274
275 #[tokio::test]
276 async fn complete_rotation_is_noop_for_unknown_rotation() {
277 let db = TestDb::new().await;
278 let user = seed_user(&db.pool, "rot_unknown").await;
279 let app = seed_app(&db.pool, user, "rotunkn").await;
280 seed_key(&db.pool, app, user).await;
281
282 // A random rotation id that was never started -> Err(0), no key change.
283 let out = synckit::complete_key_rotation(&db.pool, app, user, uuid::Uuid::new_v4())
284 .await
285 .unwrap();
286 assert_eq!(out, Err(0));
287 }
288
289 #[tokio::test]
290 async fn cancel_stale_rotation_respects_the_age_threshold() {
291 let db = TestDb::new().await;
292 let user = seed_user(&db.pool, "rot_stale").await;
293 let app = seed_app(&db.pool, user, "rotstale").await;
294 seed_key(&db.pool, app, user).await;
295 let device = seed_device(&db.pool, app, user, "dev").await;
296
297 synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1)
298 .await
299 .unwrap()
300 .expect("begin");
301
302 // A fresh rotation is not stale, so a 24h cancel is a no-op.
303 assert!(
304 !synckit::cancel_stale_rotation(&db.pool, app, user, 24)
305 .await
306 .unwrap(),
307 "a fresh rotation must not be cancelled as stale"
308 );
309 assert_eq!(rotation_row_count(&db.pool, app).await, 1);
310
311 // Age it past the threshold -> the stale sweep removes it, unblocking new begins.
312 sqlx::query(
313 "UPDATE sync_key_rotations SET updated_at = NOW() - INTERVAL '48 hours' WHERE app_id = $1",
314 )
315 .bind(app)
316 .execute(&db.pool)
317 .await
318 .unwrap();
319 assert!(
320 synckit::cancel_stale_rotation(&db.pool, app, user, 24)
321 .await
322 .unwrap()
323 );
324 assert_eq!(rotation_row_count(&db.pool, app).await, 0);
325 }
326