• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

0xmichalis / nftbk / 19134733239

06 Nov 2025 11:51AM UTC coverage: 54.069% (-0.7%) from 54.81%
19134733239

push

github

0xmichalis
build: reuse upstream x402-axum middleware

2917 of 5395 relevant lines covered (54.07%)

10.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.75
/src/cli/commands/server/create.rs
1
use std::env;
2
use std::fs::File;
3
use std::io::Cursor;
4
use std::path::PathBuf;
5

6
use anyhow::{Context, Result};
7
use flate2::read::GzDecoder;
8
use reqwest::Client;
9
use tar::Archive;
10
use zip::ZipArchive;
11

12
use crate::cli::config::load_token_config;
13
use crate::cli::x402::X402PaymentHandler;
14
use crate::envvar::is_defined;
15
use crate::server::api::{self, BackupCreateResponse, BackupRequest, BackupResponse, Tokens};
16
use crate::server::archive::archive_format_from_user_agent;
17
use crate::server::hashing::compute_task_id;
18

19
use super::common::delete_backup;
20

21
const BACKUPS_API_PATH: &str = "/v1/backups";
22

23
#[derive(Debug)]
24
enum BackupStart {
25
    Created(BackupCreateResponse),
26
    Exists(BackupCreateResponse),
27
    Conflict {
28
        retry_url: String,
29
        message: String,
30
        instance: Option<String>,
31
    },
32
}
33

34
fn extract_task_id_from_retry_url(retry_url: &str) -> Option<String> {
×
35
    // Expected format: {BACKUPS_API_PATH}/{task_id}/retry
36
    let prefix = format!("{}/", BACKUPS_API_PATH);
×
37
    if !retry_url.starts_with(&prefix) {
×
38
        return None;
×
39
    }
40
    let tail = &retry_url[prefix.len()..];
×
41
    let parts: Vec<&str> = tail.split('/').collect();
×
42
    if parts.is_empty() {
×
43
        return None;
×
44
    }
45
    Some(parts[0].to_string())
×
46
}
47

48
#[allow(clippy::too_many_arguments)]
49
async fn force_delete_and_retry(
×
50
    client: &Client,
51
    server_address: &str,
52
    auth_token: Option<&str>,
53
    retry_url: &str,
54
    instance_url: Option<&str>,
55
    token_config: &crate::TokenConfig,
56
    user_agent: &str,
57
    pin_on_ipfs: bool,
58
    x402_private_key: Option<&str>,
59
) -> Result<String> {
60
    let source = if !retry_url.is_empty() {
×
61
        Some(retry_url)
×
62
    } else {
63
        instance_url
×
64
    };
65
    if let Some(src) = source {
×
66
        if let Some(conflict_task_id) = extract_task_id_from_retry_url(src) {
×
67
            let _ = delete_backup(client, server_address, &conflict_task_id, auth_token).await;
×
68
            let start2 = request_backup(
69
                token_config,
×
70
                server_address,
×
71
                client,
×
72
                auth_token,
×
73
                user_agent,
×
74
                pin_on_ipfs,
×
75
                x402_private_key,
×
76
            )
77
            .await?;
×
78
            return match start2 {
×
79
                BackupStart::Created(r) | BackupStart::Exists(r) => Ok(r.task_id.clone()),
×
80
                BackupStart::Conflict {
81
                    message, retry_url, ..
×
82
                } => anyhow::bail!(
×
83
                    "{}\nCould not create new task after force delete. Try POST to {}{}",
×
84
                    message,
85
                    server_address.trim_end_matches('/'),
×
86
                    retry_url
87
                ),
88
            };
89
        }
90
    }
91
    anyhow::bail!(
×
92
        "Could not parse conflict task id from server response (missing retry/instance URL)"
93
    )
94
}
95

