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

0xmichalis / nftbk / 18694098093

21 Oct 2025 06:38PM UTC coverage: 45.23% (+0.6%) from 44.656%
18694098093

push

github

0xmichalis
refactor: consolidate config validation under config.rs

26 of 42 new or added lines in 3 files covered. (61.9%)

2138 of 4727 relevant lines covered (45.23%)

7.86 hits per line

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

72.55
/src/config.rs
1
use anyhow::{Context, Result};
2
use serde::Deserialize;
3
use std::collections::HashMap;
4
use std::path::Path;
5
use tracing::{error, info};
6

7
use crate::ipfs::IpfsPinningConfig;
8
use crate::server::auth::x402::{X402Config, X402ConfigRaw};
9
use crate::server::auth::JwtCredential;
10
use crate::ChainConfig;
11

12
/// Raw configuration structure that combines chains, auth, and IPFS providers
13
#[derive(Debug, Deserialize)]
14
pub struct ConfigRaw {
15
    /// Chain configurations (RPC endpoints)
16
    #[serde(default)]
17
    pub chains: HashMap<String, String>,
18

19
    /// JWT authentication credentials
20
    #[serde(default)]
21
    pub jwt: Vec<JwtCredential>,
22

23
    /// x402 payment configuration
24
    #[serde(default)]
25
    pub x402: Option<X402ConfigRaw>,
26

27
    /// IPFS pinning providers
28
    #[serde(default)]
29
    pub ipfs_pinning_provider: Vec<IpfsPinningConfig>,
30
}
31

32
impl ConfigRaw {
33
    /// Load configuration from a TOML file
34
    pub fn load_from_file(path: &Path) -> Result<Self> {
23✔
35
        let contents = std::fs::read_to_string(path)
66✔
36
            .with_context(|| format!("Failed to read config file '{}'", path.display()))?;
35✔
37

38
        let mut config: ConfigRaw = toml::from_str(&contents)
18✔
39
            .with_context(|| format!("Failed to parse config file '{}'", path.display()))?;
8✔
40

41
        // Normalize JWT verification keys (replace \n with actual newlines)
42
        for jwt_cred in &mut config.jwt {
30✔
43
            jwt_cred.verification_key = jwt_cred.verification_key.replace("\\n", "\n");
44
        }
45

46
        Ok(config)
47
    }
48

49
    /// Convert to separate config structures for backward compatibility
50
    pub fn into_separate_configs(
×
51
        self,
52
    ) -> (
53
        ChainConfig,
54
        Vec<JwtCredential>,
55
        Option<X402ConfigRaw>,
56
        Vec<IpfsPinningConfig>,
57
    ) {
58
        let chain_config = ChainConfig(self.chains);
×
59
        (
60
            chain_config,
×
61
            self.jwt,
×
62
            self.x402,
×
63
            self.ipfs_pinning_provider,
×
64
        )
65
    }
66

67
    /// Get chain configuration
68
    pub fn chain_config(&self) -> ChainConfig {
13✔
69
        ChainConfig(self.chains.clone())
13✔
70
    }
71

72
    /// Get JWT credentials
73
    pub fn jwt_credentials(&self) -> &[JwtCredential] {
9✔
74
        &self.jwt
9✔
75
    }
76

77
    /// Get x402 configuration
78
    pub fn x402_config(&self) -> Option<&X402ConfigRaw> {
9✔
79
        self.x402.as_ref()
18✔
80
    }
81

82
    /// Get IPFS pinning providers
83
    pub fn ipfs_pinning_providers(&self) -> &[IpfsPinningConfig] {
13✔
84
        &self.ipfs_pinning_provider
13✔
85
    }
86
}
87

88
/// Container for all loaded and validated configuration
89
#[derive(Debug)]
90
pub struct Config {
91
    pub chain_config: ChainConfig,
92
    pub jwt_credentials: Vec<JwtCredential>,
93
    pub x402_config: Option<X402Config>,
94
    pub ipfs_pinning_configs: Vec<IpfsPinningConfig>,
95
}
96

