Skip to main content

max / makenotwork

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