96
pub async fn run(
1✔
97
    tokens_config_path: PathBuf,
98
    server_address: String,
99
    output_path: Option<PathBuf>,
100
    force: bool,
101
    user_agent: String,
102
    pin_on_ipfs: bool,
103
    polling_interval_ms: Option<u64>,
104
) -> Result<()> {
105
    let token_config = load_token_config(&tokens_config_path).await?;
3✔
106
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
3✔
107
    let x402_private_key = env::var("NFTBK_X402_PRIVATE_KEY").ok();
3✔
108
    let client = Client::new();
2✔
109

110
    // Build the backup request to compute task_id
111
    let mut backup_req = BackupRequest {
112
        tokens: Vec::new(),
1✔
113
        pin_on_ipfs,
114
        create_archive: true,
115
    };
116
    for (chain, tokens) in &token_config.chains {
5✔
117
        backup_req.tokens.push(Tokens {
3✔
118
            chain: chain.clone(),
3✔
119
            tokens: tokens.clone(),
1✔
120
        });
121
    }
122

123
    // Compute task_id to check if backup exists without making POST request
124
    let task_id = compute_task_id(&backup_req.tokens, auth_token.as_deref());
5✔
125

126
    // Check if backup exists using GET endpoint (no payment required)
127
    let backup_exists =
1✔
128
        check_backup_exists(&client, &server_address, &task_id, auth_token.as_deref()).await?;
6✔
129

130
    let final_task_id = if backup_exists {
2✔
131
        println!("Task ID (exists): {task_id}");
×
132
        if force {
×
133
            // Delete existing backup and create new one
134
            let _ = delete_backup(&client, &server_address, &task_id, auth_token.as_deref()).await;
×
135
            let start = request_backup(
136
                &token_config,
×
137
                &server_address,
×
138
                &client,
×
139
                auth_token.as_deref(),
×
140
                &user_agent,
×
141
                pin_on_ipfs,
×
142
                x402_private_key.as_deref(),
×
143
            )
144
            .await?;
×
145
            match start {
×
146
                BackupStart::Created(resp) | BackupStart::Exists(resp) => {
×
147
                    let task_id2 = resp.task_id.clone();
×
148
                    println!("Task ID (after delete): {task_id2}");
×
149
                    task_id2
×
150
                }
151
                BackupStart::Conflict {
152
                    message: _,
153
                    retry_url,
×
154
                    instance,
×
155
                } => {
156
                    let task_id2 = force_delete_and_retry(
157
                        &client,
×
158
                        &server_address,
×
159
                        auth_token.as_deref(),
×
160
                        &retry_url,
×
161
                        instance.as_deref(),
×
162
                        &token_config,
×
163
                        &user_agent,
×
164
                        pin_on_ipfs,
×
165
                        x402_private_key.as_deref(),
×
166
                    )
167
                    .await?;
×
168
                    println!("Task ID (after force delete): {task_id2}");
×
169
                    task_id2
×
170
                }
171
            }
172
        } else {
173
            task_id
×
174
        }
175
    } else {
176
        // Backup doesn't exist, create it
177
        let start = request_backup(
178
            &token_config,
2✔
179
            &server_address,
2✔
180
            &client,
2✔
181
            auth_token.as_deref(),
3✔
182
            &user_agent,
2✔
183
            pin_on_ipfs,
2✔
184
            x402_private_key.as_deref(),
2✔
185
        )
186
        .await?;
1✔
187
        match start {
1✔
188
            BackupStart::Created(resp) => {
1✔
189
                let task_id = resp.task_id.clone();
3✔
190
                println!("Task ID: {task_id}");
2✔
191
                task_id
1✔
192
            }
193
            BackupStart::Exists(resp) => {
×
194
                let task_id = resp.task_id.clone();
×
195
                println!("Task ID (exists): {task_id}");
×
196
                task_id
×
197
            }
198
            BackupStart::Conflict {
199
                retry_url,
×
200
                message: _,
201
                instance,
×
202
            } => {
203
                if force {
×
204
                    let task_id = force_delete_and_retry(
205
                        &client,
×
206
                        &server_address,
×
207
                        auth_token.as_deref(),
×
208
                        &retry_url,
×
209
                        instance.as_deref(),
×
210
                        &token_config,
×
211
                        &user_agent,
×
212
                        pin_on_ipfs,
×
213
                        x402_private_key.as_deref(),
×
214
                    )
215
                    .await?;
×
216
                    println!("Task ID (after force delete): {task_id}");
×
217
                    task_id
×
218
                } else {
219
                    anyhow::bail!(
×
220
                        "Conflict creating backup. Re-run with --force true, or POST to {}{}",
×
221
                        server_address.trim_end_matches('/'),
×
222
                        retry_url
223
                    );
224
                }
225
            }
226
        }
227
    };
228

229
    let polling_interval = polling_interval_ms.unwrap_or(10000); // Default 10 seconds
3✔
230
    wait_for_done_backup(
231
        &client,
2✔
232
        &server_address,
2✔
233
        &final_task_id,
2✔
234
        auth_token.as_deref(),
3✔
235
        polling_interval,
1✔
236
    )
237
    .await?;
1✔
238

239
    return download_backup(
2✔
240
        &client,
2✔
241
        &server_address,
2✔
242
        &final_task_id,
2✔
243
        output_path.as_ref(),
3✔
244
        auth_token.as_deref(),
3✔
245
        &archive_format_from_user_agent(&user_agent),
1✔
246
    )
247
    .await;
1✔
248
}
249

250
async fn request_backup(
7✔
251
    token_config: &crate::TokenConfig,
252
    server_address: &str,
253
    client: &Client,
254
    auth_token: Option<&str>,
255
    user_agent: &str,
256
    pin_on_ipfs: bool,
257
    x402_private_key: Option<&str>,
258
) -> Result<BackupStart> {
259
    let mut backup_req = BackupRequest {
260
        tokens: Vec::new(),
7✔
261
        pin_on_ipfs,
262
        create_archive: true,
263
    };
264
    for (chain, tokens) in &token_config.chains {
35✔
265
        backup_req.tokens.push(Tokens {
21✔
266
            chain: chain.clone(),
21✔
267
            tokens: tokens.clone(),
7✔
268
        });
269
    }
270

271
    let server = server_address.trim_end_matches('/');
21✔
272
    println!("Submitting backup request to server at {server}{BACKUPS_API_PATH} ...");
14✔
273
    let mut req_builder = client
14✔
274
        .post(format!("{server}{BACKUPS_API_PATH}"))
21✔
275
        .json(&backup_req);
14✔
276
    req_builder = req_builder.header("User-Agent", user_agent);
28✔
277
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
31✔
278
        req_builder =
2✔
279
            req_builder.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
5✔
280
    }
281
    let req = req_builder.build().context("Failed to build request")?;
28✔
282
    let mut resp = client
14✔
283
        .execute(req.try_clone().context("Failed to clone request")?)
28✔
284
        .await
7✔
285
        .context("Failed to send backup request to server")?;
286
    let mut status = resp.status();
21✔
287

288
    // Handle 402 Payment Required response
289
    if status.as_u16() == 402 {
7✔
290
        resp = handle_402_response(client, resp, &req, x402_private_key).await?;
×
291
        status = resp.status();
×
292
    }
293

294
    if status.is_success() {
14✔
295
        let backup_resp: BackupCreateResponse =
10✔
296
            resp.json().await.context("Invalid server response")?;
15✔
297
        if status.as_u16() == 201 {
5✔
298
            return Ok(BackupStart::Created(backup_resp));
4✔
299
        } else {
300
            return Ok(BackupStart::Exists(backup_resp));
1✔
301
        }
302
    }
