Skip to main content

max / makenotwork

Pin the SyncKit db layer that only route tests had reached db/synckit's rotation, groups and invitations modules each have a db-layer contract test; log, blobs, keys, devices, apps, subscriptions and security had none, and were exercised only through the HTTP workflows. A route test authenticates as one user, so the owner scoping these queries all carry could not be asserted from there at all. Two new sibling modules, 23 tests against real Postgres: append order and no-drop, cursor paging returning each entry once, a replayed batch appending nothing, blob owner scoping and re-confirm dedup, the optimistic-concurrency key upsert, the prune and compaction guards, device upsert and cursor monotonicity, api-key and keys-secret rotation, the write gate, the terminal-canceled and thin-period webhook guards, cap queue and promote, and the audit log append. Left to the tests that already cover them: the pull filters, the quota and blob-delete paths, compaction against a fresh device, and sync revocation sparing the website session.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 22:48 UTC
Signed with PGP, not checked
Commit: 886ecbe95bc251ba599acfcc5c71dcf69586c7bf
Parent: 20deb52
3 files changed, +962 insertions, -0 deletions
@@ -40,9 +40,11 @@
40 40 mod db_scan_jobs_layer;
41 41 mod db_scanning_layer;
42 42 mod db_ssh_keys_layer;
43 + mod db_synckit_accounts_layer;
43 44 mod db_synckit_billing_layer;
44 45 mod db_synckit_groups;
45 46 mod db_synckit_invitations;
47 + mod db_synckit_layer;
46 48 mod db_synckit_rotation;
47 49 mod db_transactions_layer;
48 50 mod db_users_layer;
@@ -1,0 +1,598 @@
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;
Lines truncated
@@ -1,0 +1,460 @@
1 + //! DB-layer contract tests for the SyncKit change log, blobs and keys
2 + //! (`db::synckit::{log, blobs, keys}`), which had none of their own.
3 + //!
4 + //! Its siblings (`db_synckit_rotation`, `db_synckit_groups`,
5 + //! `db_synckit_invitations`) already pin the rotation state machine, the group
6 + //! changelog and the invitation lifecycle at this layer;
7 + //! `db_synckit_accounts_layer` covers devices, apps, subscriptions and the
8 + //! audit log. What was reachable only through the HTTP workflows is the owner
9 + //! scoping every one of these queries carries, the `app_id = $1 AND
10 + //! user_id = $2` pair: a route test authenticates as one user, so it cannot
11 + //! see a query that returns another user's rows.
12 + //!
13 + //! Deliberately not re-asserted here, because it is covered elsewhere: the
14 + //! table and `since` pull filters (`synckit_selective`), the storage quota and
15 + //! blob-delete paths through a real subscription (`synckit_paid_sync`,
16 + //! `synckit_per_key_storage`), and compaction against a freshly registered
17 + //! device (`synckit_paid_sync`).
18 +
19 + use super::db_synckit_accounts_layer::seed_active_subscription;
20 + use crate::harness::db::TestDb;
21 + use crate::harness::seed_user;
22 +
23 + use makenotwork::db::synckit;
24 + use makenotwork::db::synckit::BlobConfirm;
25 + use makenotwork::db::{SyncAppId, SyncDeviceId, SyncPlatform, UserId};
26 + use uuid::Uuid;
27 +
28 + /// Seed a sync app owned by `user`, with its usage row.
29 + async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
30 + synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None)
31 + .await
32 + .expect("seed sync app")
33 + .id
34 + }
35 +
36 + /// Mark an app first-party, so the end-user subscription model applies.
37 + async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) {
38 + sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1")
39 + .bind(app)
40 + .execute(pool)
41 + .await
42 + .expect("mark internal");
43 + }
44 +
45 + async fn seed_device(
46 + pool: &sqlx::PgPool,
47 + app: SyncAppId,
48 + user: UserId,
49 + name: &str,
50 + ) -> SyncDeviceId {
51 + synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Macos, None)
52 + .await
53 + .expect("seed device")
54 + .id
55 + }
56 +
57 + /// One INSERT change tuple in the shape `push_sync_changes` expects.
58 + fn change(
59 + table: &str,
60 + row: &str,
61 + ) -> (
62 + String,
63 + String,
64 + String,
65 + chrono::DateTime<chrono::Utc>,
66 + Option<serde_json::Value>,
67 + ) {
68 + (
69 + table.to_string(),
70 + "INSERT".to_string(),
71 + row.to_string(),
72 + chrono::Utc::now(),
73 + Some(serde_json::json!({ "row": row })),
74 + )
75 + }
76 +
77 + // ── log ─────────────────────────────────────────────────────────────────────
78 +
79 + #[tokio::test]
80 + async fn an_append_keeps_every_entry_and_the_order_it_arrived_in() {
81 + let db = TestDb::new().await;
82 + let user = seed_user(&db.pool, "sklog_append").await;
83 + let app = seed_app(&db.pool, user, "logappend").await;
84 + let device = seed_device(&db.pool, app, user, "laptop").await;
85 +
86 + let changes: Vec<_> = (0..5).map(|i| change("tasks", &format!("r{i}"))).collect();
87 + let cursor = synckit::push_sync_changes(&db.pool, app, user, device, Uuid::new_v4(), &changes)
88 + .await
89 + .unwrap();
90 +
91 + let entries = synckit::pull_sync_changes(&db.pool, app, user, 0, 100)
92 + .await
93 + .unwrap();
94 + assert_eq!(entries.len(), 5, "an append drops nothing");
95 + let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect();
96 + assert_eq!(rows, ["r0", "r1", "r2", "r3", "r4"], "and reorders nothing");
97 + assert!(
98 + entries.windows(2).all(|w| w[0].seq < w[1].seq),
99 + "seq is strictly increasing: {:?}",
100 + entries.iter().map(|e| e.seq).collect::<Vec<_>>()
101 + );
102 + assert_eq!(
103 + cursor,
104 + entries.last().unwrap().seq,
105 + "the returned cursor is the highest seq assigned"
106 + );
107 + }
108 +
109 + #[tokio::test]
110 + async fn cursor_paging_returns_each_entry_exactly_once() {
111 + let db = TestDb::new().await;
112 + let user = seed_user(&db.pool, "sklog_page").await;
113 + let app = seed_app(&db.pool, user, "logpage").await;
114 + let device = seed_device(&db.pool, app, user, "laptop").await;
115 +
116 + for i in 0..5 {
117 + synckit::push_sync_changes(
118 + &db.pool,
119 + app,
120 + user,
121 + device,
122 + Uuid::new_v4(),
123 + &[change("tasks", &format!("r{i}"))],
124 + )
125 + .await
126 + .unwrap();
127 + }
128 +
129 + // Walk the log two at a time the way a client does, carrying the last seq
130 + // forward as the next cursor.
131 + let mut seen: Vec<String> = Vec::new();
132 + let mut cursor = 0i64;
133 + loop {
134 + let page = synckit::pull_sync_changes(&db.pool, app, user, cursor, 2)
135 + .await
136 + .unwrap();
137 + if page.is_empty() {
138 + break;
139 + }
140 + cursor = page.last().unwrap().seq;
141 + seen.extend(page.into_iter().map(|e| e.row_id));
142 + assert!(seen.len() <= 5, "paging must terminate: {seen:?}");
143 + }
144 + assert_eq!(
145 + seen,
146 + ["r0", "r1", "r2", "r3", "r4"],
147 + "every entry once, in order, with no gap at a page boundary"
148 + );
149 + }
150 +
151 + #[tokio::test]
152 + async fn a_replayed_batch_appends_nothing_and_returns_the_same_cursor() {
153 + let db = TestDb::new().await;
154 + let user = seed_user(&db.pool, "sklog_replay").await;
155 + let app = seed_app(&db.pool, user, "logreplay").await;
156 + let device = seed_device(&db.pool, app, user, "laptop").await;
157 +
158 + let batch = Uuid::new_v4();
159 + let changes = [change("tasks", "r0"), change("tasks", "r1")];
160 + let first = synckit::push_sync_changes(&db.pool, app, user, device, batch, &changes)
161 + .await
162 + .unwrap();
163 + // The client never saw the response and retried the same batch id.
164 + let second = synckit::push_sync_changes(&db.pool, app, user, device, batch, &changes)
165 + .await
166 + .unwrap();
167 +
168 + assert_eq!(first, second, "at most once: the same cursor comes back");
169 + let entries = synckit::pull_sync_changes(&db.pool, app, user, 0, 100)
170 + .await
171 + .unwrap();
172 + assert_eq!(entries.len(), 2, "the retry inserted nothing: {entries:?}");
173 + }
174 +
175 + #[tokio::test]
176 + async fn a_pull_never_reaches_another_user_or_another_app() {
177 + let db = TestDb::new().await;
178 + let alice = seed_user(&db.pool, "sklog_alice").await;
179 + let bob = seed_user(&db.pool, "sklog_bob").await;
180 + let app = seed_app(&db.pool, alice, "logscope").await;
181 + let other_app = seed_app(&db.pool, alice, "logscope2").await;
182 + let alice_dev = seed_device(&db.pool, app, alice, "alice-laptop").await;
183 + let bob_dev = seed_device(&db.pool, app, bob, "bob-laptop").await;
184 + let alice_other_dev = seed_device(&db.pool, other_app, alice, "alice-phone").await;
185 +
186 + synckit::push_sync_changes(
187 + &db.pool,
188 + app,
189 + alice,
190 + alice_dev,
191 + Uuid::new_v4(),
192 + &[change("tasks", "alice")],
193 + )
194 + .await
195 + .unwrap();
196 + synckit::push_sync_changes(
197 + &db.pool,
198 + app,
199 + bob,
200 + bob_dev,
201 + Uuid::new_v4(),
202 + &[change("tasks", "bob")],
203 + )
204 + .await
205 + .unwrap();
206 + synckit::push_sync_changes(
207 + &db.pool,
208 + other_app,
209 + alice,
210 + alice_other_dev,
211 + Uuid::new_v4(),
212 + &[change("tasks", "alice-other-app")],
213 + )
214 + .await
215 + .unwrap();
216 +
217 + let alice_entries = synckit::pull_sync_changes(&db.pool, app, alice, 0, 100)
218 + .await
219 + .unwrap();
220 + let rows: Vec<&str> = alice_entries.iter().map(|e| e.row_id.as_str()).collect();
221 + assert_eq!(
222 + rows,
223 + ["alice"],
224 + "one user's log is one user's, per app: {rows:?}"
225 + );
226 + }
227 +
228 + // ── blobs ───────────────────────────────────────────────────────────────────
229 +
230 + #[tokio::test]
231 + async fn a_blob_is_scoped_to_the_user_who_stored_it() {
232 + let db = TestDb::new().await;
233 + let alice = seed_user(&db.pool, "skblob_alice").await;
234 + let bob = seed_user(&db.pool, "skblob_bob").await;
235 + let app = seed_app(&db.pool, alice, "blobscope").await;
236 + make_internal(&db.pool, app).await;
237 + seed_active_subscription(&db.pool, alice, app, "sub_blob_alice", 1_000_000).await;
238 + seed_active_subscription(&db.pool, bob, app, "sub_blob_bob", 1_000_000).await;
239 +
240 + let stored =
241 + synckit::confirm_internal_blob(&db.pool, app, alice, "hash-a", 400, "s3/alice", "default")
242 + .await
243 + .unwrap();
244 + assert_eq!(stored, BlobConfirm::Stored);
245 +
246 + assert!(
247 + synckit::get_sync_blob_by_hash(&db.pool, app, alice, "hash-a")
248 + .await
249 + .unwrap()
250 + .is_some()
251 + );
252 + assert!(
253 + synckit::get_sync_blob_by_hash(&db.pool, app, bob, "hash-a")
254 + .await
255 + .unwrap()
256 + .is_none(),
257 + "the hash is the same bytes, but it is not bob's row"
258 + );
259 + assert_eq!(
260 + synckit::storage_used_bytes(&db.pool, app, bob)
261 + .await
262 + .unwrap(),
263 + 0,
264 + "and it is not charged to bob's quota"
265 + );
266 + assert_eq!(
267 + synckit::storage_used_bytes(&db.pool, app, alice)
268 + .await
269 + .unwrap(),
270 + 400
271 + );
272 + }
273 +
274 + #[tokio::test]
275 + async fn re_confirming_the_same_hash_stores_one_row_and_charges_once() {
276 + let db = TestDb::new().await;
277 + let user = seed_user(&db.pool, "skblob_dedup").await;
278 + let app = seed_app(&db.pool, user, "blobdedup").await;
279 + make_internal(&db.pool, app).await;
280 + seed_active_subscription(&db.pool, user, app, "sub_blob_dedup", 1_000).await;
281 +
282 + let first =
283 + synckit::confirm_internal_blob(&db.pool, app, user, "hash-x", 400, "s3/x", "default")
284 + .await
285 + .unwrap();
286 + let second =
287 + synckit::confirm_internal_blob(&db.pool, app, user, "hash-x", 400, "s3/x", "default")
288 + .await
289 + .unwrap();
290 + assert_eq!(first, BlobConfirm::Stored);
291 + assert_eq!(
292 + second,
293 + BlobConfirm::AlreadyStored,
294 + "content-addressed: the same hash is the same object"
295 + );
296 +
297 + assert_eq!(
298 + synckit::storage_used_bytes(&db.pool, app, user)
299 + .await
300 + .unwrap(),
301 + 400,
302 + "a re-upload must not count twice, or a retry would eat the cap"
303 + );
304 + let rows: i64 = sqlx::query_scalar(
305 + "SELECT COUNT(*) FROM sync_blobs WHERE app_id = $1 AND user_id = $2 AND hash = 'hash-x'",
306 + )
307 + .bind(app)
308 + .bind(user)
309 + .fetch_one(&db.pool)
310 + .await
311 + .unwrap();
312 + assert_eq!(rows, 1);
313 + }
314 +
315 + // ── keys ────────────────────────────────────────────────────────────────────
316 +
317 + #[tokio::test]
318 + async fn a_key_upsert_takes_only_the_version_the_caller_expected() {
319 + let db = TestDb::new().await;
320 + let user = seed_user(&db.pool, "skkey_occ").await;
321 + let app = seed_app(&db.pool, user, "keyocc").await;
322 +
323 + assert!(
324 + synckit::upsert_sync_key(&db.pool, app, user, "env_v1", 0)
325 + .await
326 + .unwrap(),
327 + "the first key inserts"
328 + );
329 + let info = synckit::get_sync_key(&db.pool, app, user)
330 + .await
331 + .unwrap()
332 + .expect("key exists");
333 + assert_eq!(info.encrypted_key, "env_v1");
334 + assert_eq!(info.key_version, 1);
335 + assert!(info.pending_key.is_none());
336 +
337 + // A second device that still believes it is at version 0 loses.
338 + assert!(
339 + !synckit::upsert_sync_key(&db.pool, app, user, "env_stale", 0)
340 + .await
341 + .unwrap(),
342 + "a stale expected_version is a conflict, not a write"
343 + );
344 + assert_eq!(
345 + synckit::get_sync_key(&db.pool, app, user)
346 + .await
347 + .unwrap()
348 + .unwrap()
349 + .encrypted_key,
350 + "env_v1",
351 + "and the losing envelope must not have landed"
352 + );
353 +
354 + assert!(
355 + synckit::upsert_sync_key(&db.pool, app, user, "env_v2", 1)
356 + .await
357 + .unwrap()
358 + );
359 + let info = synckit::get_sync_key(&db.pool, app, user)
360 + .await
361 + .unwrap()
362 + .unwrap();
363 + assert_eq!(info.encrypted_key, "env_v2");
364 + assert_eq!(info.key_version, 2);
365 + }
366 +
367 + #[tokio::test]
368 + async fn a_key_belongs_to_one_user_within_one_app() {
369 + let db = TestDb::new().await;
370 + let alice = seed_user(&db.pool, "skkey_alice").await;
371 + let bob = seed_user(&db.pool, "skkey_bob").await;
372 + let app = seed_app(&db.pool, alice, "keyscope").await;
373 + synckit::upsert_sync_key(&db.pool, app, alice, "alice_env", 0)
374 + .await
375 + .unwrap();
376 +
377 + assert!(
378 + synckit::get_sync_key(&db.pool, app, bob)
379 + .await
380 + .unwrap()
381 + .is_none(),
382 + "bob has no key here, and must not be handed alice's envelope"
383 + );
384 + }
385 +
386 + #[tokio::test]
387 + async fn pruning_refuses_a_non_positive_horizon() {
388 + let db = TestDb::new().await;
389 + let user = seed_user(&db.pool, "skkey_prune").await;
390 + let app = seed_app(&db.pool, user, "keyprune").await;
391 + let device = seed_device(&db.pool, app, user, "laptop").await;
392 + synckit::push_sync_changes(
393 + &db.pool,
394 + app,
395 + user,
396 + device,
397 + Uuid::new_v4(),
398 + &[change("tasks", "r0")],
399 + )
400 + .await
401 + .unwrap();
402 +
403 + for horizon in [0, -1] {
404 + assert_eq!(
405 + synckit::prune_sync_log(&db.pool, horizon).await.unwrap(),
406 + 0,
407 + "a zero or negative retention is a mistake, not an instruction to \
408 + delete the whole log"
409 + );
410 + }
411 + assert_eq!(
412 + synckit::pull_sync_changes(&db.pool, app, user, 0, 100)
413 + .await
414 + .unwrap()
415 + .len(),
416 + 1
417 + );
418 + // A real horizon spares entries inside it.
419 + assert_eq!(synckit::prune_sync_log(&db.pool, 30).await.unwrap(), 0);
420 + }
421 +
422 + #[tokio::test]
423 + async fn compaction_holds_back_a_log_no_device_has_pulled() {
424 + let db = TestDb::new().await;
425 + let user = seed_user(&db.pool, "skkey_compact").await;
426 + let app = seed_app(&db.pool, user, "keycompact").await;
427 + let device = seed_device(&db.pool, app, user, "laptop").await;
428 + synckit::push_sync_changes(
429 + &db.pool,
430 + app,
431 + user,
432 + device,
433 + Uuid::new_v4(),
434 + &[change("tasks", "r0")],
435 + )
436 + .await
437 + .unwrap();
438 +
439 + assert_eq!(
440 + synckit::compact_sync_log(&db.pool, app, user, 0)
441 + .await
442 + .unwrap(),
443 + 0,
444 + "a zero-day safety margin compacts nothing"
445 + );
446 + assert_eq!(
447 + synckit::compact_sync_log(&db.pool, app, user, 7)
448 + .await
449 + .unwrap(),
450 + 0,
451 + "the device sits at cursor 0, so nothing is known-pulled"
452 + );
453 + assert_eq!(
454 + synckit::pull_sync_changes(&db.pool, app, user, 0, 100)
455 + .await
456 + .unwrap()
457 + .len(),
458 + 1
459 + );
460 + }