Skip to main content

max / makenotwork

test: SyncKit adversarial test suite New workflows/synckit_adversarial.rs (~400 lines) plus unit-level edge-case tests across synckit_auth, synckit_billing, and validation. All tests, no production changes. Adversarial workflow tests probe: - hostile JWT payloads (empty key, oversized key, null bytes, future iat, app_id/user_id mismatch) - pricing pathology (u32::MAX dimensions, zero-dim drops to floor) - mode transitions under contention - drift-job behavior with malformed sync_key_usage rows Unit-level additions: - synckit_auth: documents the decode-layer contract — empty/long/ null-byte keys round-trip through JWT and rely on the extractor (SyncUser::from_request_parts) to reject. Tests pin this so future changes can't quietly shift defense layers. - synckit_billing: u32::MAX saturation paths (Rust f64-as-i64 cast saturates, doesn't UB), zero-dimension floor behavior. - validation: validate_synckit_key boundary conditions (max length is bytes not chars, multibyte chars eat budget, controls except tab are rejected, unicode is opaque/allowed).
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 04:08 UTC
Commit: b7a993b7295f0362c3cd558a32ff351275b695e9
Parent: e36f906
6 files changed, +636 insertions, -0 deletions
@@ -303,6 +303,45 @@ mod tests {
303 303 }
304 304
305 305 #[test]
306 + fn empty_key_decodes_but_extractor_must_reject() {
307 + // `decode_sync_token` does NOT enforce non-empty `key` — the only line
308 + // of defense is `SyncUser::from_request_parts`. This test pins the
309 + // decode-layer contract; if you ever add empty-key rejection here,
310 + // also remove the extractor check (or this test).
311 + let user_id = UserId::new();
312 + let app_id = SyncAppId::new();
313 + let token = create_sync_token(TEST_SECRET, user_id, app_id, "").unwrap();
314 + let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
315 + assert!(claims.key.is_empty(), "decode must preserve empty key for extractor to filter");
316 + }
317 +
318 + #[test]
319 + fn very_long_key_round_trips_through_jwt() {
320 + // No length cap inside the JWT layer — the SDK key field is opaque
321 + // here. Caller (sync_auth route) validates via validate_synckit_key,
322 + // but a directly-minted token can carry an arbitrary string. This test
323 + // documents that: the decode layer does NOT bound key length.
324 + let user_id = UserId::new();
325 + let app_id = SyncAppId::new();
326 + let huge = "x".repeat(10_000);
327 + let token = create_sync_token(TEST_SECRET, user_id, app_id, &huge).unwrap();
328 + let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
329 + assert_eq!(claims.key.len(), 10_000);
330 + }
331 +
332 + #[test]
333 + fn key_with_null_bytes_round_trips_through_jwt() {
334 + // Same: null bytes survive the JWT round-trip. The /api/sync/auth
335 + // route blocks via validate_synckit_key; the extractor does not.
336 + let user_id = UserId::new();
337 + let app_id = SyncAppId::new();
338 + let bad = "abc\0def";
339 + let token = create_sync_token(TEST_SECRET, user_id, app_id, bad).unwrap();
340 + let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
341 + assert_eq!(claims.key, bad);
342 + }
343 +
344 + #[test]
306 345 fn token_with_future_iat_accepted() {
307 346 let user_id = UserId::new();
308 347 let app_id = SyncAppId::new();
@@ -128,4 +128,43 @@ mod tests {
128 128 fn storage_cap_in_bytes() {
129 129 assert_eq!(storage_cap_bytes(10), 10 * 1024 * 1024 * 1024);
130 130 }
131 +
132 + // ── Edge cases (test-fuzz) ──
133 +
134 + #[test]
135 + fn pricing_at_u32_max_does_not_panic() {
136 + // u32::MAX GB × 3¢ ≈ 1.3e10 cents, fits in i64. The cast must not panic.
137 + let p = monthly_price_cents("bulk", Some(u32::MAX), None, None);
138 + assert!(p > 0, "huge price should be positive, got {p}");
139 + }
140 +
141 + #[test]
142 + fn per_key_pricing_at_u32_max_saturates_cleanly() {
143 + // u32::MAX × u32::MAX overflows f64 precision but Rust's f64-as-i64 cast
144 + // saturates at i64::MAX rather than UB. Must not panic.
145 + let p = monthly_price_cents("per_key", None, Some(u32::MAX), Some(u32::MAX));
146 + assert!(p > 0, "saturated price should still be positive, got {p}");
147 + }
148 +
149 + #[test]
150 + fn storage_cap_at_u32_max_fits_in_i64() {
151 + // u32::MAX × 2^30 = ~4.6e18, well under i64::MAX (~9.2e18).
152 + let bytes = storage_cap_bytes(u32::MAX);
153 + assert!(bytes > 0, "u32::MAX GB should produce a positive i64");
154 + assert_eq!(bytes, (u32::MAX as i64) * 1024 * 1024 * 1024);
155 + }
156 +
157 + #[test]
158 + fn bulk_with_zero_gb_drops_to_floor() {
159 + // Defensive: validate_knobs rejects gb=0 at the route layer, but the
160 + // pure function should still produce the floor rather than 0.
161 + assert_eq!(monthly_price_cents("bulk", Some(0), None, None), BASE_FLOOR_CENTS);
162 + }
163 +
164 + #[test]
165 + fn per_key_one_dimension_zero_drops_to_floor() {
166 + // If only one of key_cap/gb_per_key is 0, the product is 0 → floor.
167 + assert_eq!(monthly_price_cents("per_key", None, Some(0), Some(10)), BASE_FLOOR_CENTS);
168 + assert_eq!(monthly_price_cents("per_key", None, Some(10), Some(0)), BASE_FLOOR_CENTS);
169 + }
131 170 }
@@ -358,6 +358,48 @@ mod tests {
358 358 assert!(validate_sync_row_id("row-123-abc").is_ok());
359 359 }
360 360
361 + // ── SDK key validation (test-fuzz) ──
362 +
363 + #[test]
364 + fn test_validate_synckit_key_basic() {
365 + assert!(validate_synckit_key("user-42").is_ok());
366 + assert!(validate_synckit_key("workspace/team-1").is_ok());
367 + assert!(validate_synckit_key("a").is_ok()); // single char is fine
368 + assert!(validate_synckit_key("").is_err());
369 + }
370 +
371 + #[test]
372 + fn test_validate_synckit_key_length_boundaries() {
373 + let at_max = "k".repeat(limits::SYNC_KEY_MAX);
374 + let over_max = "k".repeat(limits::SYNC_KEY_MAX + 1);
375 + assert!(validate_synckit_key(&at_max).is_ok(), "exact max must pass");
376 + assert!(validate_synckit_key(&over_max).is_err(), "over max must fail");
377 + }
378 +
379 + #[test]
380 + fn test_validate_synckit_key_null_and_controls() {
381 + assert!(validate_synckit_key("a\0b").is_err());
382 + assert!(validate_synckit_key("a\x01b").is_err());
383 + assert!(validate_synckit_key("a\x1Fb").is_err()); // unit separator
384 + // Tabs are permitted, mirroring validate_sync_row_id.
385 + assert!(validate_synckit_key("a\tb").is_ok());
386 + }
387 +
388 + #[test]
389 + fn test_validate_synckit_key_unicode_allowed() {
390 + // SDK keys are opaque — non-ASCII is fine as long as it's not a control char.
391 + assert!(validate_synckit_key("café").is_ok());
392 + assert!(validate_synckit_key("ユーザー1").is_ok());
393 + }
394 +
395 + #[test]
396 + fn test_validate_synckit_key_oversize_uses_byte_length() {
397 + // SYNC_KEY_MAX is in BYTES (key.len()), not chars. Multibyte chars eat
398 + // more budget. A 100-char emoji string easily exceeds 255 bytes.
399 + let many_emoji = "🦀".repeat(100); // 4 bytes per emoji → 400 bytes
400 + assert!(validate_synckit_key(&many_emoji).is_err());
401 + }
402 +
361 403 // ── Property-based tests (test-fuzz) ──
362 404
363 405 proptest::proptest! {
@@ -10,6 +10,7 @@ mod subscriptions;
10 10 mod synckit;
11 11 mod synckit_billing;
12 12 mod synckit_per_key_storage;
13 + mod synckit_adversarial;
13 14 mod pages;
14 15 mod analytics;
15 16 mod promo_codes_discount;
@@ -0,0 +1,404 @@
1 + //! Adversarial tests for SyncKit v2 billing + per-key storage (v0.7.0+).
2 + //!
3 + //! These tests don't exist to confirm the happy path — they exist to BREAK
4 + //! things. Hostile JWT payloads, pathological pricing inputs, weird mode
5 + //! transitions, drift-job pathology. Each test states what it's trying to
6 + //! attack and what surviving means.
7 +
8 + use crate::harness::{BuildOptions, TestHarness, stripe, storage::InMemoryStorage};
9 + use makenotwork::db::{SyncAppId, UserId};
10 + use serde_json::json;
11 + use sqlx::PgPool;
12 + use std::sync::Arc;
13 +
14 + const GIB: i64 = 1024 * 1024 * 1024;
15 +
16 + async fn harness_with_billing_and_blobs() -> (TestHarness, Arc<InMemoryStorage>) {
17 + let synckit_mem = Arc::new(InMemoryStorage::new());
18 + let mock_stripe = Arc::new(stripe::MockPaymentProvider::new());
19 + let mock_email = Arc::new(crate::harness::email::MockEmailTransport::new());
20 + let mut h = TestHarness::build(BuildOptions {
21 + synckit_storage: Some(synckit_mem.clone()),
22 + stripe_client: Some(mock_stripe.clone()),
23 + mock_email: Some(mock_email),
24 + ..Default::default()
25 + })
26 + .await;
27 + h.mock_stripe = Some(mock_stripe);
28 + (h, synckit_mem)
29 + }
30 +
31 + async fn create_draft_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
32 + let api_key = "test-api-key-adv";
33 + let key_hash = crate::harness::hash_api_key(api_key);
34 + let key_prefix = &api_key[..8];
35 + let app_id: SyncAppId = sqlx::query_scalar(
36 + "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status)
37 + VALUES ($1, 'AdvTest', $2, $3, FALSE, 'draft')
38 + RETURNING id",
39 + )
40 + .bind(user_id)
41 + .bind(&key_hash)
42 + .bind(key_prefix)
43 + .fetch_one(pool)
44 + .await
45 + .expect("insert sync_app");
46 + sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT DO NOTHING")
47 + .bind(app_id)
48 + .execute(pool)
49 + .await
50 + .unwrap();
51 + (app_id, api_key.to_string())
52 + }
53 +
54 + async fn activate_per_key(h: &mut TestHarness, app_id: SyncAppId, key_cap: u32, gb_per_key: u32) {
55 + h.client
56 + .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "")
57 + .await;
58 + let resp = h
59 + .client
60 + .post_json(
61 + &format!("/api/sync/apps/{}/billing/activate", app_id),
62 + &json!({
63 + "enforcement_mode": "per_key",
64 + "key_cap": key_cap,
65 + "gb_per_key": gb_per_key,
66 + })
67 + .to_string(),
68 + )
69 + .await;
70 + assert_eq!(resp.status, 200, "activate per_key: {}", resp.text);
71 + }
72 +
73 + async fn claim_key(h: &mut TestHarness, api_key: &str, key: &str) {
74 + let resp = h
75 + .client
76 + .post_json(
77 + "/api/sync/keys/claim",
78 + &json!({ "api_key": api_key, "key": key }).to_string(),
79 + )
80 + .await;
81 + assert_eq!(resp.status, 200, "claim {}: {}", key, resp.text);
82 + }
83 +
84 + fn auth_as(h: &mut TestHarness, user_id: UserId, app_id: SyncAppId, key: &str) {
85 + let token = makenotwork::synckit_auth::create_sync_token(
86 + "test-synckit-jwt-secret",
87 + user_id,
88 + app_id,
89 + key,
90 + )
91 + .expect("mint test JWT");
92 + h.client.set_bearer_token(&token);
93 + }
94 +
95 + fn fake_hash(seed: u8) -> String {
96 + let mut s = String::with_capacity(64);
97 + for _ in 0..32 {
98 + s.push_str(&format!("{:02x}", seed));
99 + }
100 + s
101 + }
102 +
103 + // ── Attack 1: hostile JWT key payloads ──
104 + //
105 + // The JWT extractor (SyncUser::from_request_parts) only rejects an empty `key`.
106 + // validate_synckit_key (which bans null bytes, oversize, control chars) runs
107 + // only on the /api/sync/auth route. A developer who mints their own JWT
108 + // (allowed: keys come from THEIR backend) can sneak hostile values past every
109 + // validator. These tests prove the rest of the stack survives. Surviving
110 + // means: parameterized queries don't break, presigned URLs build, and no
111 + // route panics or 500s — even if the upload eventually gets rejected.
112 +
113 + #[tokio::test]
114 + async fn adversarial_jwt_key_with_sql_injection_literal() {
115 + // Parameterized queries (sqlx) should treat this as literal data.
116 + let (mut h, blobs) = harness_with_billing_and_blobs().await;
117 + let user_id = h.signup("adv_sql", "adv_sql@example.com", "Password1!").await;
118 + let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
119 + activate_per_key(&mut h, app_id, 5, 1).await;
120 +
121 + let evil = "k'; DROP TABLE sync_apps; --";
122 + claim_key(&mut h, &api_key, evil).await;
123 +
124 + auth_as(&mut h, user_id, app_id, evil);
125 + let hash = fake_hash(0x01);
126 + let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
127 + blobs.put(&s3_key, vec![0u8; 8]);
128 +
129 + let r = h
130 + .client
131 + .post_json(
132 + "/api/sync/blobs/upload",
133 + &json!({ "hash": hash, "size_bytes": 1024 }).to_string(),
134 + )
135 + .await;
136 + assert_eq!(r.status, 200, "upload-url should not 500: {}", r.text);
137 +
138 + let r = h
139 + .client
140 + .post_json(
141 + "/api/sync/blobs/confirm",
142 + &json!({ "hash": hash, "size_bytes": 1024 }).to_string(),
143 + )
144 + .await;
145 + assert_eq!(r.status, 204, "confirm should succeed safely: {}", r.text);
146 +
147 + // Table still exists.
148 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_apps WHERE id = $1")
149 + .bind(app_id)
150 + .fetch_one(&h.db)
151 + .await
152 + .unwrap();
153 + assert_eq!(count, 1, "sync_apps row must still exist after injection-flavored key");
154 +
155 + // Per-key counter row reflects the evil key (literal storage).
156 + let stored: Option<i64> = sqlx::query_scalar(
157 + "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2",
158 + )
159 + .bind(app_id)
160 + .bind(evil)
161 + .fetch_one(&h.db)
162 + .await
163 + .unwrap();
164 + assert_eq!(stored, Some(1024));
165 + }
166 +
167 + #[tokio::test]
168 + async fn adversarial_jwt_key_with_unicode_rtl_and_zero_width() {
169 + // RTL override + zero-width joiner. Should survive end-to-end.
170 + let (mut h, blobs) = harness_with_billing_and_blobs().await;
171 + let user_id = h.signup("adv_u", "adv_u@example.com", "Password1!").await;
172 + let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
173 + activate_per_key(&mut h, app_id, 5, 1).await;
174 +
175 + let weird = "user\u{202E}admin\u{200B}";
176 + claim_key(&mut h, &api_key, weird).await;
177 + auth_as(&mut h, user_id, app_id, weird);
178 +
179 + let hash = fake_hash(0x02);
180 + let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
181 + blobs.put(&s3_key, vec![0u8; 8]);
182 +
183 + h.client
184 + .post_json(
185 + "/api/sync/blobs/upload",
186 + &json!({ "hash": hash, "size_bytes": 16 }).to_string(),
187 + )
188 + .await;
189 + let r = h
190 + .client
191 + .post_json(
192 + "/api/sync/blobs/confirm",
193 + &json!({ "hash": hash, "size_bytes": 16 }).to_string(),
194 + )
195 + .await;
196 + assert_eq!(r.status, 204, "weird unicode key should still upload: {}", r.text);
197 + }
198 +
199 + #[tokio::test]
200 + async fn adversarial_jwt_key_extremely_long() {
201 + // 32 KiB JWT key. The /api/sync/auth route would 400; a directly-minted
202 + // token slips it past the validator. This test pins current behavior so
203 + // that a future bounded-length check on the extractor side will surface
204 + // here as a deliberate change (and not break silently).
205 + let (mut h, blobs) = harness_with_billing_and_blobs().await;
206 + let user_id = h.signup("adv_l", "adv_l@example.com", "Password1!").await;
207 + let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
208 + activate_per_key(&mut h, app_id, 5, 1).await;
209 +
210 + let huge = "k".repeat(32 * 1024);
211 + claim_key(&mut h, &api_key, &huge).await;
212 + auth_as(&mut h, user_id, app_id, &huge);
213 +
214 + let hash = fake_hash(0x03);
215 + let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
216 + blobs.put(&s3_key, vec![0u8; 8]);
217 + h.client
218 + .post_json(
219 + "/api/sync/blobs/upload",
220 + &json!({ "hash": hash, "size_bytes": 32 }).to_string(),
221 + )
222 + .await;
223 + let r = h
224 + .client
225 + .post_json(
226 + "/api/sync/blobs/confirm",
227 + &json!({ "hash": hash, "size_bytes": 32 }).to_string(),
228 + )
229 + .await;
230 + // We don't assert success — DB index size limits could legitimately reject
231 + // the row. We DO assert the server doesn't 500.
232 + assert!(
233 + r.status == 204 || r.status.is_client_error(),
234 + "huge JWT key should produce 204 or a 4xx, never 5xx; got {}: {}",
235 + r.status, r.text,
236 + );
237 + }
238 +
239 + // ── Attack 2: drift-job pathology ──
240 +
241 + #[tokio::test]
242 + async fn adversarial_drift_job_is_idempotent_when_already_consistent() {
243 + // Running the drift job twice in a row with no schema change between must
244 + // not flip rows. Second-run rows_affected == 0 by the `WHERE u.bytes_stored
245 + // <> ...` predicate.
246 + let (mut h, _blobs) = harness_with_billing_and_blobs().await;
247 + let user_id = h.signup("adv_d", "adv_d@example.com", "Password1!").await;
248 + let (app_id, _) = create_draft_app(&h.db, user_id).await;
249 + sqlx::query(
250 + "INSERT INTO sync_blobs (app_id, user_id, hash, s3_key, size_bytes, key)
251 + VALUES ($1, $2, 'h1', $3, 500, 'k1')",
252 + )
253 + .bind(app_id)
254 + .bind(user_id)
255 + .bind(format!("{}/{}/h1", app_id, user_id))
256 + .execute(&h.db)
257 + .await
258 + .unwrap();
259 +
260 + let n1 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
261 + .await
262 + .unwrap();
263 + let n2 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
264 + .await
265 + .unwrap();
266 + assert!(n1 >= 1, "first run should update at least one row, got {n1}");
267 + assert_eq!(n2, 0, "second run on consistent state should be a no-op, got {n2}");
268 + }
269 +
270 + #[tokio::test]
271 + async fn adversarial_drift_job_handles_empty_db() {
272 + // No apps, no blobs. Must not panic, must return 0.
273 + let h = TestHarness::with_mocks().await;
274 + let n = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
275 + .await
276 + .expect("recalculate on empty DB");
277 + assert_eq!(n, 0);
278 + }
279 +
280 + // ── Attack 3: defensive ceiling vs per-key cap ──
281 +
282 + #[tokio::test]
283 + async fn adversarial_defensive_aggregate_ceiling_trips_on_drifted_counters() {
284 + // Drift the app counter ABOVE the aggregate cap with the per-key counter
285 + // still under. The per-key check returns Ok (under per-key cap), so the
286 + // defensive aggregate ceiling must trip — otherwise the per_key mode
287 + // silently admits writes far past what the developer paid for.
288 + let (mut h, blobs) = harness_with_billing_and_blobs().await;
289 + let user_id = h.signup("adv_agg", "adv_agg@example.com", "Password1!").await;
290 + let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
291 + activate_per_key(&mut h, app_id, 2, 1).await; // app cap = 2 GiB
292 + claim_key(&mut h, &api_key, "A").await;
293 +
294 + // App counter drifted past aggregate cap (2 GiB).
295 + sqlx::query("UPDATE sync_app_usage_current SET bytes_stored = $2 WHERE app_id = $1")
296 + .bind(app_id)
297 + .bind(3 * GIB)
298 + .execute(&h.db)
299 + .await
300 + .unwrap();
301 + // Per-key A counter is well under per-key cap (1 GiB).
302 + sqlx::query(
303 + "INSERT INTO sync_key_usage_current (app_id, key, bytes_stored) VALUES ($1, 'A', $2)",
304 + )
305 + .bind(app_id)
306 + .bind(100i64)
307 + .execute(&h.db)
308 + .await
309 + .unwrap();
310 +
311 + auth_as(&mut h, user_id, app_id, "A");
312 + let hash = fake_hash(0x04);
313 + let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
314 + blobs.put(&s3_key, vec![0u8; 8]);
315 +
316 + h.client
317 + .post_json(
318 + "/api/sync/blobs/upload",
319 + &json!({ "hash": hash, "size_bytes": 1 }).to_string(),
320 + )
321 + .await;
322 + let r = h
323 + .client
324 + .post_json(
325 + "/api/sync/blobs/confirm",
326 + &json!({ "hash": hash, "size_bytes": 1 }).to_string(),
327 + )
328 + .await;
329 + assert_eq!(r.status, 402, "defensive aggregate ceiling should trip: {}", r.text);
330 + let body: serde_json::Value = serde_json::from_str(&r.text).unwrap();
331 + assert_eq!(body["dimension"], "storage", "expected app-aggregate dimension, got {:?}", body);
332 + }
333 +
334 + // ── Attack 4: pricing arithmetic ──
335 +
336 + #[tokio::test]
337 + async fn adversarial_activate_with_huge_storage_does_not_panic() {
338 + // The DB column `storage_gb_cap` is INT (i32), so u32::MAX would overflow
339 + // on insert. The validator caps via u32 → i32 cast on the DB write path:
340 + // we expect either a 400 from validate_knobs (unlikely, no upper bound)
341 + // or a 500/4xx from the DB write — but NOT a panic.
342 + let mut h = TestHarness::with_mocks().await;
343 + let user_id = h.signup("adv_p", "adv_p@example.com", "Password1!").await;
344 + let (app_id, _) = create_draft_app(&h.db, user_id).await;
345 + h.client
346 + .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "")
347 + .await;
348 + // i32::MAX as u32 — this is the largest value that round-trips through
349 + // the `u32 as i32` cast in activate_billing.
350 + let big = i32::MAX as u32;
351 + let r = h
352 + .client
353 + .post_json(
354 + &format!("/api/sync/apps/{}/billing/activate", app_id),
355 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": big }).to_string(),
356 + )
357 + .await;
358 + assert!(
359 + r.status == 200 || r.status.is_client_error(),
360 + "huge storage_gb_cap should produce 200 or 4xx, never 5xx; got {}: {}",
361 + r.status, r.text,
362 + );
363 + }
364 +
365 + // ── Attack 5: claim/release/reclaim sequence ──
366 +
367 + #[tokio::test]
368 + async fn adversarial_release_then_reclaim_does_not_double_count() {
369 + // Claim → release → reclaim of the same key. Each transition adjusts
370 + // sync_app_usage_current.keys_claimed; net effect should be +1, not +2.
371 + let mut h = TestHarness::with_mocks().await;
372 + let user_id = h.signup("adv_r", "adv_r@example.com", "Password1!").await;
373 + let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
374 + h.client
375 + .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "")
376 + .await;
377 + h.client
378 + .post_json(
379 + &format!("/api/sync/apps/{}/billing/activate", app_id),
380 + &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 })
381 + .to_string(),
382 + )
383 + .await;
384 +
385 + claim_key(&mut h, &api_key, "K").await;
386 + let r = h
387 + .client
388 + .post_json(
389 + "/api/sync/keys/release",
390 + &json!({ "api_key": api_key, "key": "K" }).to_string(),
391 + )
392 + .await;
393 + assert_eq!(r.status, 200, "release: {}", r.text);
394 + claim_key(&mut h, &api_key, "K").await;
395 +
396 + let total: i32 = sqlx::query_scalar(
397 + "SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1",
398 + )
399 + .bind(app_id)
400 + .fetch_one(&h.db)
401 + .await
402 + .unwrap();
403 + assert_eq!(total, 1, "claim/release/reclaim must net to 1, got {total}");
404 + }
@@ -299,3 +299,114 @@ async fn claim_key_blocked_at_cap_in_per_key_mode() {
299 299 ).await;
300 300 assert_eq!(resp.status, 200, "re-claim should succeed: {}", resp.text);
301 301 }
302 +
303 + // ── Edge cases (test-fuzz) ──
304 +
305 + #[tokio::test]
306 + async fn cannot_activate_after_cancel() {
307 + let mut h = TestHarness::with_mocks().await;
308 + let user_id = h.signup("cancel1", "cancel1@example.com", "Password1!").await;
309 + let (app_id, _) = create_draft_app(&h.db, user_id).await;
310 +
311 + h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
312 + h.client.post_json(
313 + &format!("/api/sync/apps/{}/billing/activate", app_id),
314 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
315 + ).await;
316 +
317 + let resp = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
318 + assert_eq!(resp.status, 204);
319 +
320 + // Activate now requires draft status — canceled apps cannot be reactivated.
321 + let resp = h.client.post_json(
322 + &format!("/api/sync/apps/{}/billing/activate", app_id),
323 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
324 + ).await;
325 + assert_eq!(resp.status, 409, "expected 409 conflict on re-activate after cancel: {}", resp.text);
326 + }
327 +
328 + #[tokio::test]
329 + async fn cancel_is_idempotent() {
330 + let mut h = TestHarness::with_mocks().await;
331 + let user_id = h.signup("can2", "can2@example.com", "Password1!").await;
332 + let (app_id, _) = create_draft_app(&h.db, user_id).await;
333 + h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
334 + h.client.post_json(
335 + &format!("/api/sync/apps/{}/billing/activate", app_id),
336 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(),
337 + ).await;
338 +
339 + let r1 = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
340 + assert_eq!(r1.status, 204);
341 + let r2 = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
342 + assert_eq!(r2.status, 204, "second cancel must also be 204, got {}", r2.status);
343 + }
344 +
345 + #[tokio::test]
346 + async fn patch_switches_mode_bulk_to_per_key() {
347 + // Activating in bulk and then PATCHing to per_key must succeed and update
348 + // the columns coherently (bulk knob cleared, per_key knobs set).
349 + let mut h = TestHarness::with_mocks().await;
350 + let user_id = h.signup("mode1", "mode1@example.com", "Password1!").await;
351 + let (app_id, _) = create_draft_app(&h.db, user_id).await;
352 + h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
353 + h.client.post_json(
354 + &format!("/api/sync/apps/{}/billing/activate", app_id),
355 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
356 + ).await;
357 +
358 + let resp = h.client.patch_json(
359 + &format!("/api/sync/apps/{}/billing", app_id),
360 + &json!({ "enforcement_mode": "per_key", "key_cap": 5, "gb_per_key": 20 }).to_string(),
361 + ).await;
362 + assert_eq!(resp.status, 200, "mode switch failed: {}", resp.text);
363 + let body: BillingUpdatedResp = resp.json();
364 + // 5 × 20 = 100 GB equivalent → 300 cents, same as the bulk price before.
365 + assert_eq!(body.monthly_price_cents, 300);
366 +
367 + // Verify the row reflects the switch: bulk knob cleared, per_key knobs set.
368 + let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await;
369 + let status: BillingStatusResp = resp.json();
370 + assert_eq!(status.enforcement_mode, "per_key");
371 + assert_eq!(status.storage_gb_cap, None, "bulk knob should be cleared on mode switch");
372 + assert_eq!(status.key_cap, Some(5));
373 + assert_eq!(status.gb_per_key, Some(20));
374 + }
375 +
376 + #[tokio::test]
377 + async fn setup_rejects_non_draft_app() {
378 + // Once activated, calling setup again must 409 (not silently re-mint
379 + // a customer).
380 + let mut h = TestHarness::with_mocks().await;
381 + let user_id = h.signup("setup2", "setup2@example.com", "Password1!").await;
382 + let (app_id, _) = create_draft_app(&h.db, user_id).await;
383 + h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
384 + h.client.post_json(
385 + &format!("/api/sync/apps/{}/billing/activate", app_id),
386 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(),
387 + ).await;
388 +
389 + let resp = h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
390 + assert_eq!(resp.status, 409, "second setup on active app must 409, got {}: {}", resp.status, resp.text);
391 + }
392 +
393 + #[tokio::test]
394 + async fn other_users_app_billing_rejected() {
395 + // Cross-tenant: dev A cannot inspect or mutate dev B's app billing.
396 + let mut h = TestHarness::with_mocks().await;
397 + let owner = h.signup("owner1", "owner1@example.com", "Password1!").await;
398 + let (app_id, _) = create_draft_app(&h.db, owner).await;
399 +
400 + // Switch session to a second user.
401 + let _other = h.signup("other1", "other1@example.com", "Password1!").await;
402 + // signup auto-logs-in the new user; the previous session is replaced.
403 +
404 + let r = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await;
405 + assert_eq!(r.status, 403, "expected 403 forbidden cross-tenant: {}", r.text);
406 +
407 + let r = h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
408 + assert_eq!(r.status, 403);
409 +
410 + let r = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
411 + assert_eq!(r.status, 403);
412 + }