303
    if status.as_u16() == 409 {
2✔
304
        let body: serde_json::Value = resp
4✔
305
            .json()
306
            .await
1✔
307
            .context("Invalid conflict response from server")?;
308
        let retry_url = body
2✔
309
            .get("retry_url")
310
            .and_then(|v| v.as_str())
3✔
311
            .unwrap_or("")
312
            .to_string();
313
        let message = body
2✔
314
            .get("error")
315
            .and_then(|v| v.as_str())
3✔
316
            .unwrap_or(&format!("Server returned conflict for {BACKUPS_API_PATH}"))
2✔
317
            .to_string();
318
        let instance = body
2✔
319
            .get("instance")
320
            .and_then(|v| v.as_str())
1✔
321
            .map(|s| s.to_string());
1✔
322
        return Ok(BackupStart::Conflict {
1✔
323
            retry_url,
2✔
324
            message,
1✔
325
            instance,
1✔
326
        });
327
    }
328
    let text = resp.text().await.unwrap_or_default();
4✔
329
    anyhow::bail!("Server error: {}", text);
2✔
330
}
331

332
/// Handle 402 Payment Required response by creating a payment and retrying the request
333
async fn handle_402_response(
×
334
    client: &Client,
335
    response: reqwest::Response,
336
    original_request: &reqwest::Request,
337
    x402_private_key: Option<&str>,
338
) -> Result<reqwest::Response> {
339
    // Parse the 402 response structure
340
    let response_text = response
×
341
        .text()
342
        .await
×
343
        .context("Failed to read 402 response body")?;
344
    let response_json: serde_json::Value =
×
345
        serde_json::from_str(&response_text).context("Failed to parse 402 response as JSON")?;
×
346

347
    // Extract PaymentRequirements from the accepts array
348
    let accepts = response_json
×
349
        .get("accepts")
350
        .and_then(|v| v.as_array())
×
351
        .context("Missing or invalid 'accepts' field in 402 response")?;
352

353
    if accepts.is_empty() {
×
354
        anyhow::bail!("No payment options available in 402 response");
×
355
    }
356

357
    // Use the first payment option
358
    let payment_requirements: x402_rs::types::PaymentRequirements =
×
359
        serde_json::from_value(accepts[0].clone())
×
360
            .context("Failed to parse PaymentRequirements from accepts array")?;
361

362
    // Create x402 payment handler
363
    let payment_handler = X402PaymentHandler::new(x402_private_key)
×
364
        .context("Failed to create x402 payment handler")?;
365

366
    // Handle the payment and retry the request
367
    let response = payment_handler
×
368
        .handle_402_response(client, original_request, payment_requirements)
×
369
        .await
×
370
        .context("Failed to handle x402 payment")?;
371

372
    Ok(response)
×
373
}
374

375
/// Check if a backup exists by making a GET request to the backup status endpoint
376
async fn check_backup_exists(
1✔
377
    client: &Client,
378
    server_address: &str,
379
    task_id: &str,
380
    auth_token: Option<&str>,
381
) -> Result<bool> {
382
    let server = server_address.trim_end_matches('/');
3✔
383
    let mut req_builder = client.get(format!("{server}/v1/backups/{task_id}"));
5✔
384

385
    if let Some(token) = auth_token {
1✔
386
        req_builder = req_builder.header("Authorization", format!("Bearer {}", token));
×
387
    }
388

389
    let resp = req_builder.send().await?;
3✔
390
    let status = resp.status();
3✔
391

392
    match status.as_u16() {
1✔
393
        200 => Ok(true),  // Backup exists
×
394
        404 => Ok(false), // Backup doesn't exist
1✔
395
        _ => {
396
            let text = resp.text().await.unwrap_or_default();
×
397
            anyhow::bail!("Failed to check backup status: {} - {}", status, text);
×
398
        }
399
    }
400
}
401