97
/// Load and validate configuration from file with logging
98
pub fn load_and_validate_config(config_path: &str) -> Result<Config> {
11✔
99
    let config = match ConfigRaw::load_from_file(std::path::Path::new(config_path)) {
31✔
100
        Ok(config) => {
NEW
101
            info!("Loaded unified configuration from '{}'", config_path);
×
102
            config
103
        }
104
        Err(e) => {
2✔
105
            error!("Failed to load config from '{}': {}", config_path, e);
2✔
106
            return Err(e);
2✔
107
        }
108
    };
109

110
    let jwt_credentials = config.jwt_credentials().to_vec();
111
    if jwt_credentials.is_empty() {
112
        info!("No JWT credentials configured");
6✔
113
    } else {
114
        info!("Loaded {} JWT credential(s):", jwt_credentials.len());
3✔
115
        for cred in &jwt_credentials {
11✔
NEW
116
            info!("  - issuer: {}, audience: {}", cred.issuer, cred.audience);
×
117
        }
118
    }
119

120
    let x402_config = if let Some(raw_config) = config.x402_config() {
11✔
121
        match X402Config::compile(raw_config.clone()) {
122
            Ok(compiled_config) => {
2✔
123
                info!(
NEW
124
                    "Loaded x402 config (network: {}, facilitator: {})",
×
125
                    compiled_config.facilitator.network, compiled_config.facilitator.url
126
                );
127
                Some(compiled_config)
128
            }
129
            Err(e) => {
1✔
130
                error!("Failed to compile x402 config: {}", e);
1✔
131
                return Err(e);
1✔
132
            }
133
        }
134
    } else {
135
        None
6✔
136
    };
137

138
    let ipfs_pinning_configs = config.ipfs_pinning_providers().to_vec();
139
    if ipfs_pinning_configs.is_empty() {
140
        info!("No IPFS pinning providers configured");
6✔
141
    } else {
142
        info!(
2✔
NEW
143
            "Loaded {} IPFS pinning provider(s):",
×
NEW
144
            ipfs_pinning_configs.len()
×
145
        );
146
        for config in &ipfs_pinning_configs {
8✔
147
            info!(
NEW
148
                "  - type: {}, base_url: {}",
×
NEW
149
                config.provider_type(),
×
NEW
150
                config.base_url()
×
151
            );
152
        }
153
    }
154

155
    // Extract and resolve chain configuration
156
    let mut chain_config = config.chain_config();
157
    chain_config
158
        .resolve_env_vars()
159
        .with_context(|| "Failed to resolve environment variables in chain config")?;
1✔
160

161
    Ok(Config {
7✔
162
        chain_config,
7✔
163
        jwt_credentials,
7✔
164
        x402_config,
7✔
165
        ipfs_pinning_configs,
7✔
166
    })
167
}
168

169
#[cfg(test)]
170
mod tests {
171
    use super::*;
172
    use std::io::Write;
173
    use tempfile::NamedTempFile;
174

175
    fn create_temp_config(content: &str) -> NamedTempFile {
176
        let mut file = NamedTempFile::new().unwrap();
177
        write!(file, "{}", content).unwrap();
178
        file
179
    }
180

181
    #[test]
182
    fn loads_complete_config() {
183
        let config_content = r#"
184
[chains]
185
ethereum = "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}"
186
polygon = "https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}"
187

188
[[jwt]]
189
issuer = "test-issuer"
190
audience = "test-audience"
191
verification_key = "-----BEGIN PUBLIC KEY-----\ntest-key\n-----END PUBLIC KEY-----"
192

193
[x402]
194
asset_symbol = "USDC"
195
base_url = "http://localhost:8080/"
196
recipient_address = "0x1234567890123456789012345678901234567890"
197
max_timeout_seconds = 300
198

199
[x402.facilitator]
200
url = "https://x402.org/facilitator"
201
network = "base-sepolia"
202

203
[[ipfs_pinning_provider]]
204
type = "pinning-service"
205
base_url = "https://api.filebase.io/v1/ipfs"
206
bearer_token_env = "FILEBASE_TOKEN"
207
"#;
208
        let temp_file = create_temp_config(config_content);
209

210
        let result = ConfigRaw::load_from_file(temp_file.path());
211
        assert!(result.is_ok());
212

213
        let config = result.unwrap();
214
        assert_eq!(config.chains.len(), 2);
215
        assert_eq!(config.jwt.len(), 1);
216
        assert!(config.x402.is_some());
217
        assert_eq!(config.ipfs_pinning_provider.len(), 1);
218
    }
219

220
    #[test]
221
    fn loads_minimal_config() {
222
        let config_content = r#"
223
[chains]
224
ethereum = "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}"
225
"#;
226
        let temp_file = create_temp_config(config_content);
227

228
        let result = ConfigRaw::load_from_file(temp_file.path());
229
        assert!(result.is_ok());
230

231
        let config = result.unwrap();
232
        assert_eq!(config.chains.len(), 1);
233
        assert_eq!(config.jwt.len(), 0);
234
        assert!(config.x402.is_none());
235
        assert_eq!(config.ipfs_pinning_provider.len(), 0);
236
    }
237

238
    #[test]
239
    fn normalizes_jwt_verification_keys() {
240
        let config_content = r#"
241
[[jwt]]
242
issuer = "test-issuer"
243
audience = "test-audience"
244
verification_key = "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\\n-----END PUBLIC KEY-----"
245
"#;
246
        let temp_file = create_temp_config(config_content);
247

248
        let result = ConfigRaw::load_from_file(temp_file.path());
249
        assert!(result.is_ok());
250

251
        let config = result.unwrap();
252
        assert!(config.jwt[0].verification_key.contains('\n'));
253
        assert!(!config.jwt[0].verification_key.contains("\\n"));
254
    }
255

256
    #[test]
257
    fn handles_empty_config() {
258
        let config_content = "";
259
        let temp_file = create_temp_config(config_content);
260

261
        let result = ConfigRaw::load_from_file(temp_file.path());
262
        assert!(result.is_ok());
263

264
        let config = result.unwrap();
265
        assert_eq!(config.chains.len(), 0);
266
        assert_eq!(config.jwt.len(), 0);
267
        assert!(config.x402.is_none());
268
        assert_eq!(config.ipfs_pinning_provider.len(), 0);
269
    }
270

271
    #[test]
272
    fn returns_error_for_nonexistent_file() {
273
        let nonexistent_path = std::path::Path::new("/nonexistent/path/config.toml");
274

275
        let result = ConfigRaw::load_from_file(nonexistent_path);
276
        assert!(result.is_err());
277

278
        let error = result.unwrap_err();
279
        assert!(error.to_string().contains("Failed to read config file"));
280
    }
281

282
    #[test]
283
    fn returns_error_for_invalid_toml() {
284
        let config_content = "invalid toml content {";
285
        let temp_file = create_temp_config(config_content);
286

287
        let result = ConfigRaw::load_from_file(temp_file.path());
288
        assert!(result.is_err());
289

290
        let error = result.unwrap_err();
291
        assert!(error.to_string().contains("Failed to parse config file"));
292
    }
293

294
    mod load_and_validate_config_tests {
295
        use super::*;
296

297
        #[test]
298
        fn loads_and_validates_complete_config() {
299
            std::env::set_var("ALCHEMY_API_KEY", "test-key");
300
            std::env::set_var("FILEBASE_TOKEN", "test-token");
301

302
            let config_content = r#"
303
[chains]
304
ethereum = "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}"
305
polygon = "https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}"
306

307
[[jwt]]
308
issuer = "test-issuer"
309
audience = "test-audience"
310
verification_key = "-----BEGIN PUBLIC KEY-----\ntest-key\n-----END PUBLIC KEY-----"
311

312
[x402]
313
asset_symbol = "USDC"
314
base_url = "http://localhost:8080/"
315
recipient_address = "0x1234567890123456789012345678901234567890"
316
max_timeout_seconds = 300
317

318
[x402.facilitator]
319
url = "https://x402.org/facilitator"
320
network = "base-sepolia"
321

322
[[ipfs_pinning_provider]]
323
type = "pinning-service"
324
base_url = "https://api.filebase.io/v1/ipfs"
325
bearer_token_env = "FILEBASE_TOKEN"
326
"#;
327
            let temp_file = create_temp_config(config_content);
328

329
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
330
            assert!(result.is_ok());
331

332
            let config = result.unwrap();
333
            assert_eq!(config.chain_config.0.len(), 2);
334
            assert_eq!(config.jwt_credentials.len(), 1);
335
            assert!(config.x402_config.is_some());
336
            assert_eq!(config.ipfs_pinning_configs.len(), 1);
337

338
            // Verify environment variables were resolved
339
            assert!(config
340
                .chain_config
341
                .0
342
                .get("ethereum")
343
                .unwrap()
344
                .contains("test-key"));
345
            assert!(config
346
                .chain_config
347
                .0
348
                .get("polygon")
349
                .unwrap()
350
                .contains("test-key"));
351

352
            std::env::remove_var("ALCHEMY_API_KEY");
353
            std::env::remove_var("FILEBASE_TOKEN");
354
        }
355

356
        #[test]
357
        fn loads_and_validates_minimal_config() {
358
            let config_content = r#"
359
[chains]
360
ethereum = "https://eth-mainnet.g.alchemy.com/v2/test-key"
361
"#;
362
            let temp_file = create_temp_config(config_content);
363

364
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
365
            assert!(result.is_ok());
366

367
            let config = result.unwrap();
368
            assert_eq!(config.chain_config.0.len(), 1);
369
            assert_eq!(config.jwt_credentials.len(), 0);
370
            assert!(config.x402_config.is_none());
371
            assert_eq!(config.ipfs_pinning_configs.len(), 0);
372
        }
373

374
        #[test]
375
        fn loads_and_validates_config_with_jwt_only() {
376
            let config_content = r#"
377
[chains]
378
ethereum = "https://eth-mainnet.g.alchemy.com/v2/test-key"
379

380
[[jwt]]
381
issuer = "test-issuer"
382
audience = "test-audience"
383
verification_key = "-----BEGIN PUBLIC KEY-----\ntest-key\n-----END PUBLIC KEY-----"
384

385
[[jwt]]
386
issuer = "test-issuer-2"
387
audience = "test-audience-2"
388
verification_key = "-----BEGIN PUBLIC KEY-----\ntest-key-2\n-----END PUBLIC KEY-----"
389
"#;
390
            let temp_file = create_temp_config(config_content);
391

392
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
393
            assert!(result.is_ok());
394

395
            let config = result.unwrap();
396
            assert_eq!(config.jwt_credentials.len(), 2);
397
            assert_eq!(config.jwt_credentials[0].issuer, "test-issuer");
398
            assert_eq!(config.jwt_credentials[1].issuer, "test-issuer-2");
399
        }
400

401
        #[test]
402
        fn loads_and_validates_config_with_x402_only() {
403
            let config_content = r#"
404
[chains]
405
ethereum = "https://eth-mainnet.g.alchemy.com/v2/test-key"
406

407
[x402]
408
asset_symbol = "USDC"
409
base_url = "http://localhost:8080/"
410
recipient_address = "0x1234567890123456789012345678901234567890"
411
max_timeout_seconds = 300
412

413
[x402.facilitator]
414
url = "https://x402.org/facilitator"
415
network = "base-sepolia"
416
"#;
417
            let temp_file = create_temp_config(config_content);
418

419
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
420
            assert!(result.is_ok());
421

422
            let config = result.unwrap();
423
            assert!(config.x402_config.is_some());
424
            let x402_config = config.x402_config.unwrap();
425
            use x402_rs::network::Network;
426
            assert_eq!(x402_config.facilitator.network, Network::BaseSepolia);
427
            assert_eq!(x402_config.facilitator.url, "https://x402.org/facilitator");
428
        }
429

430
        #[test]
431
        fn loads_and_validates_config_with_ipfs_only() {
432
            let config_content = r#"
433
[chains]
434
ethereum = "https://eth-mainnet.g.alchemy.com/v2/test-key"
435

436
[[ipfs_pinning_provider]]
437
type = "pinata"
438
base_url = "https://api.pinata.cloud"
439
bearer_token_env = "PINATA_TOKEN"
440

441
[[ipfs_pinning_provider]]
442
type = "pinning-service"
443
base_url = "https://api.filebase.io/v1/ipfs"
444
bearer_token_env = "FILEBASE_TOKEN"
445
"#;
446
            let temp_file = create_temp_config(config_content);
447

448
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
449
            assert!(result.is_ok());
450

451
            let config = result.unwrap();
452
            assert_eq!(config.ipfs_pinning_configs.len(), 2);
453
        }
454

455
        #[test]
456
        fn returns_error_for_nonexistent_file() {
457
            let result = load_and_validate_config("/nonexistent/path/config.toml");
458
            assert!(result.is_err());
459

460
            let error = result.unwrap_err();
461
            assert!(error.to_string().contains("Failed to read config file"));
462
        }
463

464
        #[test]
465
        fn returns_error_for_invalid_toml() {
466
            let config_content = "invalid toml content {";
467
            let temp_file = create_temp_config(config_content);
468

469
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
470
            assert!(result.is_err());
471

472
            let error = result.unwrap_err();
473
            assert!(error.to_string().contains("Failed to parse config file"));
474
        }
475

476
        #[test]
477
        fn returns_error_for_invalid_x402_config() {
478
            let config_content = r#"
479
[chains]
480
ethereum = "https://eth-mainnet.g.alchemy.com/v2/test-key"
481

482
[x402]
483
asset_symbol = "USDC"
484
base_url = "http://localhost:8080/"
485
recipient_address = "invalid-address"
486
max_timeout_seconds = 300
487

488
[x402.facilitator]
489
url = "https://x402.org/facilitator"
490
network = "base-sepolia"
491
"#;
492
            let temp_file = create_temp_config(config_content);
493

494
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
495
            assert!(result.is_err());
496

497
            let error = result.unwrap_err();
498
            // The error message should contain information about the invalid address
499
            assert!(error.to_string().contains("invalid"));
500
        }
501

502
        #[test]
503
        fn returns_error_for_missing_env_vars_in_chain_config() {
504
            let config_content = r#"
505
[chains]
506
ethereum = "https://eth-mainnet.g.alchemy.com/v2/${MISSING_VAR}"
507
"#;
508
            let temp_file = create_temp_config(config_content);
509

510
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
511
            assert!(result.is_err());
512

513
            let error = result.unwrap_err();
514
            assert!(error
515
                .to_string()
516
                .contains("Failed to resolve environment variables in chain config"));
517
        }
518

519
        #[test]
520
        fn handles_empty_config() {
521
            let config_content = "";
522
            let temp_file = create_temp_config(config_content);
523

524
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
525
            assert!(result.is_ok());
526

527
            let config = result.unwrap();
528
            assert_eq!(config.chain_config.0.len(), 0);
529
            assert_eq!(config.jwt_credentials.len(), 0);
530
            assert!(config.x402_config.is_none());
531
            assert_eq!(config.ipfs_pinning_configs.len(), 0);
532
        }
533

534
        #[test]
535
        fn normalizes_jwt_verification_keys() {
536
            let config_content = r#"
537
[chains]
538
ethereum = "https://eth-mainnet.g.alchemy.com/v2/test-key"
539

540
[[jwt]]
541
issuer = "test-issuer"
542
audience = "test-audience"
543
verification_key = "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\\n-----END PUBLIC KEY-----"
544
"#;
545
            let temp_file = create_temp_config(config_content);
546

547
            let result = load_and_validate_config(&temp_file.path().to_string_lossy());
548
            assert!(result.is_ok());
549

550
            let config = result.unwrap();
551
            assert!(config.jwt_credentials[0].verification_key.contains('\n'));
552
            assert!(!config.jwt_credentials[0].verification_key.contains("\\n"));
553
        }
554
    }
555
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc