Skip to main content

max / synckit

10.4 KB · 351 lines History Blame Raw
1 //! Authentication, token handling, and session lifecycle.
2 //!
3 //! The credential exchange (`authenticate`, `authenticate_with_code`), what the
4 //! client does with a JWT that is expired or near expiry, and the session
5 //! transitions: restore, clear, re-authenticate over a live session.
6
7 use crate::common::*;
8
9 const AUTH_PATH: &str = "/api/v1/sync/auth";
10
11 // ── Auth flow ──
12
13 #[tokio::test]
14 async fn authenticate_success_stores_session() {
15 let kit = MockKit::start().await;
16 kit.post(AUTH_PATH).json(auth_response_json()).await;
17
18 let client = kit.client();
19 let (user_id, app_id) = client
20 .authenticate("user@test.com", "password", "test-key")
21 .await
22 .unwrap();
23
24 let info = client.session_info().expect("session stored");
25 assert_eq!(info.user_id, user_id);
26 assert_eq!(info.app_id, app_id);
27 }
28
29 #[tokio::test]
30 async fn authenticate_wrong_password_no_retry() {
31 let kit = MockKit::start().await;
32 // Exactly once: a retry on a rejected credential is a lockout waiting to
33 // happen, so the count is the assertion.
34 kit.post(AUTH_PATH)
35 .code(401)
36 .exactly(1)
37 .text("Unauthorized")
38 .await;
39
40 let client = kit.client();
41 let err = client
42 .authenticate("user@test.com", "wrong", "test-key")
43 .await
44 .unwrap_err();
45
46 assert!(
47 matches!(err, SyncKitError::Server { status: 401, .. }),
48 "Expected 401 error, got: {err:?}"
49 );
50 }
51
52 #[tokio::test]
53 async fn authenticate_retries_on_503() {
54 let kit = MockKit::start().await;
55 kit.post(AUTH_PATH)
56 .code(503)
57 .once()
58 .text("Service Unavailable")
59 .await;
60 kit.post(AUTH_PATH).json(auth_response_json()).await;
61
62 let client = kit.client();
63 let result = client
64 .authenticate("user@test.com", "password", "test-key")
65 .await;
66 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
67 }
68
69 #[tokio::test]
70 async fn authenticate_with_code_success() {
71 let kit = MockKit::start().await;
72
73 let (user_id, app_id) = test_ids();
74 kit.post("/oauth/token")
75 .json(json!({
76 "access_token": fresh_token(),
77 "token_type": "Bearer",
78 "expires_in": 3600,
79 "user_id": user_id,
80 "app_id": app_id,
81 }))
82 .await;
83
84 let client = kit.client();
85 let (uid, aid) = client
86 .authenticate_with_code("auth-code", "verifier", 8080, "test-key")
87 .await
88 .unwrap();
89
90 assert_eq!(uid, user_id);
91 assert_eq!(aid, app_id);
92 assert!(client.session_info().is_some());
93 }
94
95 // ── Token handling ──
96
97 #[tokio::test]
98 async fn expired_jwt_returns_token_expired() {
99 let kit = MockKit::start().await;
100 let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 3600));
101
102 let err = client.status().await.unwrap_err();
103 assert!(
104 matches!(err, SyncKitError::TokenExpired),
105 "Expected TokenExpired, got: {err:?}"
106 );
107 }
108
109 #[tokio::test]
110 async fn near_expiry_jwt_returns_token_expired() {
111 let kit = MockKit::start().await;
112 // Expires in 10 seconds, inside the 30-second pre-flight buffer.
113 let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() + 10));
114
115 let err = client.status().await.unwrap_err();
116 assert!(
117 matches!(err, SyncKitError::TokenExpired),
118 "Expected TokenExpired, got: {err:?}"
119 );
120 }
121
122 // ── Session management ──
123
124 #[tokio::test]
125 async fn restore_then_clear_session() {
126 let kit = MockKit::start().await;
127 kit.get("/api/v1/sync/status")
128 .json(json!({"total_changes": 0, "latest_cursor": null}))
129 .await;
130
131 let client = kit.authed();
132
133 // Should work while authenticated
134 let status = client.status().await.unwrap();
135 assert_eq!(status.total_changes, 0);
136
137 // Clear session
138 client.clear_session();
139
140 // Now should fail
141 let err = client.status().await.unwrap_err();
142 assert!(matches!(err, SyncKitError::NotAuthenticated));
143 }
144
145 #[tokio::test]
146 async fn status_without_auth_returns_not_authenticated() {
147 let kit = MockKit::start().await;
148 let err = kit.client().status().await.unwrap_err();
149 assert!(matches!(err, SyncKitError::NotAuthenticated));
150 }
151
152 #[tokio::test]
153 async fn push_without_auth_returns_not_authenticated() {
154 let kit = MockKit::start().await;
155 let err = kit
156 .client()
157 .push(DeviceId::new(Uuid::new_v4()), vec![])
158 .await
159 .unwrap_err();
160 assert!(matches!(err, SyncKitError::NotAuthenticated));
161 }
162
163 // ── Session expiry handling ──
164
165 /// A client whose restored token expired 100 seconds ago, holding a master key
166 /// so nothing but the expiry can stop the call under test.
167 fn expired_keyed_client(kit: &MockKit) -> SyncKitClient {
168 let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100));
169 client.set_master_key_raw(synckit_client::crypto::generate_master_key());
170 client
171 }
172
173 #[tokio::test]
174 async fn expired_token_detected_before_push() {
175 let kit = MockKit::start().await;
176 let err = expired_keyed_client(&kit)
177 .push(DeviceId::new(Uuid::new_v4()), vec![])
178 .await
179 .unwrap_err();
180 assert!(
181 matches!(err, SyncKitError::TokenExpired),
182 "Expired token should be detected pre-flight, got: {err:?}"
183 );
184 }
185
186 #[tokio::test]
187 async fn expired_token_detected_before_pull() {
188 let kit = MockKit::start().await;
189 let err = expired_keyed_client(&kit)
190 .pull(DeviceId::new(Uuid::new_v4()), 0)
191 .await
192 .unwrap_err();
193 assert!(matches!(err, SyncKitError::TokenExpired));
194 }
195
196 #[tokio::test]
197 async fn expired_token_detected_before_register_device() {
198 let kit = MockKit::start().await;
199 let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100));
200
201 let err = client.register_device("Test", "test").await.unwrap_err();
202 assert!(matches!(err, SyncKitError::TokenExpired));
203 }
204
205 #[tokio::test]
206 async fn expired_token_detected_before_list_devices() {
207 let kit = MockKit::start().await;
208 let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100));
209
210 let err = client.list_devices().await.unwrap_err();
211 assert!(matches!(err, SyncKitError::TokenExpired));
212 }
213
214 // ── Session edge cases ──
215
216 #[tokio::test]
217 async fn double_authenticate_overwrites_session() {
218 let kit = MockKit::start().await;
219
220 let (user_id, app_id) = test_ids();
221 let second_user_id = UserId::new(Uuid::new_v4());
222
223 kit.post(AUTH_PATH)
224 .once()
225 .json(json!({
226 "token": fresh_token(),
227 "user_id": user_id,
228 "app_id": app_id,
229 }))
230 .await;
231 // Second auth answers with a different user_id.
232 kit.post(AUTH_PATH)
233 .json(json!({
234 "token": fresh_token(),
235 "user_id": second_user_id,
236 "app_id": app_id,
237 }))
238 .await;
239
240 let client = kit.client();
241 let (uid1, _) = client
242 .authenticate("user1@test.com", "pass1", "test-key")
243 .await
244 .unwrap();
245 assert_eq!(uid1, user_id);
246
247 let (uid2, _) = client
248 .authenticate("user2@test.com", "pass2", "test-key")
249 .await
250 .unwrap();
251 assert_eq!(uid2, second_user_id);
252
253 // Session should now reflect the second auth
254 let info = client.session_info().unwrap();
255 assert_eq!(info.user_id, second_user_id);
256 }
257
258 #[tokio::test]
259 async fn clear_session_then_authenticate_succeeds() {
260 let kit = MockKit::start().await;
261 kit.post(AUTH_PATH).json(auth_response_json()).await;
262
263 let client = kit.authed();
264 assert!(client.session_info().is_some());
265
266 client.clear_session();
267 assert!(client.session_info().is_none());
268
269 // Re-authenticate should work
270 let result = client
271 .authenticate("user@test.com", "pass", "test-key")
272 .await;
273 assert!(
274 result.is_ok(),
275 "Should be able to re-authenticate after clear: {result:?}"
276 );
277 assert!(client.session_info().is_some());
278 }
279
280 #[tokio::test]
281 async fn restore_session_with_expired_token_then_push_returns_token_expired() {
282 let kit = MockKit::start().await;
283 let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 3600));
284 client.set_master_key_raw(synckit_client::crypto::generate_master_key());
285
286 let err = client
287 .push(DeviceId::new(Uuid::new_v4()), vec![])
288 .await
289 .unwrap_err();
290 assert!(
291 matches!(err, SyncKitError::TokenExpired),
292 "Restored expired token should return TokenExpired, got: {err:?}"
293 );
294 }
295
296 // ── API-key validation ──
297 //
298 // `validate_api_key` is a free function: it builds its own HTTP client rather
299 // than going through `SyncKitClient`, so nothing else in the suite covers it.
300 // A setup UI calls it before saving a key, and it has to tell "wrong key"
301 // (401) apart from "server unreachable" or the UI reports the wrong problem.
302
303 const VALIDATE_PATH: &str = "/api/v1/sync/validate-app";
304
305 #[tokio::test]
306 async fn validate_api_key_returns_the_app_name() {
307 ensure_crypto_provider();
308 let kit = MockKit::start().await;
309 kit.post(VALIDATE_PATH)
310 .json(json!({"app_name": "goingson"}))
311 .await;
312
313 let name = synckit_client::validate_api_key(&kit.uri(), "sk_live_whatever")
314 .await
315 .expect("a 200 carrying app_name validates");
316 assert_eq!(name, "goingson");
317 }
318
319 #[tokio::test]
320 async fn validate_api_key_reports_401_as_a_server_error() {
321 ensure_crypto_provider();
322 let kit = MockKit::start().await;
323 kit.post(VALIDATE_PATH).code(401).text("nope").await;
324
325 let err = synckit_client::validate_api_key(&kit.uri(), "sk_live_wrong")
326 .await
327 .expect_err("a rejected key is an error");
328 assert!(
329 matches!(err, SyncKitError::Server { status: 401, .. }),
330 "expected a 401 Server error, got {err:?}"
331 );
332 }
333
334 #[tokio::test]
335 async fn validate_api_key_unreachable_server_is_not_the_401_variant() {
336 ensure_crypto_provider();
337 // Bind then drop, so the port is closed and nothing is listening. A setup UI
338 // must not tell the user their key is wrong when the server is down.
339 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind a loopback port");
340 let port = listener.local_addr().expect("the bound address").port();
341 drop(listener);
342
343 let err = synckit_client::validate_api_key(&format!("http://127.0.0.1:{port}"), "sk_live_any")
344 .await
345 .expect_err("a closed port cannot validate anything");
346 assert!(
347 !matches!(err, SyncKitError::Server { status: 401, .. }),
348 "an unreachable server must not read as a rejected key, got {err:?}"
349 );
350 }
351