402
async fn wait_for_done_backup(
4✔
403
    client: &reqwest::Client,
404
    server_address: &str,
405
    task_id: &str,
406
    auth_token: Option<&str>,
407
    polling_interval_ms: u64,
408
) -> Result<()> {
409
    let status_url = format!(
8✔
410
        "{}{}/{}",
411
        server_address.trim_end_matches('/'),
8✔
412
        BACKUPS_API_PATH,
413
        task_id
414
    );
415
    let mut in_progress_logged = false;
8✔
416
    loop {
417
        let mut req = client.get(&status_url);
16✔
418
        if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
19✔
419
            req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
6✔
420
        }
421
        let resp = req.send().await;
12✔
422
        match resp {
4✔
423
            Ok(r) => {
4✔
424
                if r.status().is_success() {
8✔
425
                    let status: BackupResponse = r.json().await.unwrap_or({
20✔
426
                        // Fallback shape with nulls
427
                        BackupResponse {
4✔
428
                            task_id: task_id.to_string(),
12✔
429
                            created_at: String::new(),
8✔
430
                            storage_mode: String::new(),
8✔
431
                            tokens: Vec::new(),
8✔
432
                            total_tokens: 0,
4✔
433
                            page: 1,
4✔
434
                            limit: 50,
4✔
435
                            archive: Some(api::Archive {
8✔
436
                                status: api::SubresourceStatus {
12✔
437
                                    status: None,
16✔
438
                                    fatal_error: None,
16✔
439
                                    error_log: None,
12✔
440
                                    deleted_at: None,
12✔
441
                                },
442
                                format: None,
8✔
443
                                expires_at: None,
8✔
444
                            }),
445
                            pins: Some(api::Pins {
4✔
446
                                status: api::SubresourceStatus {
4✔
447
                                    status: None,
8✔
448
                                    fatal_error: None,
8✔
449
                                    error_log: None,
4✔
450
                                    deleted_at: None,
4✔
451
                                },
452
                            }),
453
                        }
454
                    });
455
                    // Aggregate a coarse "overall" view for UX: in_progress if any subresource is in_progress
456
                    let archive_status = status
8✔
457
                        .archive
4✔
458
                        .as_ref()
459
                        .and_then(|a| a.status.status.as_deref());
12✔
460
                    let ipfs_status = status
8✔
461
                        .pins
4✔
462
                        .as_ref()
463
                        .and_then(|p| p.status.status.as_deref());
12✔
464
                    let any_in_progress = matches!(archive_status, Some("in_progress"))
12✔
465
                        || matches!(ipfs_status, Some("in_progress"));
8✔
466
                    let any_error = matches!(archive_status, Some("error"))
13✔
467
                        || matches!(ipfs_status, Some("error"))
6✔
468
                        || status
3✔
469
                            .archive
3✔
470
                            .as_ref()
3✔
471
                            .and_then(|a| a.status.fatal_error.as_ref())
9✔
472
                            .is_some()
3✔
473
                        || status
3✔
474
                            .pins
3✔
475
                            .as_ref()
3✔
476
                            .and_then(|p| p.status.fatal_error.as_ref())
9✔
477
                            .is_some();
3✔
478
                    let all_done = matches!(archive_status, Some("done"))
15✔
479
                        && (ipfs_status.is_none() || matches!(ipfs_status, Some("done")));
15✔
480
                    if any_in_progress {
4✔
481
                        if !in_progress_logged {
×
482
                            println!("Waiting for backup to complete...");
×
483
                            in_progress_logged = true;
×
484
                        }
485
                    } else if all_done {
4✔
486
                        println!("Backup complete.");
6✔
487
                        if let Some(ref a) = status
3✔
488
                            .archive
3✔
489
                            .as_ref()
490
                            .and_then(|a| a.status.error_log.as_ref())
9✔
491
                        {
492
                            if !a.is_empty() {
×
493
                                tracing::warn!("{}", a);
×
494
                            }
495
                        }
496
                        if let Some(ref i) = status
3✔
497
                            .pins
3✔
498
                            .as_ref()
499
                            .and_then(|p| p.status.error_log.as_ref())
9✔
500
                        {
501
                            if !i.is_empty() {
×
502
                                tracing::warn!("{}", i);
×
503
                            }
504
                        }
505
                        break;
3✔
506
                    } else if any_error {
1✔
507
                        let msg = status
2✔
508
                            .archive
1✔
509
                            .as_ref()
510
                            .and_then(|a| a.status.fatal_error.clone())
3✔
511
                            .or(status
2✔
512
                                .pins
1✔
513
                                .as_ref()
1✔
514
                                .and_then(|p| p.status.fatal_error.clone()))
3✔
515
                            .unwrap_or_else(|| "Unknown error".to_string());
1✔
516
                        anyhow::bail!("Server error: {}", msg);
2✔
517
                    } else {
518
                        println!(
×
519
                            "Unknown status: archive={:?} ipfs={:?}",
×
520
                            status
×
521
                                .archive
×
522
                                .as_ref()
×
523
                                .and_then(|a| a.status.status.as_deref()),
×
524
                            status
×
525
                                .pins
×
526
                                .as_ref()
×
527
                                .and_then(|p| p.status.status.as_deref())
×
528
                        );
529
                    }
530
                } else {
531
                    println!("Failed to get status: {}", r.status());
×
532
                }
533
            }
534
            Err(e) => {
×
535
                println!("Error polling status: {e}");
×
536
            }
537
        }
538
        tokio::time::sleep(std::time::Duration::from_millis(polling_interval_ms)).await;
×
539
    }
540
    Ok(())
3✔
541
}
542

