| 2 |
2 |
|
|
| 3 |
3 |
|
use reqwest::Client;
|
| 4 |
4 |
|
use std::sync::Mutex;
|
|
5 |
+ |
use std::time::Duration;
|
| 5 |
6 |
|
use uuid::Uuid;
|
| 6 |
7 |
|
|
|
8 |
+ |
use base64::Engine;
|
|
9 |
+ |
|
| 7 |
10 |
|
use crate::{
|
| 8 |
11 |
|
crypto,
|
| 9 |
12 |
|
error::{Result, SyncKitError},
|
| 11 |
14 |
|
types::*,
|
| 12 |
15 |
|
};
|
| 13 |
16 |
|
|
|
17 |
+ |
/// Maximum number of retry attempts for transient failures.
|
|
18 |
+ |
const MAX_RETRIES: u32 = 3;
|
|
19 |
+ |
|
|
20 |
+ |
/// Base delay for exponential backoff (1s, 2s, 4s).
|
|
21 |
+ |
const BASE_DELAY: Duration = Duration::from_secs(1);
|
|
22 |
+ |
|
|
23 |
+ |
/// Seconds before actual expiry to consider the token expired.
|
|
24 |
+ |
/// Avoids sending a request with a token that expires mid-flight.
|
|
25 |
+ |
const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
|
|
26 |
+ |
|
| 14 |
27 |
|
/// Configuration for the SyncKit client.
|
| 15 |
28 |
|
#[derive(Debug, Clone)]
|
| 16 |
29 |
|
pub struct SyncKitConfig {
|
| 45 |
58 |
|
impl SyncKitClient {
|
| 46 |
59 |
|
/// Create a new client with the given configuration.
|
| 47 |
60 |
|
pub fn new(config: SyncKitConfig) -> Self {
|
|
61 |
+ |
let http = Client::builder()
|
|
62 |
+ |
.timeout(std::time::Duration::from_secs(30))
|
|
63 |
+ |
.connect_timeout(std::time::Duration::from_secs(10))
|
|
64 |
+ |
.build()
|
|
65 |
+ |
.expect("failed to build HTTP client");
|
|
66 |
+ |
|
| 48 |
67 |
|
Self {
|
| 49 |
68 |
|
config,
|
| 50 |
|
- |
http: Client::new(),
|
|
69 |
+ |
http,
|
| 51 |
70 |
|
session: Mutex::new(None),
|
| 52 |
71 |
|
master_key: Mutex::new(None),
|
| 53 |
72 |
|
}
|
| 123 |
142 |
|
tracing::info!("Session restored for user {user_id}, app {app_id}");
|
| 124 |
143 |
|
}
|
| 125 |
144 |
|
|
|
145 |
+ |
/// Check whether the current session token has expired (or will expire
|
|
146 |
+ |
/// within a 30-second buffer). Returns `true` if there is no session or
|
|
147 |
+ |
/// if the token's `exp` claim is in the past. Returns `false` if the
|
|
148 |
+ |
/// token cannot be decoded (assumes not expired — the server will reject
|
|
149 |
+ |
/// it with a 401 if it actually is).
|
|
150 |
+ |
pub fn is_token_expired(&self) -> bool {
|
|
151 |
+ |
let guard = self.session.lock().unwrap();
|
|
152 |
+ |
let Some(session) = guard.as_ref() else {
|
|
153 |
+ |
return true;
|
|
154 |
+ |
};
|
|
155 |
+ |
token_is_expired(&session.token)
|
|
156 |
+ |
}
|
|
157 |
+ |
|
| 126 |
158 |
|
// ── OAuth ──
|
| 127 |
159 |
|
|
| 128 |
160 |
|
/// Build the authorization URL for the OAuth2 PKCE flow.
|
| 272 |
304 |
|
) -> Result<()> {
|
| 273 |
305 |
|
let (app_id, user_id) = self.require_session_ids()?;
|
| 274 |
306 |
|
|
| 275 |
|
- |
// Get the current master key (from memory or re-derive from old password)
|
| 276 |
|
- |
let master_key = {
|
|
307 |
+ |
// Get the current master key (from memory or re-derive from old password).
|
|
308 |
+ |
// Check the in-memory cache first, dropping the lock before any .await.
|
|
309 |
+ |
let cached = {
|
| 277 |
310 |
|
let guard = self.master_key.lock().unwrap();
|
| 278 |
|
- |
if let Some(ref key) = *guard {
|
| 279 |
|
- |
**key
|
| 280 |
|
- |
} else {
|
| 281 |
|
- |
// Fall back to decrypting from server
|
| 282 |
|
- |
let envelope_json = self.get_server_key().await?;
|
| 283 |
|
- |
let wrapping_key =
|
| 284 |
|
- |
crypto::derive_wrapping_key(old_password, app_id, user_id)?;
|
| 285 |
|
- |
crypto::unwrap_master_key(&envelope_json, &wrapping_key)?
|
| 286 |
|
- |
}
|
|
311 |
+ |
guard.as_ref().map(|key| **key)
|
|
312 |
+ |
};
|
|
313 |
+ |
|
|
314 |
+ |
let master_key = if let Some(key) = cached {
|
|
315 |
+ |
key
|
|
316 |
+ |
} else {
|
|
317 |
+ |
let envelope_json = self.get_server_key().await?;
|
|
318 |
+ |
let wrapping_key =
|
|
319 |
+ |
crypto::derive_wrapping_key(old_password, app_id, user_id)?;
|
|
320 |
+ |
crypto::unwrap_master_key(&envelope_json, &wrapping_key)?
|
| 287 |
321 |
|
};
|
| 288 |
322 |
|
|
| 289 |
323 |
|
// Re-wrap with new password
|
| 344 |
378 |
|
|
| 345 |
379 |
|
/// Push changes to the server. Encrypts `data` fields automatically.
|
| 346 |
380 |
|
/// Returns the server cursor after the push.
|
|
381 |
+ |
///
|
|
382 |
+ |
/// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
|
| 347 |
383 |
|
pub async fn push(
|
| 348 |
384 |
|
&self,
|
| 349 |
385 |
|
device_id: Uuid,
|
| 352 |
388 |
|
let url = format!("{}/api/sync/push", self.config.server_url);
|
| 353 |
389 |
|
let token = self.require_token()?;
|
| 354 |
390 |
|
|
|
391 |
+ |
// Encrypt once, before the retry loop
|
| 355 |
392 |
|
let wire_changes = changes
|
| 356 |
393 |
|
.into_iter()
|
| 357 |
394 |
|
.map(|c| self.encrypt_change(c))
|
| 358 |
395 |
|
.collect::<Result<Vec<_>>>()?;
|
| 359 |
396 |
|
|
|
397 |
+ |
let body = serde_json::to_vec(&WirePushRequest {
|
|
398 |
+ |
device_id,
|
|
399 |
+ |
changes: wire_changes,
|
|
400 |
+ |
})?;
|
|
401 |
+ |
|
| 360 |
402 |
|
let resp = self
|
| 361 |
|
- |
.http
|
| 362 |
|
- |
.post(&url)
|
| 363 |
|
- |
.bearer_auth(&token)
|
| 364 |
|
- |
.json(&WirePushRequest {
|
| 365 |
|
- |
device_id,
|
| 366 |
|
- |
changes: wire_changes,
|
|
403 |
+ |
.retry_request(|| {
|
|
404 |
+ |
let req = self
|
|
405 |
+ |
.http
|
|
406 |
+ |
.post(&url)
|
|
407 |
+ |
.bearer_auth(&token)
|
|
408 |
+ |
.header("content-type", "application/json")
|
|
409 |
+ |
.body(body.clone());
|
|
410 |
+ |
async move { check_response(req.send().await?).await }
|
| 367 |
411 |
|
})
|
| 368 |
|
- |
.send()
|
| 369 |
412 |
|
.await?;
|
| 370 |
413 |
|
|
| 371 |
|
- |
let resp = check_response(resp).await?;
|
| 372 |
414 |
|
let push_resp: PushResponse = resp.json().await?;
|
| 373 |
415 |
|
Ok(push_resp.cursor)
|
| 374 |
416 |
|
}
|
| 376 |
418 |
|
/// Pull changes from the server since the given cursor.
|
| 377 |
419 |
|
/// Decrypts `data` fields automatically.
|
| 378 |
420 |
|
/// Returns (changes, new_cursor, has_more).
|
|
421 |
+ |
///
|
|
422 |
+ |
/// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
|
| 379 |
423 |
|
pub async fn pull(
|
| 380 |
424 |
|
&self,
|
| 381 |
425 |
|
device_id: Uuid,
|
| 384 |
428 |
|
let url = format!("{}/api/sync/pull", self.config.server_url);
|
| 385 |
429 |
|
let token = self.require_token()?;
|
| 386 |
430 |
|
|
|
431 |
+ |
let body = serde_json::to_vec(&PullRequest { device_id, cursor })?;
|
|
432 |
+ |
|
| 387 |
433 |
|
let resp = self
|
| 388 |
|
- |
.http
|
| 389 |
|
- |
.post(&url)
|
| 390 |
|
- |
.bearer_auth(&token)
|
| 391 |
|
- |
.json(&PullRequest { device_id, cursor })
|
| 392 |
|
- |
.send()
|
|
434 |
+ |
.retry_request(|| {
|
|
435 |
+ |
let req = self
|
|
436 |
+ |
.http
|
|
437 |
+ |
.post(&url)
|
|
438 |
+ |
.bearer_auth(&token)
|
|
439 |
+ |
.header("content-type", "application/json")
|
|
440 |
+ |
.body(body.clone());
|
|
441 |
+ |
async move { check_response(req.send().await?).await }
|
|
442 |
+ |
})
|
| 393 |
443 |
|
.await?;
|
| 394 |
444 |
|
|
| 395 |
|
- |
let resp = check_response(resp).await?;
|
| 396 |
445 |
|
let pull_resp: PullResponse = resp.json().await?;
|
| 397 |
446 |
|
|
| 398 |
447 |
|
let changes = pull_resp
|
| 420 |
469 |
|
Ok(resp.json().await?)
|
| 421 |
470 |
|
}
|
| 422 |
471 |
|
|
|
472 |
+ |
// ── Blobs ──
|
|
473 |
+ |
|
|
474 |
+ |
/// Request a presigned upload URL for a blob.
|
|
475 |
+ |
/// Returns (upload_url, already_exists). If `already_exists` is true,
|
|
476 |
+ |
/// the blob is already on the server and no upload is needed.
|
|
477 |
+ |
pub async fn blob_upload_url(
|
|
478 |
+ |
&self,
|
|
479 |
+ |
hash: &str,
|
|
480 |
+ |
size_bytes: i64,
|
|
481 |
+ |
) -> Result<BlobUploadUrlResponse> {
|
|
482 |
+ |
let url = format!("{}/api/sync/blobs/upload", self.config.server_url);
|
|
483 |
+ |
let token = self.require_token()?;
|
|
484 |
+ |
|
|
485 |
+ |
let resp = self
|
|
486 |
+ |
.http
|
|
487 |
+ |
.post(&url)
|
|
488 |
+ |
.bearer_auth(&token)
|
|
489 |
+ |
.json(&BlobUploadUrlRequest {
|
|
490 |
+ |
hash: hash.to_string(),
|
|
491 |
+ |
size_bytes,
|
|
492 |
+ |
})
|
|
493 |
+ |
.send()
|
|
494 |
+ |
.await?;
|
|
495 |
+ |
|
|
496 |
+ |
let resp = check_response(resp).await?;
|
|
497 |
+ |
Ok(resp.json().await?)
|
|
498 |
+ |
}
|
|
499 |
+ |
|
|
500 |
+ |
/// Upload blob data directly to S3 via a presigned PUT URL.
|
|
501 |
+ |
pub async fn blob_upload(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
|
|
502 |
+ |
let resp = self
|
|
503 |
+ |
.http
|
|
504 |
+ |
.put(presigned_url)
|
|
505 |
+ |
.header("content-type", "application/octet-stream")
|
|
506 |
+ |
.body(data)
|
|
507 |
+ |
.send()
|
|
508 |
+ |
.await?;
|
|
509 |
+ |
|
|
510 |
+ |
if !resp.status().is_success() {
|
|
511 |
+ |
let status = resp.status().as_u16();
|
|
512 |
+ |
let message = resp.text().await.unwrap_or_default();
|
|
513 |
+ |
return Err(SyncKitError::Server { status, message });
|
|
514 |
+ |
}
|
|
515 |
+ |
|
|
516 |
+ |
Ok(())
|
|
517 |
+ |
}
|
|
518 |
+ |
|
|
519 |
+ |
/// Confirm that a blob upload completed successfully.
|
|
520 |
+ |
/// The server verifies the object exists in S3 and records it.
|
|
521 |
+ |
pub async fn blob_confirm(&self, hash: &str, size_bytes: i64) -> Result<()> {
|
|
522 |
+ |
let url = format!("{}/api/sync/blobs/confirm", self.config.server_url);
|
|
523 |
+ |
let token = self.require_token()?;
|
|
524 |
+ |
|
|
525 |
+ |
let resp = self
|
|
526 |
+ |
.http
|
|
527 |
+ |
.post(&url)
|
|
528 |
+ |
.bearer_auth(&token)
|
|
529 |
+ |
.json(&BlobConfirmRequest {
|
|
530 |
+ |
hash: hash.to_string(),
|
|
531 |
+ |
size_bytes,
|
|
532 |
+ |
})
|
|
533 |
+ |
.send()
|
|
534 |
+ |
.await?;
|
|
535 |
+ |
|
|
536 |
+ |
check_response(resp).await?;
|
|
537 |
+ |
Ok(())
|
|
538 |
+ |
}
|
|
539 |
+ |
|
|
540 |
+ |
/// Get a presigned download URL for a blob by hash.
|
|
541 |
+ |
pub async fn blob_download_url(&self, hash: &str) -> Result<String> {
|
|
542 |
+ |
let url = format!("{}/api/sync/blobs/download", self.config.server_url);
|
|
543 |
+ |
let token = self.require_token()?;
|
|
544 |
+ |
|
|
545 |
+ |
let resp = self
|
|
546 |
+ |
.http
|
|
547 |
+ |
.post(&url)
|
|
548 |
+ |
.bearer_auth(&token)
|
|
549 |
+ |
.json(&BlobDownloadUrlRequest {
|
|
550 |
+ |
hash: hash.to_string(),
|
|
551 |
+ |
})
|
|
552 |
+ |
.send()
|
|
553 |
+ |
.await?;
|
|
554 |
+ |
|
|
555 |
+ |
let resp = check_response(resp).await?;
|
|
556 |
+ |
let body: BlobDownloadUrlResponse = resp.json().await?;
|
|
557 |
+ |
Ok(body.download_url)
|
|
558 |
+ |
}
|
|
559 |
+ |
|
|
560 |
+ |
/// Download blob data from S3 via a presigned GET URL.
|
|
561 |
+ |
pub async fn blob_download(&self, presigned_url: &str) -> Result<Vec<u8>> {
|
|
562 |
+ |
let resp = self
|
|
563 |
+ |
.http
|
|
564 |
+ |
.get(presigned_url)
|
|
565 |
+ |
.send()
|
|
566 |
+ |
.await?;
|
|
567 |
+ |
|
|
568 |
+ |
if !resp.status().is_success() {
|
|
569 |
+ |
let status = resp.status().as_u16();
|
|
570 |
+ |
let message = resp.text().await.unwrap_or_default();
|
|
571 |
+ |
return Err(SyncKitError::Server { status, message });
|
|
572 |
+ |
}
|
|
573 |
+ |
|
|
574 |
+ |
Ok(resp.bytes().await?.to_vec())
|
|
575 |
+ |
}
|
|
576 |
+ |
|
| 423 |
577 |
|
// ── Internal helpers ──
|
| 424 |
578 |
|
|
| 425 |
579 |
|
fn require_token(&self) -> Result<String> {
|
| 426 |
580 |
|
let guard = self.session.lock().unwrap();
|
| 427 |
|
- |
guard
|
| 428 |
|
- |
.as_ref()
|
| 429 |
|
- |
.map(|s| s.token.clone())
|
| 430 |
|
- |
.ok_or(SyncKitError::NotAuthenticated)
|
|
581 |
+ |
let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?;
|
|
582 |
+ |
|
|
583 |
+ |
if token_is_expired(&session.token) {
|
|
584 |
+ |
return Err(SyncKitError::TokenExpired);
|
|
585 |
+ |
}
|
|
586 |
+ |
|
|
587 |
+ |
Ok(session.token.clone())
|
| 431 |
588 |
|
}
|
| 432 |
589 |
|
|
| 433 |
590 |
|
fn require_session_ids(&self) -> Result<(Uuid, Uuid)> {
|
| 484 |
641 |
|
Ok(key_resp.encrypted_key)
|
| 485 |
642 |
|
}
|
| 486 |
643 |
|
|
|
644 |
+ |
/// Retry an async HTTP operation with exponential backoff.
|
|
645 |
+ |
///
|
|
646 |
+ |
/// Retries on transient errors (network failures, 5xx, 429) up to [`MAX_RETRIES`]
|
|
647 |
+ |
/// times with delays of 1s, 2s, 4s. Returns the last error if all attempts fail.
|
|
648 |
+ |
/// Client errors (4xx except 429) are considered permanent and returned immediately.
|
|
649 |
+ |
async fn retry_request<F, Fut>(&self, mut operation: F) -> Result<reqwest::Response>
|
|
650 |
+ |
where
|
|
651 |
+ |
F: FnMut() -> Fut,
|
|
652 |
+ |
Fut: std::future::Future<Output = Result<reqwest::Response>>,
|
|
653 |
+ |
{
|
|
654 |
+ |
let mut last_err = None;
|
|
655 |
+ |
|
|
656 |
+ |
for attempt in 0..=MAX_RETRIES {
|
|
657 |
+ |
match operation().await {
|
|
658 |
+ |
Ok(resp) => return Ok(resp),
|
|
659 |
+ |
Err(err) => {
|
|
660 |
+ |
if !is_transient(&err) {
|
|
661 |
+ |
return Err(err);
|
|
662 |
+ |
}
|
|
663 |
+ |
|
|
664 |
+ |
if attempt < MAX_RETRIES {
|
|
665 |
+ |
let delay = BASE_DELAY * 2u32.pow(attempt);
|
|
666 |
+ |
tracing::debug!(
|
|
667 |
+ |
attempt = attempt + 1,
|
|
668 |
+ |
max_retries = MAX_RETRIES,
|
|
669 |
+ |
delay_ms = delay.as_millis() as u64,
|
|
670 |
+ |
error = %err,
|
|
671 |
+ |
"Transient error, retrying after backoff",
|
|
672 |
+ |
);
|
|
673 |
+ |
tokio::time::sleep(delay).await;
|
|
674 |
+ |
}
|
|
675 |
+ |
|
|
676 |
+ |
last_err = Some(err);
|
|
677 |
+ |
}
|
|
678 |
+ |
}
|
|
679 |
+ |
}
|
|
680 |
+ |
|
|
681 |
+ |
Err(last_err.expect("loop ran at least once"))
|
|
682 |
+ |
}
|
|
683 |
+ |
|
| 487 |
684 |
|
/// Encrypt the data field of a change entry for the wire.
|
| 488 |
685 |
|
fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> {
|
| 489 |
686 |
|
let encrypted_data = match entry.data {
|
| 523 |
720 |
|
}
|
| 524 |
721 |
|
}
|
| 525 |
722 |
|
|
|
723 |
+ |
/// Extract the `exp` claim from a JWT without verifying the signature.
|
|
724 |
+ |
///
|
|
725 |
+ |
/// JWTs are `header.payload.signature` where the payload is base64url-encoded JSON.
|
|
726 |
+ |
/// We decode the payload segment and read the `exp` field. Returns `None` if
|
|
727 |
+ |
/// the token is malformed or `exp` is missing.
|
|
728 |
+ |
fn jwt_exp(token: &str) -> Option<i64> {
|
|
729 |
+ |
let payload = token.split('.').nth(1)?;
|
|
730 |
+ |
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
731 |
+ |
.decode(payload)
|
|
732 |
+ |
.ok()?;
|
|
733 |
+ |
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
|
734 |
+ |
claims["exp"].as_i64()
|
|
735 |
+ |
}
|
|
736 |
+ |
|
|
737 |
+ |
/// Returns `true` if the token's `exp` claim is within [`TOKEN_EXPIRY_BUFFER_SECS`]
|
|
738 |
+ |
/// of the current time (or already past). Returns `false` if the token cannot
|
|
739 |
+ |
/// be decoded — in that case, let the server decide.
|
|
740 |
+ |
fn token_is_expired(token: &str) -> bool {
|
|
741 |
+ |
let Some(exp) = jwt_exp(token) else {
|
|
742 |
+ |
return false;
|
|
743 |
+ |
};
|
|
744 |
+ |
let now = chrono::Utc::now().timestamp();
|
|
745 |
+ |
now >= exp - TOKEN_EXPIRY_BUFFER_SECS
|
|
746 |
+ |
}
|
|
747 |
+ |
|
| 526 |
748 |
|
/// Check an HTTP response for errors, returning the response on success.
|
| 527 |
749 |
|
async fn check_response(resp: reqwest::Response) -> Result<reqwest::Response> {
|
| 528 |
750 |
|
let status = resp.status().as_u16();
|
| 532 |
754 |
|
}
|
| 533 |
755 |
|
Ok(resp)
|
| 534 |
756 |
|
}
|
|
757 |
+ |
|
|
758 |
+ |
/// Returns true if the error is transient and worth retrying.
|
|
759 |
+ |
///
|
|
760 |
+ |
/// Transient errors:
|
|
761 |
+ |
/// - Network-level failures (connection refused, timeout, DNS, etc.)
|
|
762 |
+ |
/// - Server errors (5xx)
|
|
763 |
+ |
/// - Rate limiting (429)
|
|
764 |
+ |
///
|
|
765 |
+ |
/// Permanent errors (not retried):
|
|
766 |
+ |
/// - Client errors (4xx except 429) — bad request, auth failure, not found, etc.
|
|
767 |
+ |
/// - Serialization errors, encryption errors, missing session, etc.
|
|
768 |
+ |
fn is_transient(err: &SyncKitError) -> bool {
|
|
769 |
+ |
match err {
|
|
770 |
+ |
SyncKitError::Http(e) => {
|
|
771 |
+ |
// All reqwest transport errors are transient (timeout, connect, DNS, etc.)
|
|
772 |
+ |
// except for builder errors which indicate programming mistakes.
|
|
773 |
+ |
!e.is_builder()
|
|
774 |
+ |
}
|
|
775 |
+ |
SyncKitError::Server { status, .. } => {
|
|
776 |
+ |
// 5xx = server error (transient), 429 = rate limited (transient)
|
|
777 |
+ |
*status >= 500 || *status == 429
|
|
778 |
+ |
}
|
|
779 |
+ |
// Everything else (auth, crypto, serialization) is permanent
|
|
780 |
+ |
_ => false,
|
|
781 |
+ |
}
|
|
782 |
+ |
}
|
|
783 |
+ |
|
|
784 |
+ |
#[cfg(test)]
|
|
785 |
+ |
mod tests {
|
|
786 |
+ |
use super::*;
|
|
787 |
+ |
use base64::Engine;
|
|
788 |
+ |
use chrono::Utc;
|
|
789 |
+ |
|
|
790 |
+ |
fn test_config() -> SyncKitConfig {
|
|
791 |
+ |
SyncKitConfig {
|
|
792 |
+ |
server_url: "https://example.com".to_string(),
|
|
793 |
+ |
api_key: "test-api-key-123".to_string(),
|
|
794 |
+ |
}
|
|
795 |
+ |
}
|
|
796 |
+ |
|
|
797 |
+ |
fn test_ids() -> (Uuid, Uuid) {
|
|
798 |
+ |
(
|
|
799 |
+ |
Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
|
800 |
+ |
Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
|
|
801 |
+ |
)
|
|
802 |
+ |
}
|
|
803 |
+ |
|
|
804 |
+ |
// ── SyncKitClient::new() construction ──
|
|
805 |
+ |
|
|
806 |
+ |
#[test]
|
|
807 |
+ |
fn new_client_starts_unauthenticated() {
|
|
808 |
+ |
let client = SyncKitClient::new(test_config());
|
|
809 |
+ |
assert!(client.session_info().is_none());
|
|
810 |
+ |
}
|
|
811 |
+ |
|
|
812 |
+ |
#[test]
|
|
813 |
+ |
fn new_client_has_no_master_key() {
|
|
814 |
+ |
let client = SyncKitClient::new(test_config());
|
|
815 |
+ |
assert!(!client.has_master_key());
|
|
816 |
+ |
}
|
|
817 |
+ |
|
|
818 |
+ |
#[test]
|
|
819 |
+ |
fn config_returns_provided_values() {
|
|
820 |
+ |
let client = SyncKitClient::new(test_config());
|
|
821 |
+ |
assert_eq!(client.config().server_url, "https://example.com");
|
|
822 |
+ |
assert_eq!(client.config().api_key, "test-api-key-123");
|
|
823 |
+ |
}
|
|
824 |
+ |
|
|
825 |
+ |
// ── SyncKitConfig ──
|
|
826 |
+ |
|
|
827 |
+ |
#[test]
|
|
828 |
+ |
fn config_clone() {
|
|
829 |
+ |
let config = test_config();
|
|
830 |
+ |
let cloned = config.clone();
|
|
831 |
+ |
assert_eq!(cloned.server_url, config.server_url);
|
|
832 |
+ |
assert_eq!(cloned.api_key, config.api_key);
|
|
833 |
+ |
}
|
|
834 |
+ |
|
|
835 |
+ |
#[test]
|
|
836 |
+ |
fn config_debug() {
|
|
837 |
+ |
let config = test_config();
|
|
838 |
+ |
let debug = format!("{:?}", config);
|
|
839 |
+ |
assert!(debug.contains("SyncKitConfig"));
|
|
840 |
+ |
assert!(debug.contains("example.com"));
|
|
841 |
+ |
}
|
|
842 |
+ |
|
|
843 |
+ |
// ── restore_session ──
|
|
844 |
+ |
|
|
845 |
+ |
#[test]
|
|
846 |
+ |
fn restore_session_makes_client_authenticated() {
|
|
847 |
+ |
let client = SyncKitClient::new(test_config());
|
|
848 |
+ |
let (app_id, user_id) = test_ids();
|
|
849 |
+ |
|
|
850 |
+ |
client.restore_session("fake-token", user_id, app_id);
|
|
851 |
+ |
|
|
852 |
+ |
let info = client.session_info().expect("session should exist");
|
|
853 |
+ |
assert_eq!(info.token, "fake-token");
|
|
854 |
+ |
assert_eq!(info.user_id, user_id);
|
|
855 |
+ |
assert_eq!(info.app_id, app_id);
|
|
856 |
+ |
}
|
|
857 |
+ |
|
|
858 |
+ |
#[test]
|
|
859 |
+ |
fn restore_session_overwrites_previous_session() {
|
|
860 |
+ |
let client = SyncKitClient::new(test_config());
|
|
861 |
+ |
let (app_id, user_id) = test_ids();
|
|
862 |
+ |
|
|
863 |
+ |
client.restore_session("first-token", user_id, app_id);
|
|
864 |
+ |
client.restore_session("second-token", user_id, app_id);
|
|
865 |
+ |
|
|
866 |
+ |
let info = client.session_info().unwrap();
|
|
867 |
+ |
assert_eq!(info.token, "second-token");
|
|
868 |
+ |
}
|
|
869 |
+ |
|
|
870 |
+ |
// ── require_token ──
|
|
871 |
+ |
|
|
872 |
+ |
#[test]
|
|
873 |
+ |
fn require_token_fails_without_session() {
|
|
874 |
+ |
let client = SyncKitClient::new(test_config());
|
|
875 |
+ |
let err = client.require_token().unwrap_err();
|
|
876 |
+ |
assert!(matches!(err, SyncKitError::NotAuthenticated));
|
|
877 |
+ |
}
|
|
878 |
+ |
|