//! SyncKit integration tests — auth, devices, push/pull, key management. use crate::harness::TestHarness; use makenotwork::db::{SyncAppId, SyncDeviceId, UserId}; use serde::Deserialize; use serde_json::json; use sqlx::PgPool; // ── Response types for deserialization ── #[derive(Deserialize)] struct AuthResponse { token: String, user_id: UserId, #[serde(rename = "app_id")] _app_id: SyncAppId, } #[derive(Deserialize)] struct StatusResponse { total_changes: i64, latest_cursor: Option, } #[derive(Deserialize)] struct DeviceResponse { id: SyncDeviceId, } #[derive(Deserialize)] struct PushResponse { cursor: i64, } #[derive(Deserialize)] struct PullResponse { changes: Vec, cursor: i64, has_more: bool, } #[derive(Deserialize)] struct PullChange { #[serde(rename = "seq")] _seq: i64, table: String, op: String, row_id: String, data: Option, } #[derive(Deserialize)] struct KeyResponse { encrypted_key: String, key_version: i32, } // ── Helper ── /// Insert a sync app directly via SQL and return (app_id, api_key). async fn create_sync_app_for_user(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = "test-api-key-for-synckit-integration"; 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) VALUES ($1, 'AudioFiles', $2, $3) RETURNING id", ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("Failed to create sync app"); (app_id, api_key.to_string()) } /// Sign up a user, create a sync app, authenticate, and return the bearer token. async fn setup_authenticated(h: &mut TestHarness) -> (String, SyncAppId) { let user_id = h.signup("syncer", "syncer@example.com", "Password1!").await; let (app_id, api_key) = create_sync_app_for_user(&h.db, user_id).await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "syncer@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Auth failed: {}", resp.text); let auth: AuthResponse = resp.json(); h.client.set_bearer_token(&auth.token); (auth.token, app_id) } /// Register a device and return its ID. async fn register_device(h: &mut TestHarness, name: &str) -> SyncDeviceId { 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 failed: {}", resp.text); let dev: DeviceResponse = resp.json(); dev.id } // ── Tests ── #[tokio::test] async fn auth_and_status() { let mut h = TestHarness::new().await; let user_id = h.signup("syncer", "syncer@example.com", "Password1!").await; let (_, api_key) = create_sync_app_for_user(&h.db, user_id).await; // Authenticate let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "syncer@example.com", "password": "Password1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Auth failed: {}", resp.text); let auth: AuthResponse = resp.json(); assert_eq!(auth.user_id, user_id); assert!(!auth.token.is_empty()); // Use token for status h.client.set_bearer_token(&auth.token); let resp = h.client.get("/api/sync/status").await; assert_eq!(resp.status, 200); let status: StatusResponse = resp.json(); assert_eq!(status.total_changes, 0); assert!(status.latest_cursor.is_none()); } #[tokio::test] async fn auth_rejects_bad_credentials() { let mut h = TestHarness::new().await; let user_id = h.signup("syncer", "syncer@example.com", "Password1!").await; let (_, api_key) = create_sync_app_for_user(&h.db, user_id).await; // Wrong password let resp = h .client .post_json( "/api/sync/auth", &json!({ "email": "syncer@example.com", "password": "WrongPass1!", "api_key": api_key, "key": "test-sdk-key", }) .to_string(), ) .await; assert_eq!(resp.status, 401); } #[tokio::test] async fn device_crud() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; // Register a device let device_id = register_device(&mut h, "MacBook Pro").await; // List devices — should be 1 let resp = h.client.get("/api/sync/devices").await; assert_eq!(resp.status, 200); let devices: Vec = resp.json(); assert_eq!(devices.len(), 1); assert_eq!(devices[0].id, device_id); // Delete device let resp = h .client .delete(&format!("/api/sync/devices/{}", device_id)) .await; assert_eq!(resp.status, 204); // List again — should be 0 let resp = h.client.get("/api/sync/devices").await; assert_eq!(resp.status, 200); let devices: Vec = resp.json(); assert_eq!(devices.len(), 0); } #[tokio::test] async fn push_pull_roundtrip() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; let device_id = register_device(&mut h, "MacBook Pro").await; // Push 3 changes let resp = h .client .post_json( "/api/sync/push", &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": [ { "table": "tasks", "op": "INSERT", "row_id": "aaa", "timestamp": "2025-01-01T00:00:00Z", "data": {"title": "Task 1"} }, { "table": "tasks", "op": "UPDATE", "row_id": "aaa", "timestamp": "2025-01-01T00:01:00Z", "data": {"title": "Task 1 updated"} }, { "table": "tasks", "op": "DELETE", "row_id": "bbb", "timestamp": "2025-01-01T00:02:00Z" }, ] }) .to_string(), ) .await; assert_eq!(resp.status, 200, "Push failed: {}", resp.text); let push: PushResponse = resp.json(); assert!(push.cursor > 0); // Pull from cursor 0 let resp = h .client .post_json( "/api/sync/pull", &json!({ "device_id": device_id, "cursor": 0 }).to_string(), ) .await; assert_eq!(resp.status, 200, "Pull failed: {}", resp.text); let pull: PullResponse = resp.json(); assert_eq!(pull.changes.len(), 3); assert!(!pull.has_more); assert_eq!(pull.cursor, push.cursor); // Verify change content assert_eq!(pull.changes[0].table, "tasks"); assert_eq!(pull.changes[0].op, "INSERT"); assert_eq!(pull.changes[0].row_id, "aaa"); assert_eq!(pull.changes[2].op, "DELETE"); assert_eq!(pull.changes[2].row_id, "bbb"); assert!(pull.changes[2].data.is_none()); } #[tokio::test] async fn key_management() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; // PUT encrypted key (expected_version 0 = no prior key) let resp = h .client .put_json( "/api/sync/keys", &json!({ "encrypted_key": "encrypted-master-key-blob", "expected_version": 0 }).to_string(), ) .await; assert_eq!(resp.status, 204, "Put key failed: {}", resp.text); // GET it back let resp = h.client.get("/api/sync/keys").await; assert_eq!(resp.status, 200, "Get key failed: {}", resp.text); let key: KeyResponse = resp.json(); assert_eq!(key.encrypted_key, "encrypted-master-key-blob"); assert_eq!(key.key_version, 1); // PUT again (upsert bumps version, expect current version 1) let resp = h .client .put_json( "/api/sync/keys", &json!({ "encrypted_key": "rotated-key-blob", "expected_version": 1 }).to_string(), ) .await; assert_eq!(resp.status, 204); let resp = h.client.get("/api/sync/keys").await; assert_eq!(resp.status, 200); let key: KeyResponse = resp.json(); assert_eq!(key.encrypted_key, "rotated-key-blob"); assert_eq!(key.key_version, 2); } #[tokio::test] async fn unauthenticated_rejected() { let mut h = TestHarness::new().await; // No bearer token — all sync endpoints should return 401 let resp = h.client.get("/api/sync/status").await; assert_eq!(resp.status, 401); let resp = h.client.get("/api/sync/devices").await; assert_eq!(resp.status, 401); let resp = h.client.get("/api/sync/keys").await; assert_eq!(resp.status, 401); let resp = h .client .post_json("/api/sync/push", &json!({"device_id": 1, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": []}).to_string()) .await; assert_eq!(resp.status, 401); } #[tokio::test] async fn push_validation() { let mut h = TestHarness::new().await; setup_authenticated(&mut h).await; let device_id = register_device(&mut h, "MacBook Pro").await; // Too many changes (>500) let changes: Vec<_> = (0..501) .map(|i| { json!({ "table": "tasks", "op": "INSERT", "row_id": format!("id-{}", i), "timestamp": "2025-01-01T00:00:00Z", "data": {"title": "x"} }) }) .collect(); let resp = h .client .post_json( "/api/sync/push", &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": changes }).to_string(), ) .await; assert_eq!(resp.status, 400, "Expected 400 for >500 changes: {}", resp.text); // DELETE with data should be rejected let resp = h .client .post_json( "/api/sync/push", &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": [{ "table": "tasks", "op": "DELETE", "row_id": "aaa", "timestamp": "2025-01-01T00:00:00Z", "data": {"should": "not be here"} }] }) .to_string(), ) .await; assert_eq!(resp.status, 400, "Expected 400 for DELETE with data: {}", resp.text); // Empty changes should be rejected let resp = h .client .post_json( "/api/sync/push", &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": [] }).to_string(), ) .await; assert_eq!(resp.status, 400, "Expected 400 for empty changes: {}", resp.text); }