Skip to main content

max / makenotwork

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