543
async fn download_backup(
6✔
544
    client: &Client,
545
    server_address: &str,
546
    task_id: &str,
547
    output_path: Option<&PathBuf>,
548
    _auth_token: Option<&str>,
549
    archive_format: &str,
550
) -> Result<()> {
551
    // Step 1: Get download token
552
    let token_url = format!(
12✔
553
        "{}{}/{}/download-tokens",
554
        server_address.trim_end_matches('/'),
12✔
555
        BACKUPS_API_PATH,
556
        task_id
557
    );
558
    let mut token_req = client.post(&token_url);
24✔
559
    if is_defined(&_auth_token.as_ref().map(|s| s.to_string())) {
24✔
560
        token_req = token_req.header("Authorization", format!("Bearer {}", _auth_token.unwrap()));
×
561
    }
562
    let token_resp = token_req
18✔
563
        .send()
564
        .await
6✔
565
        .context("Failed to get download token")?;
566
    if !token_resp.status().is_success() {
6✔
567
        anyhow::bail!("Failed to get download token: {}", token_resp.status());
4✔
568
    }
569
    let token_json: serde_json::Value =
10✔
570
        token_resp.json().await.context("Invalid token response")?;
15✔
571
    let download_token = token_json
10✔
572
        .get("token")
573
        .and_then(|v| v.as_str())
15✔
574
        .ok_or_else(|| anyhow::anyhow!("No token in response"))?;
5✔
575

576
    // Step 2: Download using token
577
    let download_url = format!(
10✔
578
        "{}{}/{}/download?token={}",
579
        server_address.trim_end_matches('/'),
10✔
580
        BACKUPS_API_PATH,
581
        task_id,
582
        urlencoding::encode(download_token)
10✔
583
    );
584
    println!("Downloading archive ...");
10✔
585
    let resp = client
15✔
586
        .get(&download_url)
15✔
587
        .send()
588
        .await
5✔
589
        .context("Failed to download archive")?;
590
    if !resp.status().is_success() {
5✔
591
        anyhow::bail!("Failed to download archive: {}", resp.status());
4✔
592
    }
593
    let bytes = resp.bytes().await.context("Failed to read archive bytes")?;
16✔
594
    let output_path = output_path.cloned().unwrap_or_else(|| PathBuf::from("."));
17✔
595

596
    println!("Extracting backup to {}...", output_path.display());
12✔
597
    match archive_format {
4✔
598
        "tar.gz" => {
4✔
599
            let gz = GzDecoder::new(Cursor::new(bytes));
8✔
600
            let mut archive = Archive::new(gz);
6✔
601
            archive
2✔
602
                .unpack(&output_path)
4✔
603
                .context("Failed to extract backup archive (tar.gz)")?;
604
        }
605
        "zip" => {
2✔
606
            let mut zip =
1✔
607
                ZipArchive::new(Cursor::new(bytes)).context("Failed to read zip archive")?;
4✔
608
            for i in 0..zip.len() {
3✔
609
                let mut file = zip.by_index(i).context("Failed to access file in zip")?;
5✔
610
                let outpath = match file.enclosed_name() {
2✔
611
                    Some(path) => output_path.join(path),
3✔
612
                    None => continue,
×
613
                };
614
                if file.name().ends_with('/') {
2✔
615
                    std::fs::create_dir_all(&outpath)
×
616
                        .context("Failed to create directory from zip")?;
617
                } else {
618
                    if let Some(p) = outpath.parent() {
2✔
619
                        std::fs::create_dir_all(p)
2✔
620
                            .context("Failed to create parent directory for zip file")?;
621
                    }
622
                    let mut outfile =
1✔
623
                        File::create(&outpath).context("Failed to create file from zip")?;
3✔
624
                    std::io::copy(&mut file, &mut outfile)
3✔
625
                        .context("Failed to extract file from zip")?;
626
                }
627
            }
628
        }
629
        _ => anyhow::bail!("Unknown archive format: {archive_format}"),
2✔
630
    }
631
    println!("Backup extracted to {}", output_path.display());
9✔
632
    Ok(())
3✔
633
}
634

