Skip to main content

max / makenotwork

19.0 KB · 599 lines History Blame Raw
1 //! DB-layer contract tests for SyncKit devices, apps, end-user subscriptions
2 //! and the security audit log (`db::synckit::{devices, apps, subscriptions,
3 //! security}`), which had none of their own. `db_synckit_layer` covers the
4 //! change log, blobs and keys; see its header for what is deliberately left to
5 //! the HTTP workflows.
6 //!
7 //! `apps` is covered here beyond the API-key hashing its own `#[cfg(test)]`
8 //! module already pins, and sync-token revocation leaving the website session
9 //! alone stays in `synckit_security`, which asserts it end to end.
10
11 use crate::harness::db::TestDb;
12 use crate::harness::seed_user;
13
14 use makenotwork::db::synckit;
15 use makenotwork::db::synckit::NewAppSyncSubscription;
16 use makenotwork::db::{SyncAppId, SyncDeviceId, SyncPlatform, UserId};
17
18 /// Seed a sync app owned by `user`, with its usage row.
19 async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
20 synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None)
21 .await
22 .expect("seed sync app")
23 .id
24 }
25
26 /// Mark an app first-party, so the end-user subscription model applies.
27 async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) {
28 sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1")
29 .bind(app)
30 .execute(pool)
31 .await
32 .expect("mark internal");
33 }
34
35 async fn seed_device(
36 pool: &sqlx::PgPool,
37 app: SyncAppId,
38 user: UserId,
39 name: &str,
40 ) -> SyncDeviceId {
41 synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Macos, None)
42 .await
43 .expect("seed device")
44 .id
45 }
46
47 /// Give `user` an active subscription on `app` with a `limit_bytes` cap.
48 pub(crate) async fn seed_active_subscription(
49 pool: &sqlx::PgPool,
50 user: UserId,
51 app: SyncAppId,
52 sub_id: &str,
53 limit_bytes: i64,
54 ) {
55 let created = synckit::create_app_sync_subscription(
56 pool,
57 &NewAppSyncSubscription {
58 user_id: user,
59 app_id: app,
60 stripe_subscription_id: sub_id,
61 stripe_customer_id: "cus_test",
62 interval: "monthly",
63 storage_limit_bytes: limit_bytes,
64 },
65 )
66 .await
67 .expect("seed subscription");
68 assert!(created, "the first insert is a write");
69 }
70
71 // ── devices ─────────────────────────────────────────────────────────────────
72
73 #[tokio::test]
74 async fn re_registering_a_device_updates_the_row_it_already_has() {
75 let db = TestDb::new().await;
76 let user = seed_user(&db.pool, "skdev_upsert").await;
77 let app = seed_app(&db.pool, user, "devupsert").await;
78
79 let first = synckit::upsert_sync_device(
80 &db.pool,
81 app,
82 user,
83 "laptop",
84 SyncPlatform::Macos,
85 Some("1.2.0"),
86 )
87 .await
88 .unwrap();
89 let again = synckit::upsert_sync_device(
90 &db.pool,
91 app,
92 user,
93 "laptop",
94 SyncPlatform::Linux,
95 Some("1.3.0"),
96 )
97 .await
98 .unwrap();
99 assert_eq!(first.id, again.id, "the same device is not a second device");
100 assert_eq!(again.platform, SyncPlatform::Linux);
101 assert_eq!(again.client_version.as_deref(), Some("1.3.0"));
102
103 // A client that stops sending a User-Agent has not downgraded to unknown.
104 let quiet =
105 synckit::upsert_sync_device(&db.pool, app, user, "laptop", SyncPlatform::Linux, None)
106 .await
107 .unwrap();
108 assert_eq!(
109 quiet.client_version.as_deref(),
110 Some("1.3.0"),
111 "None leaves the recorded version alone rather than blanking it"
112 );
113 assert_eq!(
114 synckit::count_sync_devices(&db.pool, app, user)
115 .await
116 .unwrap(),
117 1
118 );
119 }
120
121 #[tokio::test]
122 async fn a_device_answers_only_to_its_own_owner() {
123 let db = TestDb::new().await;
124 let alice = seed_user(&db.pool, "skdev_alice").await;
125 let bob = seed_user(&db.pool, "skdev_bob").await;
126 let app = seed_app(&db.pool, alice, "devscope").await;
127 let other_app = seed_app(&db.pool, alice, "devscope2").await;
128 let device = seed_device(&db.pool, app, alice, "alice-laptop").await;
129
130 assert!(
131 synckit::sync_device_belongs(&db.pool, device, app, alice)
132 .await
133 .unwrap()
134 );
135 assert!(
136 !synckit::sync_device_belongs(&db.pool, device, app, bob)
137 .await
138 .unwrap(),
139 "a device id from another user's token must not verify"
140 );
141 assert!(
142 !synckit::sync_device_belongs(&db.pool, device, other_app, alice)
143 .await
144 .unwrap(),
145 "nor one from the same user in another app"
146 );
147
148 assert!(
149 !synckit::delete_sync_device(&db.pool, device, app, bob)
150 .await
151 .unwrap(),
152 "and a non-owner's delete removes nothing"
153 );
154 assert!(
155 synckit::sync_device_belongs(&db.pool, device, app, alice)
156 .await
157 .unwrap()
158 );
159 assert!(
160 synckit::delete_sync_device(&db.pool, device, app, alice)
161 .await
162 .unwrap()
163 );
164 assert!(
165 !synckit::delete_sync_device(&db.pool, device, app, alice)
166 .await
167 .unwrap(),
168 "deleting it twice reports no second removal"
169 );
170
171 let alice_devices = synckit::get_sync_devices(&db.pool, app, bob).await.unwrap();
172 assert!(alice_devices.is_empty());
173 }
174
175 #[tokio::test]
176 async fn a_pull_cursor_only_ever_moves_forward() {
177 let db = TestDb::new().await;
178 let user = seed_user(&db.pool, "skdev_cursor").await;
179 let app = seed_app(&db.pool, user, "devcursor").await;
180 let device = seed_device(&db.pool, app, user, "laptop").await;
181
182 synckit::touch_and_advance_cursor(&db.pool, device, 40, Some("1.4.0"))
183 .await
184 .unwrap();
185 // An out-of-order pull response, or a client replaying an old one.
186 synckit::touch_and_advance_cursor(&db.pool, device, 7, None)
187 .await
188 .unwrap();
189
190 let row: (i64, Option<String>) =
191 sqlx::query_as("SELECT last_pulled_seq, client_version FROM sync_devices WHERE id = $1")
192 .bind(device)
193 .fetch_one(&db.pool)
194 .await
195 .unwrap();
196 assert_eq!(
197 row.0, 40,
198 "a lower cursor must not rewind the device, or compaction would \
199 delete entries it has already seen"
200 );
201 assert_eq!(
202 row.1.as_deref(),
203 Some("1.4.0"),
204 "and a version-less pull leaves the recorded version alone"
205 );
206 }
207
208 // ── apps ────────────────────────────────────────────────────────────────────
209
210 #[tokio::test]
211 async fn regenerating_an_api_key_retires_the_old_one() {
212 let db = TestDb::new().await;
213 let user = seed_user(&db.pool, "skapp_regen").await;
214 let app = synckit::create_sync_app(&db.pool, user, "regen", "sk_old_key_value", None, None)
215 .await
216 .unwrap();
217
218 assert_eq!(
219 synckit::get_sync_app_by_api_key(&db.pool, "sk_old_key_value")
220 .await
221 .unwrap()
222 .map(|a| a.id),
223 Some(app.id)
224 );
225
226 let updated = synckit::regenerate_sync_app_key(&db.pool, app.id, "sk_new_key_value")
227 .await
228 .unwrap();
229 assert_eq!(
230 updated.api_key_prefix, "sk_new_k",
231 "the prefix is the first 8"
232 );
233 assert!(
234 synckit::get_sync_app_by_api_key(&db.pool, "sk_old_key_value")
235 .await
236 .unwrap()
237 .is_none(),
238 "rotation is immediate: the old key stops working when this returns"
239 );
240 assert_eq!(
241 synckit::get_sync_app_by_api_key(&db.pool, "sk_new_key_value")
242 .await
243 .unwrap()
244 .map(|a| a.id),
245 Some(app.id)
246 );
247 }
248
249 #[tokio::test]
250 async fn the_keys_secret_is_a_separate_credential_from_the_api_key() {
251 let db = TestDb::new().await;
252 let user = seed_user(&db.pool, "skapp_secret").await;
253 let app = synckit::create_sync_app(&db.pool, user, "secret", "sk_app_api_key", None, None)
254 .await
255 .unwrap();
256
257 assert!(
258 synckit::get_sync_app_by_keys_secret(&db.pool, "sk_app_api_key")
259 .await
260 .unwrap()
261 .is_none(),
262 "the api key ships in every client binary, so it may not open the \
263 server-to-server routes"
264 );
265
266 synckit::set_sync_app_keys_secret(&db.pool, app.id, "ks_first_secret")
267 .await
268 .unwrap();
269 assert_eq!(
270 synckit::get_sync_app_by_keys_secret(&db.pool, "ks_first_secret")
271 .await
272 .unwrap()
273 .map(|a| a.id),
274 Some(app.id)
275 );
276
277 synckit::set_sync_app_keys_secret(&db.pool, app.id, "ks_second_secret")
278 .await
279 .unwrap();
280 assert!(
281 synckit::get_sync_app_by_keys_secret(&db.pool, "ks_first_secret")
282 .await
283 .unwrap()
284 .is_none(),
285 "rotation is immediate and unversioned"
286 );
287
288 sqlx::query("UPDATE sync_apps SET is_active = false WHERE id = $1")
289 .bind(app.id)
290 .execute(&db.pool)
291 .await
292 .unwrap();
293 assert!(
294 synckit::get_sync_app_by_keys_secret(&db.pool, "ks_second_secret")
295 .await
296 .unwrap()
297 .is_none(),
298 "a deactivated app authenticates nothing"
299 );
300 }
301
302 // ── subscriptions ───────────────────────────────────────────────────────────
303
304 #[tokio::test]
305 async fn only_a_first_party_app_gates_writes_on_a_subscription() {
306 let db = TestDb::new().await;
307 let user = seed_user(&db.pool, "sksub_gate").await;
308 let developer_app = seed_app(&db.pool, user, "gatedev").await;
309 let internal_app = seed_app(&db.pool, user, "gateint").await;
310 make_internal(&db.pool, internal_app).await;
311
312 assert!(
313 synckit::internal_write_allowed(&db.pool, developer_app, user)
314 .await
315 .unwrap(),
316 "a developer-billed app's users never hold a subscription of their own"
317 );
318 assert!(
319 !synckit::internal_write_allowed(&db.pool, internal_app, user)
320 .await
321 .unwrap(),
322 "first-party sync is paid-only"
323 );
324
325 seed_active_subscription(&db.pool, user, internal_app, "sub_gate", 1_000).await;
326 assert!(
327 synckit::internal_write_allowed(&db.pool, internal_app, user)
328 .await
329 .unwrap()
330 );
331
332 synckit::update_app_sync_subscription_status(&db.pool, "sub_gate", "canceled", None)
333 .await
334 .unwrap();
335 assert!(
336 !synckit::internal_write_allowed(&db.pool, internal_app, user)
337 .await
338 .unwrap(),
339 "a canceled subscription closes writes again"
340 );
341
342 assert!(
343 !synckit::internal_write_allowed(&db.pool, SyncAppId::new(), user)
344 .await
345 .unwrap(),
346 "an app that does not exist denies rather than defaults open"
347 );
348 }
349
350 #[tokio::test]
351 async fn a_canceled_subscription_is_not_revived_by_a_late_webhook() {
352 let db = TestDb::new().await;
353 let user = seed_user(&db.pool, "sksub_terminal").await;
354 let app = seed_app(&db.pool, user, "subterminal").await;
355 seed_active_subscription(&db.pool, user, app, "sub_terminal", 1_000).await;
356
357 synckit::update_app_sync_subscription_status(&db.pool, "sub_terminal", "canceled", None)
358 .await
359 .unwrap();
360 // An `invoice.paid` that took the long way round arrives after the delete.
361 synckit::update_app_sync_subscription_status(
362 &db.pool,
363 "sub_terminal",
364 "active",
365 Some(1_800_000_000),
366 )
367 .await
368 .unwrap();
369
370 let sub = synckit::get_user_app_subscription(&db.pool, user, app)
371 .await
372 .unwrap()
373 .expect("row still there");
374 assert_eq!(sub.status, "canceled", "canceled is terminal here");
375 assert!(
376 sub.current_period_end.is_none(),
377 "and the refused update stamped no period either"
378 );
379 }
380
381 #[tokio::test]
382 async fn a_thin_webhook_period_is_dropped_rather_than_stamped() {
383 let db = TestDb::new().await;
384 let user = seed_user(&db.pool, "sksub_epoch").await;
385 let app = seed_app(&db.pool, user, "subepoch").await;
386 seed_active_subscription(&db.pool, user, app, "sub_epoch", 1_000).await;
387
388 synckit::update_app_sync_subscription_status(
389 &db.pool,
390 "sub_epoch",
391 "active",
392 Some(1_800_000_000),
393 )
394 .await
395 .unwrap();
396 let good = synckit::get_user_app_subscription(&db.pool, user, app)
397 .await
398 .unwrap()
399 .unwrap()
400 .current_period_end
401 .expect("a real period lands");
402
403 for thin in [None, Some(0), Some(-5)] {
404 synckit::update_app_sync_subscription_status(&db.pool, "sub_epoch", "active", thin)
405 .await
406 .unwrap();
407 assert_eq!(
408 synckit::get_user_app_subscription(&db.pool, user, app)
409 .await
410 .unwrap()
411 .unwrap()
412 .current_period_end,
413 Some(good),
414 "a zero or missing period keeps the live one, never a 1970 stamp"
415 );
416 }
417 }
418
419 #[tokio::test]
420 async fn a_queued_cap_change_lands_only_at_the_period_roll() {
421 let db = TestDb::new().await;
422 let user = seed_user(&db.pool, "sksub_cap").await;
423 let app = seed_app(&db.pool, user, "subcap").await;
424 seed_active_subscription(&db.pool, user, app, "sub_cap", 1_000).await;
425
426 synckit::set_pending_storage_cap(&db.pool, user, app, 500)
427 .await
428 .unwrap();
429 let sub = synckit::get_user_app_subscription(&db.pool, user, app)
430 .await
431 .unwrap()
432 .unwrap();
433 assert_eq!(
434 sub.storage_limit_bytes,
435 Some(1_000),
436 "the user paid for this period and keeps it"
437 );
438 assert_eq!(sub.pending_storage_limit_bytes, Some(500));
439
440 synckit::apply_pending_storage_cap(&db.pool, "sub_cap")
441 .await
442 .unwrap();
443 let sub = synckit::get_user_app_subscription(&db.pool, user, app)
444 .await
445 .unwrap()
446 .unwrap();
447 assert_eq!(sub.storage_limit_bytes, Some(500), "the roll promotes it");
448 assert_eq!(sub.pending_storage_limit_bytes, None);
449
450 // A second renewal must not re-apply anything.
451 synckit::apply_pending_storage_cap(&db.pool, "sub_cap")
452 .await
453 .unwrap();
454 let sub = synckit::get_user_app_subscription(&db.pool, user, app)
455 .await
456 .unwrap()
457 .unwrap();
458 assert_eq!(sub.storage_limit_bytes, Some(500));
459 assert_eq!(sub.pending_storage_limit_bytes, None);
460 }
461
462 #[tokio::test]
463 async fn raising_the_cap_now_clears_the_queued_change() {
464 let db = TestDb::new().await;
465 let user = seed_user(&db.pool, "sksub_now").await;
466 let app = seed_app(&db.pool, user, "subnow").await;
467 seed_active_subscription(&db.pool, user, app, "sub_now", 1_000).await;
468 synckit::set_pending_storage_cap(&db.pool, user, app, 500)
469 .await
470 .unwrap();
471
472 // The user is blocked by a full cap and buys more; the price is re-quoted
473 // against Stripe with prorations, so the storage arrives with the charge.
474 synckit::set_storage_cap_now(&db.pool, user, app, 4_000)
475 .await
476 .unwrap();
477
478 let sub = synckit::get_user_app_subscription(&db.pool, user, app)
479 .await
480 .unwrap()
481 .unwrap();
482 assert_eq!(sub.storage_limit_bytes, Some(4_000));
483 assert_eq!(
484 sub.pending_storage_limit_bytes, None,
485 "the queued decrease is gone, not waiting to undo the purchase"
486 );
487 assert_eq!(
488 synckit::get_subscription_by_stripe_id(&db.pool, "sub_now")
489 .await
490 .unwrap(),
491 Some((user, app))
492 );
493 }
494
495 #[tokio::test]
496 async fn a_subscription_is_read_back_per_user_and_per_app() {
497 let db = TestDb::new().await;
498 let alice = seed_user(&db.pool, "sksub_alice").await;
499 let bob = seed_user(&db.pool, "sksub_bob").await;
500 let app = seed_app(&db.pool, alice, "subscope").await;
501 let other_app = seed_app(&db.pool, alice, "subscope2").await;
502 seed_active_subscription(&db.pool, alice, app, "sub_scope", 1_000).await;
503
504 assert!(
505 synckit::get_user_app_subscription(&db.pool, bob, app)
506 .await
507 .unwrap()
508 .is_none()
509 );
510 assert!(
511 synckit::get_user_app_subscription(&db.pool, alice, other_app)
512 .await
513 .unwrap()
514 .is_none(),
515 "paying for one app is not paying for another"
516 );
517 }
518
519 // ── security ────────────────────────────────────────────────────────────────
520
521 #[tokio::test]
522 async fn the_audit_log_appends_and_keeps_what_it_recorded() {
523 let db = TestDb::new().await;
524 let user = seed_user(&db.pool, "sksec_audit").await;
525 let app = seed_app(&db.pool, user, "secaudit").await;
526
527 synckit::record_security_event(
528 &db.pool,
529 app,
530 Some(user),
531 synckit::sync_security_event::DEVICE_REMOVED,
532 Some(serde_json::json!({ "device_id": 7 })),
533 Some("203.0.113.9"),
534 )
535 .await
536 .unwrap();
537 // A denied auth may not know who was knocking; the row still has to land.
538 synckit::record_security_event(
539 &db.pool,
540 app,
541 None,
542 synckit::sync_security_event::AUTH_FAILURE,
543 None,
544 None,
545 )
546 .await
547 .unwrap();
548
549 let rows: Vec<(
550 String,
551 Option<UserId>,
552 Option<serde_json::Value>,
553 Option<String>,
554 )> = sqlx::query_as(
555 "SELECT event_type, user_id, detail, ip FROM sync_security_events
556 WHERE app_id = $1 ORDER BY id",
557 )
558 .bind(app)
559 .fetch_all(&db.pool)
560 .await
561 .unwrap();
562 assert_eq!(rows.len(), 2, "an append, not an upsert: {rows:?}");
563 assert_eq!(rows[0].0, "device_removed");
564 assert_eq!(rows[0].1, Some(user));
565 assert_eq!(rows[0].2, Some(serde_json::json!({ "device_id": 7 })));
566 assert_eq!(rows[0].3.as_deref(), Some("203.0.113.9"));
567 assert_eq!(rows[1].0, "auth_failure");
568 assert_eq!(rows[1].1, None, "an unknown subject is recorded as unknown");
569 }
570
571 #[tokio::test]
572 async fn revoking_sync_tokens_touches_only_the_named_user() {
573 let db = TestDb::new().await;
574 let alice = seed_user(&db.pool, "sksec_alice").await;
575 let bob = seed_user(&db.pool, "sksec_bob").await;
576
577 synckit::invalidate_user_sync_tokens(&db.pool, alice)
578 .await
579 .unwrap();
580
581 let stamped: Option<chrono::DateTime<chrono::Utc>> =
582 sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1")
583 .bind(alice)
584 .fetch_one(&db.pool)
585 .await
586 .unwrap();
587 assert!(
588 stamped.is_some(),
589 "alice's sync sessions must re-authenticate"
590 );
591 let untouched: Option<chrono::DateTime<chrono::Utc>> =
592 sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1")
593 .bind(bob)
594 .fetch_one(&db.pool)
595 .await
596 .unwrap();
597 assert!(untouched.is_none(), "and nobody else's");
598 }
599