//! SyncKit security hardening (ultra-fuzz --deep Security A+): device removal //! immediately invalidates the user's sync tokens, and security-relevant events //! (auth failures, device removals) land in the `sync_security_events` audit log. use makenotwork::db::{SyncAppId, UserId}; use serde_json::json; use sqlx::PgPool; use crate::harness::TestHarness; /// Create an active first-party (internal) sync app; returns (app_id, api_key). async fn create_internal_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = "test-api-key-synckit-security"; 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, 'SecApp', $2, $3, TRUE, 'active') RETURNING id", ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("insert internal sync_app"); (app_id, api_key.to_string()) } 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); } #[tokio::test] async fn deleting_a_device_invalidates_the_users_sync_token_and_audits_it() { let mut h = TestHarness::new().await; let user_id = h .signup("sec_dev", "sec_dev@example.com", "Password1!") .await; let (app_id, _api_key) = create_internal_app(&h.db, user_id).await; auth_as(&mut h, user_id, app_id, "user-key"); // A device to remove. let device_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO sync_devices (app_id, user_id, device_name, platform) VALUES ($1, $2, 'laptop', 'test') RETURNING id", ) .bind(app_id) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); // The token authenticates a delete of an unknown device as 404 (auth passes, // device not found), proving it is valid right now. let probe = h .client .delete(&format!("/api/sync/devices/{}", uuid::Uuid::new_v4())) .await; assert_eq!( probe.status, 404, "token should be valid before removal: {}", probe.text ); // Remove the real device. let resp = h .client .delete(&format!("/api/sync/devices/{device_id}")) .await; assert_eq!( resp.status, 204, "device delete should succeed: {}", resp.text ); // Same token now fails: device removal bumped sync_jwt_invalidated_at, so the // unknown-device probe returns 401 (auth) rather than 404. let probe = h .client .delete(&format!("/api/sync/devices/{}", uuid::Uuid::new_v4())) .await; assert_eq!( probe.status, 401, "token must be invalid after device removal: {}", probe.text ); // The website session path is untouched: jwt_invalidated_at stays NULL. let web_invalidated: Option> = sqlx::query_scalar("SELECT jwt_invalidated_at FROM users WHERE id = $1") .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!( web_invalidated.is_none(), "sync revocation must not touch web jwt_invalidated_at" ); // The removal was audited. let events: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM sync_security_events WHERE app_id = $1 AND user_id = $2 AND event_type = 'device_removed'", ) .bind(app_id) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( events, 1, "device removal must be recorded in the audit log" ); } #[tokio::test] async fn failed_sync_auth_is_recorded_in_the_audit_log() { let mut h = TestHarness::new().await; let user_id = h .signup("sec_auth", "sec_auth@example.com", "Password1!") .await; let (app_id, api_key) = create_internal_app(&h.db, user_id).await; let resp = h .client .post_json( "/api/sync/auth", &json!({ "api_key": api_key, "email": "sec_auth@example.com", "password": "WrongPassword1!", "key": "user-key", }) .to_string(), ) .await; assert_eq!( resp.status, 401, "wrong password must be rejected: {}", resp.text ); let events: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM sync_security_events WHERE app_id = $1 AND user_id = $2 AND event_type = 'auth_failure'", ) .bind(app_id) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( events, 1, "a failed sync auth must be recorded in the audit log" ); } #[tokio::test] async fn deleting_a_device_invalidates_oauth_userinfo_tokens_too() { // M-Sec1: the OAuth userinfo extractor (and the refresh path) previously // checked only `jwt_invalidated_at`, so a removed device's userinfo token // survived until expiry. The shared `assert_token_live` gate now enforces // `sync_jwt_invalidated_at` for every SyncKit-derived credential, closing the // parity gap that migration 150 left open. let mut h = TestHarness::new().await; let user_id = h .signup("sec_oauth", "sec_oauth@example.com", "Password1!") .await; let (app_id, _api_key) = create_internal_app(&h.db, user_id).await; // Mint a userinfo-scoped OAuth access token directly and present it. let scopes = makenotwork::oauth_scope::GrantedScopes::parse("profile:read"); let token = makenotwork::synckit_auth::create_oauth_access_token( "test-synckit-jwt-secret", user_id, app_id, "user-key", &scopes, ) .expect("mint userinfo token"); h.client.set_bearer_token(&token); // Valid right now. let ok = h.client.get("/oauth/userinfo").await; assert_eq!( ok.status, 200, "userinfo token must work before device removal: {}", ok.text ); // Simulate device removal: bump sync_jwt_invalidated_at past the token's iat. sqlx::query( "UPDATE users SET sync_jwt_invalidated_at = NOW() + INTERVAL '5 seconds' WHERE id = $1", ) .bind(user_id) .execute(&h.db) .await .unwrap(); // The userinfo token is now rejected, the parity gap is closed. let denied = h.client.get("/oauth/userinfo").await; assert_eq!( denied.status, 401, "device removal must kill the userinfo token: {}", denied.text ); }