635
#[cfg(test)]
636
mod tests {
637
    use super::*;
638
    use crate::types::TokenConfig;
639
    use std::collections::HashMap;
640
    use tempfile::TempDir;
641
    use wiremock::matchers::{header, method, path, query_param};
642
    use wiremock::{Mock, MockServer, ResponseTemplate};
643

644
    fn create_test_token_config() -> TokenConfig {
645
        let mut chains = HashMap::new();
646
        chains.insert("ethereum".to_string(), vec!["0x123:1".to_string()]);
647
        TokenConfig { chains }
648
    }
649

650
    fn create_test_backup_create_response() -> BackupCreateResponse {
651
        BackupCreateResponse {
652
            task_id: "test-task-123".to_string(),
653
        }
654
    }
655

656
    fn create_test_backup_response() -> BackupResponse {
657
        BackupResponse {
658
            task_id: "test-task-123".to_string(),
659
            created_at: "2023-01-01T00:00:00Z".to_string(),
660
            storage_mode: "full".to_string(),
661
            tokens: vec![],
662
            total_tokens: 5,
663
            page: 1,
664
            limit: 50,
665
            archive: Some(api::Archive {
666
                status: api::SubresourceStatus {
667
                    status: Some("done".to_string()),
668
                    fatal_error: None,
669
                    error_log: None,
670
                    deleted_at: None,
671
                },
672
                format: Some("zip".to_string()),
673
                expires_at: None,
674
            }),
675
            pins: Some(api::Pins {
676
                status: api::SubresourceStatus {
677
                    status: Some("done".to_string()),
678
                    fatal_error: None,
679
                    error_log: None,
680
                    deleted_at: None,
681
                },
682
            }),
683
        }
684
    }
685

686
    mod request_backup_tests {
687
        use super::*;
688

689
        #[tokio::test]
690
        async fn creates_new_backup_successfully() {
691
            let mock_server = MockServer::start().await;
692
            let server_address = mock_server.uri();
693
            let token_config = create_test_token_config();
694

695
            let backup_response = create_test_backup_create_response();
696

697
            Mock::given(method("POST"))
698
                .and(path("/v1/backups"))
699
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
700
                .mount(&mock_server)
701
                .await;
702

703
            let client = Client::new();
704
            let result = request_backup(
705
                &token_config,
706
                &server_address,
707
                &client,
708
                None,
709
                "TestAgent",
710
                false,
711
                None,
712
            )
713
            .await;
714

715
            assert!(result.is_ok());
716
            match result.unwrap() {
717
                BackupStart::Created(resp) => {
718
                    assert_eq!(resp.task_id, "test-task-123");
719
                }
720
                _ => panic!("Expected Created variant"),
721
            }
722
        }
723

724
        #[tokio::test]
725
        async fn handles_existing_backup() {
726
            let mock_server = MockServer::start().await;
727
            let server_address = mock_server.uri();
728
            let token_config = create_test_token_config();
729

730
            let backup_response = create_test_backup_create_response();
731

732
            Mock::given(method("POST"))
733
                .and(path("/v1/backups"))
734
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
735
                .mount(&mock_server)
736
                .await;
737

738
            let client = Client::new();
739
            let result = request_backup(
740
                &token_config,
741
                &server_address,
742
                &client,
743
                None,
744
                "TestAgent",
745
                false,
746
                None,
747
            )
748
            .await;
749

750
            assert!(result.is_ok());
751
            match result.unwrap() {
752
                BackupStart::Exists(resp) => {
753
                    assert_eq!(resp.task_id, "test-task-123");
754
                }
755
                _ => panic!("Expected Exists variant"),
756
            }
757
        }
758

759
        #[tokio::test]
760
        async fn handles_conflict_response() {
761
            let mock_server = MockServer::start().await;
762
            let server_address = mock_server.uri();
763
            let token_config = create_test_token_config();
764

765
            let conflict_response = serde_json::json!({
766
                "task_id": "conflict-task-456",
767
                "retry_url": "/v1/backups/conflict-task-456/retry",
768
                "error": "Task already in progress"
769
            });
770

771
            Mock::given(method("POST"))
772
                .and(path("/v1/backups"))
773
                .respond_with(ResponseTemplate::new(409).set_body_json(&conflict_response))
774
                .mount(&mock_server)
775
                .await;
776

777
            let client = Client::new();
778
            let result = request_backup(
779
                &token_config,
780
                &server_address,
781
                &client,
782
                None,
783
                "TestAgent",
784
                false,
785
                None,
786
            )
787
            .await;
788

789
            assert!(result.is_ok());
790
            match result.unwrap() {
791
                BackupStart::Conflict {
792
                    retry_url, message, ..
793
                } => {
794
                    assert_eq!(retry_url, "/v1/backups/conflict-task-456/retry");
795
                    assert_eq!(message, "Task already in progress");
796
                }
797
                _ => panic!("Expected Conflict variant"),
798
            }
799
        }
800

801
        #[tokio::test]
802
        async fn includes_auth_header_when_token_present() {
803
            let mock_server = MockServer::start().await;
804
            let server_address = mock_server.uri();
805
            let token_config = create_test_token_config();
806

807
            let backup_response = create_test_backup_create_response();
808

809
            Mock::given(method("POST"))
810
                .and(path("/v1/backups"))
811
                .and(header("Authorization", "Bearer test-token-123"))
812
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
813
                .mount(&mock_server)
814
                .await;
815

816
            let client = Client::new();
817
            let result = request_backup(
818
                &token_config,
819
                &server_address,
820
                &client,
821
                Some("test-token-123"),
822
                "TestAgent",
823
                false,
824
                None,
825
            )
826
            .await;
827

828
            assert!(result.is_ok());
829
        }
830

831
        #[tokio::test]
832
        async fn includes_user_agent_header() {
833
            let mock_server = MockServer::start().await;
834
            let server_address = mock_server.uri();
835
            let token_config = create_test_token_config();
836

837
            let backup_response = create_test_backup_create_response();
838

839
            Mock::given(method("POST"))
840
                .and(path("/v1/backups"))
841
                .and(header("User-Agent", "CustomAgent/1.0"))
842
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
843
                .mount(&mock_server)
844
                .await;
845

846
            let client = Client::new();
847
            let result = request_backup(
848
                &token_config,
849
                &server_address,
850
                &client,
851
                None,
852
                "CustomAgent/1.0",
853
                false,
854
                None,
855
            )
856
            .await;
857

858
            assert!(result.is_ok());
859
        }
860

861
        #[tokio::test]
862
        async fn handles_server_error() {
863
            let mock_server = MockServer::start().await;
864
            let server_address = mock_server.uri();
865
            let token_config = create_test_token_config();
866

867
            Mock::given(method("POST"))
868
                .and(path("/v1/backups"))
869
                .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
870
                .mount(&mock_server)
871
                .await;
872

873
            let client = Client::new();
874
            let result = request_backup(
875
                &token_config,
876
                &server_address,
877
                &client,
878
                None,
879
                "TestAgent",
880
                false,
881
                None,
882
            )
883
            .await;
884

885
            assert!(result.is_err());
886
            assert!(result.unwrap_err().to_string().contains("Server error"));
887
        }
888
    }
889

890
    mod wait_for_done_backup_tests {
891
        use super::*;
892

893
        #[tokio::test]
894
        async fn waits_for_backup_completion() {
895
            let mock_server = MockServer::start().await;
896
            let server_address = mock_server.uri();
897
            let task_id = "test-task-123";
898

899
            let backup_response = create_test_backup_response();
900

901
            Mock::given(method("GET"))
902
                .and(path(format!("/v1/backups/{}", task_id)))
903
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
904
                .mount(&mock_server)
905
                .await;
906

907
            let client = Client::new();
908
            let result = wait_for_done_backup(&client, &server_address, task_id, None, 10).await;
909

910
            assert!(result.is_ok());
911
        }
912

913
        #[tokio::test]
914
        async fn handles_backup_with_errors() {
915
            let mock_server = MockServer::start().await;
916
            let server_address = mock_server.uri();
917
            let task_id = "test-task-error";
918

919
            let mut backup_response = create_test_backup_response();
920
            backup_response.archive.as_mut().unwrap().status.status = Some("error".to_string());
921
            backup_response.archive.as_mut().unwrap().status.fatal_error =
922
                Some("Archive creation failed".to_string());
923

924
            Mock::given(method("GET"))
925
                .and(path(format!("/v1/backups/{}", task_id)))
926
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
927
                .mount(&mock_server)
928
                .await;
929

930
            let client = Client::new();
931
            let result = wait_for_done_backup(&client, &server_address, task_id, None, 10).await;
932

933
            if result.is_ok() {
934
                println!("Expected error but got Ok result");
935
            }
936
            assert!(result.is_err());
937
            assert!(result.unwrap_err().to_string().contains("Server error"));
938
        }
939

940
        #[tokio::test]
941
        async fn includes_auth_header_when_token_present() {
942
            let mock_server = MockServer::start().await;
943
            let server_address = mock_server.uri();
944
            let task_id = "test-task-auth";
945

946
            let backup_response = create_test_backup_response();
947

948
            Mock::given(method("GET"))
949
                .and(path(format!("/v1/backups/{}", task_id)))
950
                .and(header("Authorization", "Bearer test-token-123"))
951
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
952
                .mount(&mock_server)
953
                .await;
954

955
            let client = Client::new();
956
            let result = wait_for_done_backup(
957
                &client,
958
                &server_address,
959
                task_id,
960
                Some("test-token-123"),
961
                10,
962
            )
963
            .await;
964

965
            assert!(result.is_ok());
966
        }
967
    }
968

969
    mod download_backup_tests {
970
        use super::*;
971

972
        #[tokio::test]
973
        async fn downloads_and_extracts_zip_archive() {
974
            let mock_server = MockServer::start().await;
975
            let server_address = mock_server.uri();
976
            let task_id = "test-task-123";
977

978
            // Create a temporary directory for extraction
979
            let temp_dir = TempDir::new().unwrap();
980
            let output_path = temp_dir.path();
981

982
            // Mock download token response
983
            let token_response = serde_json::json!({
984
                "token": "download-token-123"
985
            });
986

987
            Mock::given(method("POST"))
988
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
989
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
990
                .mount(&mock_server)
991
                .await;
992

993
            // Create a simple zip file content
994
            let mut zip_data = Vec::new();
995
            {
996
                use std::io::Write;
997
                let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_data));
998
                zip.start_file("test.txt", zip::write::FileOptions::default())
999
                    .unwrap();
1000
                zip.write_all(b"Hello, World!").unwrap();
1001
                zip.finish().unwrap();
1002
            }
1003

1004
            // Mock download response
1005
            Mock::given(method("GET"))
1006
                .and(path(format!("/v1/backups/{}/download", task_id)))
1007
                .and(query_param("token", "download-token-123"))
1008
                .respond_with(ResponseTemplate::new(200).set_body_bytes(zip_data.clone()))
1009
                .mount(&mock_server)
1010
                .await;
1011

1012
            let client = Client::new();
1013
            let result = download_backup(
1014
                &client,
1015
                &server_address,
1016
                task_id,
1017
                Some(&output_path.to_path_buf()),
1018
                None,
1019
                "zip",
1020
            )
1021
            .await;
1022

1023
            assert!(result.is_ok());
1024

1025
            // Verify the file was extracted
1026
            let extracted_file = output_path.join("test.txt");
1027
            assert!(extracted_file.exists());
1028
            let content = std::fs::read_to_string(&extracted_file).unwrap();
1029
            assert_eq!(content, "Hello, World!");
1030
        }
