//! Authentication, token handling, and session lifecycle. //! //! The credential exchange (`authenticate`, `authenticate_with_code`), what the //! client does with a JWT that is expired or near expiry, and the session //! transitions: restore, clear, re-authenticate over a live session. use crate::common::*; const AUTH_PATH: &str = "/api/v1/sync/auth"; // ── Auth flow ── #[tokio::test] async fn authenticate_success_stores_session() { let kit = MockKit::start().await; kit.post(AUTH_PATH).json(auth_response_json()).await; let client = kit.client(); let (user_id, app_id) = client .authenticate("user@test.com", "password", "test-key") .await .unwrap(); let info = client.session_info().expect("session stored"); assert_eq!(info.user_id, user_id); assert_eq!(info.app_id, app_id); } #[tokio::test] async fn authenticate_wrong_password_no_retry() { let kit = MockKit::start().await; // Exactly once: a retry on a rejected credential is a lockout waiting to // happen, so the count is the assertion. kit.post(AUTH_PATH) .code(401) .exactly(1) .text("Unauthorized") .await; let client = kit.client(); let err = client .authenticate("user@test.com", "wrong", "test-key") .await .unwrap_err(); assert!( matches!(err, SyncKitError::Server { status: 401, .. }), "Expected 401 error, got: {err:?}" ); } #[tokio::test] async fn authenticate_retries_on_503() { let kit = MockKit::start().await; kit.post(AUTH_PATH) .code(503) .once() .text("Service Unavailable") .await; kit.post(AUTH_PATH).json(auth_response_json()).await; let client = kit.client(); let result = client .authenticate("user@test.com", "password", "test-key") .await; assert!(result.is_ok(), "Should succeed after retry: {result:?}"); } #[tokio::test] async fn authenticate_with_code_success() { let kit = MockKit::start().await; let (user_id, app_id) = test_ids(); kit.post("/oauth/token") .json(json!({ "access_token": fresh_token(), "token_type": "Bearer", "expires_in": 3600, "user_id": user_id, "app_id": app_id, })) .await; let client = kit.client(); let (uid, aid) = client .authenticate_with_code("auth-code", "verifier", 8080, "test-key") .await .unwrap(); assert_eq!(uid, user_id); assert_eq!(aid, app_id); assert!(client.session_info().is_some()); } // ── Token handling ── #[tokio::test] async fn expired_jwt_returns_token_expired() { let kit = MockKit::start().await; let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 3600)); let err = client.status().await.unwrap_err(); assert!( matches!(err, SyncKitError::TokenExpired), "Expected TokenExpired, got: {err:?}" ); } #[tokio::test] async fn near_expiry_jwt_returns_token_expired() { let kit = MockKit::start().await; // Expires in 10 seconds, inside the 30-second pre-flight buffer. let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() + 10)); let err = client.status().await.unwrap_err(); assert!( matches!(err, SyncKitError::TokenExpired), "Expected TokenExpired, got: {err:?}" ); } // ── Session management ── #[tokio::test] async fn restore_then_clear_session() { let kit = MockKit::start().await; kit.get("/api/v1/sync/status") .json(json!({"total_changes": 0, "latest_cursor": null})) .await; let client = kit.authed(); // Should work while authenticated let status = client.status().await.unwrap(); assert_eq!(status.total_changes, 0); // Clear session client.clear_session(); // Now should fail let err = client.status().await.unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[tokio::test] async fn status_without_auth_returns_not_authenticated() { let kit = MockKit::start().await; let err = kit.client().status().await.unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[tokio::test] async fn push_without_auth_returns_not_authenticated() { let kit = MockKit::start().await; let err = kit .client() .push(DeviceId::new(Uuid::new_v4()), vec![]) .await .unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } // ── Session expiry handling ── /// A client whose restored token expired 100 seconds ago, holding a master key /// so nothing but the expiry can stop the call under test. fn expired_keyed_client(kit: &MockKit) -> SyncKitClient { let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100)); client.set_master_key_raw(synckit_client::crypto::generate_master_key()); client } #[tokio::test] async fn expired_token_detected_before_push() { let kit = MockKit::start().await; let err = expired_keyed_client(&kit) .push(DeviceId::new(Uuid::new_v4()), vec![]) .await .unwrap_err(); assert!( matches!(err, SyncKitError::TokenExpired), "Expired token should be detected pre-flight, got: {err:?}" ); } #[tokio::test] async fn expired_token_detected_before_pull() { let kit = MockKit::start().await; let err = expired_keyed_client(&kit) .pull(DeviceId::new(Uuid::new_v4()), 0) .await .unwrap_err(); assert!(matches!(err, SyncKitError::TokenExpired)); } #[tokio::test] async fn expired_token_detected_before_register_device() { let kit = MockKit::start().await; let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100)); let err = client.register_device("Test", "test").await.unwrap_err(); assert!(matches!(err, SyncKitError::TokenExpired)); } #[tokio::test] async fn expired_token_detected_before_list_devices() { let kit = MockKit::start().await; let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100)); let err = client.list_devices().await.unwrap_err(); assert!(matches!(err, SyncKitError::TokenExpired)); } // ── Session edge cases ── #[tokio::test] async fn double_authenticate_overwrites_session() { let kit = MockKit::start().await; let (user_id, app_id) = test_ids(); let second_user_id = UserId::new(Uuid::new_v4()); kit.post(AUTH_PATH) .once() .json(json!({ "token": fresh_token(), "user_id": user_id, "app_id": app_id, })) .await; // Second auth answers with a different user_id. kit.post(AUTH_PATH) .json(json!({ "token": fresh_token(), "user_id": second_user_id, "app_id": app_id, })) .await; let client = kit.client(); let (uid1, _) = client .authenticate("user1@test.com", "pass1", "test-key") .await .unwrap(); assert_eq!(uid1, user_id); let (uid2, _) = client .authenticate("user2@test.com", "pass2", "test-key") .await .unwrap(); assert_eq!(uid2, second_user_id); // Session should now reflect the second auth let info = client.session_info().unwrap(); assert_eq!(info.user_id, second_user_id); } #[tokio::test] async fn clear_session_then_authenticate_succeeds() { let kit = MockKit::start().await; kit.post(AUTH_PATH).json(auth_response_json()).await; let client = kit.authed(); assert!(client.session_info().is_some()); client.clear_session(); assert!(client.session_info().is_none()); // Re-authenticate should work let result = client .authenticate("user@test.com", "pass", "test-key") .await; assert!( result.is_ok(), "Should be able to re-authenticate after clear: {result:?}" ); assert!(client.session_info().is_some()); } #[tokio::test] async fn restore_session_with_expired_token_then_push_returns_token_expired() { let kit = MockKit::start().await; let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 3600)); client.set_master_key_raw(synckit_client::crypto::generate_master_key()); let err = client .push(DeviceId::new(Uuid::new_v4()), vec![]) .await .unwrap_err(); assert!( matches!(err, SyncKitError::TokenExpired), "Restored expired token should return TokenExpired, got: {err:?}" ); } // ── API-key validation ── // // `validate_api_key` is a free function: it builds its own HTTP client rather // than going through `SyncKitClient`, so nothing else in the suite covers it. // A setup UI calls it before saving a key, and it has to tell "wrong key" // (401) apart from "server unreachable" or the UI reports the wrong problem. const VALIDATE_PATH: &str = "/api/v1/sync/validate-app"; #[tokio::test] async fn validate_api_key_returns_the_app_name() { ensure_crypto_provider(); let kit = MockKit::start().await; kit.post(VALIDATE_PATH) .json(json!({"app_name": "goingson"})) .await; let name = synckit_client::validate_api_key(&kit.uri(), "sk_live_whatever") .await .expect("a 200 carrying app_name validates"); assert_eq!(name, "goingson"); } #[tokio::test] async fn validate_api_key_reports_401_as_a_server_error() { ensure_crypto_provider(); let kit = MockKit::start().await; kit.post(VALIDATE_PATH).code(401).text("nope").await; let err = synckit_client::validate_api_key(&kit.uri(), "sk_live_wrong") .await .expect_err("a rejected key is an error"); assert!( matches!(err, SyncKitError::Server { status: 401, .. }), "expected a 401 Server error, got {err:?}" ); } #[tokio::test] async fn validate_api_key_unreachable_server_is_not_the_401_variant() { ensure_crypto_provider(); // Bind then drop, so the port is closed and nothing is listening. A setup UI // must not tell the user their key is wrong when the server is down. let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind a loopback port"); let port = listener.local_addr().expect("the bound address").port(); drop(listener); let err = synckit_client::validate_api_key(&format!("http://127.0.0.1:{port}"), "sk_live_any") .await .expect_err("a closed port cannot validate anything"); assert!( !matches!(err, SyncKitError::Server { status: 401, .. }), "an unreachable server must not read as a rejected key, got {err:?}" ); }