//! Adversarial tests for SyncKit v2 billing + per-key storage (v0.7.0+). //! //! These tests don't exist to confirm the happy path — they exist to BREAK //! things. Hostile JWT payloads, pathological pricing inputs, weird mode //! transitions, drift-job pathology. Each test states what it's trying to //! attack and what surviving means. use crate::harness::{BuildOptions, TestHarness, stripe, storage::InMemoryStorage}; use makenotwork::db::{SyncAppId, UserId}; use serde_json::json; use sqlx::PgPool; use std::sync::Arc; const GIB: i64 = 1024 * 1024 * 1024; async fn harness_with_billing_and_blobs() -> (TestHarness, Arc) { let synckit_mem = Arc::new(InMemoryStorage::new()); let mock_stripe = Arc::new(stripe::MockPaymentProvider::new()); let mock_email = Arc::new(crate::harness::email::MockEmailTransport::new()); let mut h = TestHarness::build(BuildOptions { synckit_storage: Some(synckit_mem.clone()), stripe_client: Some(mock_stripe.clone()), mock_email: Some(mock_email), ..Default::default() }) .await; h.mock_stripe = Some(mock_stripe); (h, synckit_mem) } async fn create_draft_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = "test-api-key-adv"; let key_hash = crate::harness::hash_api_key(api_key); let key_prefix = &api_key[..8]; let app_id: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status) VALUES ($1, 'AdvTest', $2, $3, FALSE, 'draft') RETURNING id", ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("insert sync_app"); sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT DO NOTHING") .bind(app_id) .execute(pool) .await .unwrap(); (app_id, api_key.to_string()) } async fn activate_per_key(h: &mut TestHarness, app_id: SyncAppId, key_cap: u32, gb_per_key: u32) { h.client .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "") .await; let resp = h .client .post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "per_key", "key_cap": key_cap, "gb_per_key": gb_per_key, }) .to_string(), ) .await; assert_eq!(resp.status, 200, "activate per_key: {}", resp.text); } async fn claim_key(h: &mut TestHarness, api_key: &str, key: &str) { let resp = h .client .post_json( "/api/sync/keys/claim", &json!({ "api_key": api_key, "key": key }).to_string(), ) .await; assert_eq!(resp.status, 200, "claim {}: {}", key, resp.text); } fn auth_as(h: &mut TestHarness, user_id: UserId, app_id: SyncAppId, key: &str) { let token = makenotwork::synckit_auth::create_sync_token( "test-synckit-jwt-secret", user_id, app_id, key, ) .expect("mint test JWT"); h.client.set_bearer_token(&token); } fn fake_hash(seed: u8) -> String { let mut s = String::with_capacity(64); for _ in 0..32 { s.push_str(&format!("{:02x}", seed)); } s } // ── Attack 1: hostile JWT key payloads ── // // The JWT extractor (SyncUser::from_request_parts) only rejects an empty `key`. // validate_synckit_key (which bans null bytes, oversize, control chars) runs // only on the /api/sync/auth route. A developer who mints their own JWT // (allowed: keys come from THEIR backend) can sneak hostile values past every // validator. These tests prove the rest of the stack survives. Surviving // means: parameterized queries don't break, presigned URLs build, and no // route panics or 500s — even if the upload eventually gets rejected. #[tokio::test] async fn adversarial_jwt_key_with_sql_injection_literal() { // Parameterized queries (sqlx) should treat this as literal data. let (mut h, blobs) = harness_with_billing_and_blobs().await; let user_id = h.signup("adv_sql", "adv_sql@example.com", "Password1!").await; let (app_id, api_key) = create_draft_app(&h.db, user_id).await; activate_per_key(&mut h, app_id, 5, 1).await; let evil = "k'; DROP TABLE sync_apps; --"; claim_key(&mut h, &api_key, evil).await; auth_as(&mut h, user_id, app_id, evil); let hash = fake_hash(0x01); let s3_key = format!("{}/{}/{}", app_id, user_id, &hash); blobs.put(&s3_key, vec![0u8; 8]); let r = h .client .post_json( "/api/sync/blobs/upload", &json!({ "hash": hash, "size_bytes": 1024 }).to_string(), ) .await; assert_eq!(r.status, 200, "upload-url should not 500: {}", r.text); let r = h .client .post_json( "/api/sync/blobs/confirm", &json!({ "hash": hash, "size_bytes": 1024 }).to_string(), ) .await; assert_eq!(r.status, 204, "confirm should succeed safely: {}", r.text); // Table still exists. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_apps WHERE id = $1") .bind(app_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "sync_apps row must still exist after injection-flavored key"); // Per-key counter row reflects the evil key (literal storage). let stored: Option = sqlx::query_scalar( "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2", ) .bind(app_id) .bind(evil) .fetch_one(&h.db) .await .unwrap(); assert_eq!(stored, Some(1024)); } #[tokio::test] async fn adversarial_jwt_key_with_unicode_rtl_and_zero_width() { // RTL override + zero-width joiner. Should survive end-to-end. let (mut h, blobs) = harness_with_billing_and_blobs().await; let user_id = h.signup("adv_u", "adv_u@example.com", "Password1!").await; let (app_id, api_key) = create_draft_app(&h.db, user_id).await; activate_per_key(&mut h, app_id, 5, 1).await; let weird = "user\u{202E}admin\u{200B}"; claim_key(&mut h, &api_key, weird).await; auth_as(&mut h, user_id, app_id, weird); let hash = fake_hash(0x02); let s3_key = format!("{}/{}/{}", app_id, user_id, &hash); blobs.put(&s3_key, vec![0u8; 8]); h.client .post_json( "/api/sync/blobs/upload", &json!({ "hash": hash, "size_bytes": 16 }).to_string(), ) .await; let r = h .client .post_json( "/api/sync/blobs/confirm", &json!({ "hash": hash, "size_bytes": 16 }).to_string(), ) .await; assert_eq!(r.status, 204, "weird unicode key should still upload: {}", r.text); } #[tokio::test] async fn adversarial_jwt_key_extremely_long() { // 32 KiB JWT key. The /api/sync/auth route would 400; a directly-minted // token slips it past the validator. This test pins current behavior so // that a future bounded-length check on the extractor side will surface // here as a deliberate change (and not break silently). let (mut h, blobs) = harness_with_billing_and_blobs().await; let user_id = h.signup("adv_l", "adv_l@example.com", "Password1!").await; let (app_id, api_key) = create_draft_app(&h.db, user_id).await; activate_per_key(&mut h, app_id, 5, 1).await; let huge = "k".repeat(32 * 1024); claim_key(&mut h, &api_key, &huge).await; auth_as(&mut h, user_id, app_id, &huge); let hash = fake_hash(0x03); let s3_key = format!("{}/{}/{}", app_id, user_id, &hash); blobs.put(&s3_key, vec![0u8; 8]); h.client .post_json( "/api/sync/blobs/upload", &json!({ "hash": hash, "size_bytes": 32 }).to_string(), ) .await; let r = h .client .post_json( "/api/sync/blobs/confirm", &json!({ "hash": hash, "size_bytes": 32 }).to_string(), ) .await; // We don't assert success — DB index size limits could legitimately reject // the row. We DO assert the server doesn't 500. assert!( r.status == 204 || r.status.is_client_error(), "huge JWT key should produce 204 or a 4xx, never 5xx; got {}: {}", r.status, r.text, ); } // ── Attack 2: drift-job pathology ── #[tokio::test] async fn adversarial_drift_job_is_idempotent_when_already_consistent() { // Running the drift job twice in a row with no schema change between must // not flip rows. Second-run rows_affected == 0 by the `WHERE u.bytes_stored // <> ...` predicate. let (mut h, _blobs) = harness_with_billing_and_blobs().await; let user_id = h.signup("adv_d", "adv_d@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; sqlx::query( "INSERT INTO sync_blobs (app_id, user_id, hash, s3_key, size_bytes, key) VALUES ($1, $2, 'h1', $3, 500, 'k1')", ) .bind(app_id) .bind(user_id) .bind(format!("{}/{}/h1", app_id, user_id)) .execute(&h.db) .await .unwrap(); let n1 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db) .await .unwrap(); let n2 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db) .await .unwrap(); assert!(n1 >= 1, "first run should update at least one row, got {n1}"); assert_eq!(n2, 0, "second run on consistent state should be a no-op, got {n2}"); } #[tokio::test] async fn adversarial_drift_job_handles_empty_db() { // No apps, no blobs. Must not panic, must return 0. let h = TestHarness::with_mocks().await; let n = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db) .await .expect("recalculate on empty DB"); assert_eq!(n, 0); } // ── Attack 3: defensive ceiling vs per-key cap ── #[tokio::test] async fn adversarial_defensive_aggregate_ceiling_trips_on_drifted_counters() { // Drift the app counter ABOVE the aggregate cap with the per-key counter // still under. The per-key check returns Ok (under per-key cap), so the // defensive aggregate ceiling must trip — otherwise the per_key mode // silently admits writes far past what the developer paid for. let (mut h, blobs) = harness_with_billing_and_blobs().await; let user_id = h.signup("adv_agg", "adv_agg@example.com", "Password1!").await; let (app_id, api_key) = create_draft_app(&h.db, user_id).await; activate_per_key(&mut h, app_id, 2, 1).await; // app cap = 2 GiB claim_key(&mut h, &api_key, "A").await; // App counter drifted past aggregate cap (2 GiB). sqlx::query("UPDATE sync_app_usage_current SET bytes_stored = $2 WHERE app_id = $1") .bind(app_id) .bind(3 * GIB) .execute(&h.db) .await .unwrap(); // Per-key A counter is well under per-key cap (1 GiB). sqlx::query( "INSERT INTO sync_key_usage_current (app_id, key, bytes_stored) VALUES ($1, 'A', $2)", ) .bind(app_id) .bind(100i64) .execute(&h.db) .await .unwrap(); auth_as(&mut h, user_id, app_id, "A"); let hash = fake_hash(0x04); let s3_key = format!("{}/{}/{}", app_id, user_id, &hash); blobs.put(&s3_key, vec![0u8; 8]); h.client .post_json( "/api/sync/blobs/upload", &json!({ "hash": hash, "size_bytes": 1 }).to_string(), ) .await; let r = h .client .post_json( "/api/sync/blobs/confirm", &json!({ "hash": hash, "size_bytes": 1 }).to_string(), ) .await; assert_eq!(r.status, 402, "defensive aggregate ceiling should trip: {}", r.text); let body: serde_json::Value = serde_json::from_str(&r.text).unwrap(); assert_eq!(body["dimension"], "storage", "expected app-aggregate dimension, got {:?}", body); } // ── Attack 4: pricing arithmetic ── #[tokio::test] async fn adversarial_activate_with_huge_storage_does_not_panic() { // The DB column `storage_gb_cap` is INT (i32), so u32::MAX would overflow // on insert. The validator caps via u32 → i32 cast on the DB write path: // we expect either a 400 from validate_knobs (unlikely, no upper bound) // or a 500/4xx from the DB write — but NOT a panic. let mut h = TestHarness::with_mocks().await; let user_id = h.signup("adv_p", "adv_p@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "") .await; // i32::MAX as u32 — this is the largest value that round-trips through // the `u32 as i32` cast in activate_billing. let big = i32::MAX as u32; let r = h .client .post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": big }).to_string(), ) .await; assert!( r.status == 200 || r.status.is_client_error(), "huge storage_gb_cap should produce 200 or 4xx, never 5xx; got {}: {}", r.status, r.text, ); } // ── Attack 5: claim/release/reclaim sequence ── #[tokio::test] async fn adversarial_release_then_reclaim_does_not_double_count() { // Claim → release → reclaim of the same key. Each transition adjusts // sync_app_usage_current.keys_claimed; net effect should be +1, not +2. let mut h = TestHarness::with_mocks().await; let user_id = h.signup("adv_r", "adv_r@example.com", "Password1!").await; let (app_id, api_key) = create_draft_app(&h.db, user_id).await; h.client .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "") .await; h.client .post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 }) .to_string(), ) .await; claim_key(&mut h, &api_key, "K").await; let r = h .client .post_json( "/api/sync/keys/release", &json!({ "api_key": api_key, "key": "K" }).to_string(), ) .await; assert_eq!(r.status, 200, "release: {}", r.text); claim_key(&mut h, &api_key, "K").await; let total: i32 = sqlx::query_scalar( "SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1", ) .bind(app_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(total, 1, "claim/release/reclaim must net to 1, got {total}"); }