1031

1032
        #[tokio::test]
1033
        async fn downloads_and_extracts_tar_gz_archive() {
1034
            let mock_server = MockServer::start().await;
1035
            let server_address = mock_server.uri();
1036
            let task_id = "test-task-tar";
1037

1038
            // Create a temporary directory for extraction
1039
            let temp_dir = TempDir::new().unwrap();
1040
            let output_path = temp_dir.path();
1041

1042
            // Mock download token response
1043
            let token_response = serde_json::json!({
1044
                "token": "download-token-tar"
1045
            });
1046

1047
            Mock::given(method("POST"))
1048
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1049
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1050
                .mount(&mock_server)
1051
                .await;
1052

1053
            // Create a simple tar.gz file content
1054
            let mut tar_data = Vec::new();
1055
            {
1056
                use flate2::write::GzEncoder;
1057
                use flate2::Compression;
1058
                use tar::Builder;
1059

1060
                let gz = GzEncoder::new(&mut tar_data, Compression::default());
1061
                let mut tar = Builder::new(gz);
1062
                let mut header = tar::Header::new_gnu();
1063
                header.set_path("test.txt").unwrap();
1064
                header.set_size(13);
1065
                header.set_cksum();
1066
                tar.append(&header, &b"Hello, World!"[..]).unwrap();
1067
                tar.finish().unwrap();
1068
            }
1069

1070
            // Mock download response
1071
            Mock::given(method("GET"))
1072
                .and(path(format!("/v1/backups/{}/download", task_id)))
1073
                .and(query_param("token", "download-token-tar"))
1074
                .respond_with(ResponseTemplate::new(200).set_body_bytes(tar_data.clone()))
1075
                .mount(&mock_server)
1076
                .await;
1077

1078
            let client = Client::new();
1079
            let result = download_backup(
1080
                &client,
1081
                &server_address,
1082
                task_id,
1083
                Some(&output_path.to_path_buf()),
1084
                None,
1085
                "tar.gz",
1086
            )
1087
            .await;
1088

1089
            assert!(result.is_ok());
1090

1091
            // Verify the file was extracted
1092
            let extracted_file = output_path.join("test.txt");
1093
            assert!(extracted_file.exists());
1094
            let content = std::fs::read_to_string(&extracted_file).unwrap();
1095
            assert_eq!(content, "Hello, World!");
1096
        }
1097

1098
        #[tokio::test]
1099
        async fn handles_download_token_error() {
1100
            let mock_server = MockServer::start().await;
1101
            let server_address = mock_server.uri();
1102
            let task_id = "test-task-token-error";
1103

1104
            Mock::given(method("POST"))
1105
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1106
                .respond_with(ResponseTemplate::new(500).set_body_string("Token generation failed"))
1107
                .mount(&mock_server)
1108
                .await;
1109

1110
            let client = Client::new();
1111
            let result =
1112
                download_backup(&client, &server_address, task_id, None, None, "zip").await;
1113

1114
            assert!(result.is_err());
1115
            assert!(result
1116
                .unwrap_err()
1117
                .to_string()
1118
                .contains("Failed to get download token"));
1119
        }
