//! HTTP tests: a group's writes bill to the *admin's* slot. //! //! Group push is gated on the admin's entitlement, not the pushing member's, so a //! member with no subscription of their own can still contribute to a group whose //! admin pays (the Groups billing decision, 2026-07-23). Reuses the paid-sync //! harness helpers. Design: wiki synckit-groups-design. use serde_json::json; use super::synckit_paid_sync::{ auth_as, create_internal_app, harness_with_blobs, seed_subscription, }; use crate::harness::TestHarness; const GIB: i64 = 1024 * 1024 * 1024; async fn create_group(h: &mut TestHarness, name: &str) -> String { let resp = h .client .post_json( "/api/sync/groups", &json!({ "id": uuid::Uuid::new_v4().to_string(), "name": name, "admin_sealed_gck": "sealed_admin", "admin_pubkey": "pk_admin" }).to_string(), ) .await; assert_eq!(resp.status, 200, "create group: {}", resp.text); let body: serde_json::Value = resp.json(); body["id"].as_str().expect("group id").to_string() } async fn register_device(h: &mut TestHarness, name: &str) -> String { let resp = h .client .post_json( "/api/sync/devices", &json!({ "device_name": name, "platform": "macos" }).to_string(), ) .await; assert_eq!(resp.status, 200, "register device: {}", resp.text); let body: serde_json::Value = resp.json(); body["id"].as_str().expect("device id").to_string() } fn push_body(device_id: &str) -> String { json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4(), "changes": [{ "table": "tasks", "op": "INSERT", "row_id": "r1", "timestamp": "2026-01-01T00:00:00Z", "data": { "v": 1 } }] }) .to_string() } #[tokio::test] async fn group_push_gated_on_admin_subscription() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gb_admin", "gb_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); let group_id = create_group(&mut h, "Team").await; let admin_device = register_device(&mut h, "admin-dev").await; // No subscription yet: the admin's own group push is refused (402). let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/push"), &push_body(&admin_device), ) .await; assert_eq!(resp.status, 402, "unsubscribed admin push: {}", resp.text); let body: serde_json::Value = resp.json(); assert_eq!(body["reason"], "no_subscription"); // Give the admin an active subscription: group push now succeeds. seed_subscription(&h.db, admin, app, "active", 10 * GIB).await; let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/push"), &push_body(&admin_device), ) .await; assert_eq!(resp.status, 200, "subscribed admin push: {}", resp.text); } #[tokio::test] async fn member_without_own_subscription_writes_on_admin_slot() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gb2_admin", "gb2_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); seed_subscription(&h.db, admin, app, "active", 10 * GIB).await; let group_id = create_group(&mut h, "Team").await; // A verified member with NO subscription of their own. let bob = h .signup("gb2_bob", "gb2_bob@example.com", "Password1!") .await; sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(bob) .execute(&h.db) .await .expect("verify bob"); let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/members"), &json!({ "member_email": "gb2_bob@example.com", "sealed_gck": "sealed_bob", "member_pubkey": "pk_bob" }) .to_string(), ) .await; assert_eq!(resp.status, 204, "add member: {}", resp.text); // Bob has no subscription, but the group's admin does: his push succeeds. auth_as(&mut h, bob, app, "bob-key"); let bob_device = register_device(&mut h, "bob-dev").await; let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/push"), &push_body(&bob_device), ) .await; assert_eq!( resp.status, 200, "member writes on admin's slot: {}", resp.text ); // A verified non-member is refused before the billing gate (403). let carol = h .signup("gb2_carol", "gb2_carol@example.com", "Password1!") .await; auth_as(&mut h, carol, app, "carol-key"); let carol_device = register_device(&mut h, "carol-dev").await; let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/push"), &push_body(&carol_device), ) .await; assert_eq!( resp.status, 403, "non-member push must be forbidden: {}", resp.text ); }