|
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 |
+ |
}
|