1120

1121
        #[tokio::test]
1122
        async fn handles_download_error() {
1123
            let mock_server = MockServer::start().await;
1124
            let server_address = mock_server.uri();
1125
            let task_id = "test-task-download-error";
1126

1127
            // Mock download token response
1128
            let token_response = serde_json::json!({
1129
                "token": "download-token-error"
1130
            });
1131

1132
            Mock::given(method("POST"))
1133
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1134
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1135
                .mount(&mock_server)
1136
                .await;
1137

1138
            // Mock download error
1139
            Mock::given(method("GET"))
1140
                .and(path(format!("/v1/backups/{}/download", task_id)))
1141
                .respond_with(ResponseTemplate::new(500).set_body_string("Download failed"))
1142
                .mount(&mock_server)
1143
                .await;
1144

1145
            let client = Client::new();
1146
            let result =
1147
                download_backup(&client, &server_address, task_id, None, None, "zip").await;
1148

1149
            assert!(result.is_err());
1150
            assert!(result
1151
                .unwrap_err()
1152
                .to_string()
1153
                .contains("Failed to download archive"));
1154
        }
1155

1156
        #[tokio::test]
1157
        async fn handles_unknown_archive_format() {
1158
            let mock_server = MockServer::start().await;
1159
            let server_address = mock_server.uri();
1160
            let task_id = "test-task-unknown-format";
1161

1162
            // Mock download token response
1163
            let token_response = serde_json::json!({
1164
                "token": "download-token-unknown"
1165
            });
1166

1167
            Mock::given(method("POST"))
1168
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1169
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1170
                .mount(&mock_server)
1171
                .await;
1172

1173
            // Mock download response
1174
            Mock::given(method("GET"))
1175
                .and(path(format!("/v1/backups/{}/download", task_id)))
1176
                .respond_with(ResponseTemplate::new(200).set_body_bytes(b"some data"))
1177
                .mount(&mock_server)
1178
                .await;
1179

1180
            let client = Client::new();
1181
            let result =
1182
                download_backup(&client, &server_address, task_id, None, None, "unknown").await;
1183

1184
            assert!(result.is_err());
1185
            assert!(result
1186
                .unwrap_err()
1187
                .to_string()
1188
                .contains("Unknown archive format"));
1189
        }
1190
    }
1191

1192
    mod run_tests {
1193
        use super::*;
1194
        use std::fs;
1195

1196
        #[tokio::test]
1197
        async fn runs_successfully_with_valid_config() {
1198
            let mock_server = MockServer::start().await;
1199
            let server_address = mock_server.uri();
1200

1201
            // Create a temporary tokens config file
1202
            let temp_dir = TempDir::new().unwrap();
1203
            let tokens_config_path = temp_dir.path().join("tokens.toml");
1204
            fs::write(
1205
                &tokens_config_path,
1206
                r#"
1207
ethereum = ["0x123:1"]
1208
"#,
1209
            )
1210
            .unwrap();
1211

1212
            // Mock the backup creation
1213
            let backup_response = create_test_backup_create_response();
1214
            Mock::given(method("POST"))
1215
                .and(path("/v1/backups"))
1216
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
1217
                .mount(&mock_server)
1218
                .await;
1219

1220
            // Mock the status check
1221
            let status_response = create_test_backup_response();
1222
            Mock::given(method("GET"))
1223
                .and(path("/v1/backups/test-task-123"))
1224
                .respond_with(ResponseTemplate::new(200).set_body_json(&status_response))
1225
                .mount(&mock_server)
1226
                .await;
1227

1228
            // Mock download token response
1229
            let token_response = serde_json::json!({
1230
                "token": "download-token-123"
1231
            });
1232
            Mock::given(method("POST"))
1233
                .and(path("/v1/backups/test-task-123/download-tokens"))
1234
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1235
                .mount(&mock_server)
1236
                .await;
1237

1238
            // Create a simple tar.gz file content (Linux user agent maps to tar.gz)
1239
            let mut tar_data = Vec::new();
1240
            {
1241
                use flate2::write::GzEncoder;
1242
                use flate2::Compression;
1243
                use tar::Builder;
1244

1245
                let gz = GzEncoder::new(&mut tar_data, Compression::default());
1246
                let mut tar = Builder::new(gz);
1247
                let mut header = tar::Header::new_gnu();
1248
                header.set_path("test.txt").unwrap();
1249
                header.set_size(13);
1250
                header.set_cksum();
1251
                tar.append(&header, &b"Hello, World!"[..]).unwrap();
1252
                tar.finish().unwrap();
1253
            }
1254

1255
            // Mock download response
1256
            Mock::given(method("GET"))
1257
                .and(path("/v1/backups/test-task-123/download"))
1258
                .respond_with(ResponseTemplate::new(200).set_body_bytes(tar_data.clone()))
1259
                .mount(&mock_server)
1260
                .await;
1261

1262
            let result = run(
1263
                tokens_config_path,
1264
                server_address,
1265
                Some(temp_dir.path().to_path_buf()),
1266
                false,
1267
                "Linux".to_string(),
1268
                false,
1269
                Some(10), // Very fast polling for tests
1270
            )
1271
            .await;
1272

1273
            if let Err(e) = &result {
1274
                println!("Test failed with error: {}", e);
1275
            }
1276
            assert!(result.is_ok());
1277
        }
1278
    }
1279
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc