A Rust application to showcase badge awards in the AT Protocol ecosystem.
at main 881 lines 30 kB view raw
1//! Badge processing logic with signature validation and image handling. 2//! 3//! Processes badge award events by validating cryptographic signatures, 4//! downloading and processing badge images, and storing validated awards. 5 6use std::str::FromStr; 7use std::sync::Arc; 8 9use crate::errors::{Result, ShowcaseError}; 10use atproto_client::com::atproto::repo::{get_blob, get_record}; 11use atproto_identity::{ 12 key::{KeyData, identify_key, validate}, 13 model::Document, 14 resolve::IdentityResolver, 15 storage::DidDocumentStorage, 16}; 17use atproto_record::aturi::ATURI; 18use chrono::Utc; 19use image::{GenericImageView, ImageFormat}; 20use serde::{Deserialize, Serialize}; 21use serde_json::{Value, json}; 22use tracing::{error, info, warn}; 23 24use crate::{ 25 config::Config, 26 consumer::{AwardEvent, BadgeEventReceiver}, 27 storage::{Award, Badge, FileStorage, Storage}, 28}; 29 30/// Badge award record structure from AT Protocol 31#[derive(Debug, Deserialize, Serialize)] 32struct AwardRecord { 33 pub did: String, 34 pub badge: StrongRef, 35 pub issued: String, 36 pub signatures: Vec<Signature>, 37} 38 39/// Strong reference to another record 40#[derive(Debug, Deserialize, Serialize)] 41struct StrongRef { 42 #[serde(rename = "$type")] 43 pub type_: String, 44 pub uri: String, 45 pub cid: String, 46} 47 48/// Signature from badge issuer 49#[derive(Debug, Deserialize, Serialize)] 50struct Signature { 51 pub issuer: String, 52 #[serde(rename = "issuedAt")] 53 pub issued_at: String, 54 pub signature: String, 55} 56 57/// Badge definition record 58#[derive(Debug, Deserialize, Serialize)] 59struct BadgeRecord { 60 pub name: String, 61 pub description: String, 62 pub image: Option<BlobRef>, 63} 64 65/// Blob reference for badge images 66#[derive(Debug, Deserialize, Serialize)] 67struct BlobRef { 68 #[serde(rename = "$type")] 69 pub type_: String, 70 #[serde(rename = "ref")] 71 pub ref_: Option<LinkRef>, 72 #[serde(rename = "mimeType")] 73 pub mime_type: String, 74 pub size: u64, 75} 76 77/// Link reference in blob 78#[derive(Debug, Deserialize, Serialize)] 79struct LinkRef { 80 #[serde(rename = "$link")] 81 pub link: String, 82} 83 84impl BlobRef { 85 fn get_ref(&self) -> Option<String> { 86 self.ref_.as_ref().map(|r| r.link.clone()) 87 } 88} 89 90/// Background processor for badge events 91pub struct BadgeProcessor { 92 storage: Arc<dyn Storage>, 93 config: Arc<Config>, 94 identity_resolver: IdentityResolver, 95 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 96 http_client: reqwest::Client, 97 file_storage: Arc<dyn FileStorage>, 98} 99 100impl BadgeProcessor { 101 /// Create a new badge processor with the required dependencies. 102 pub fn new( 103 storage: Arc<dyn Storage>, 104 config: Arc<Config>, 105 identity_resolver: IdentityResolver, 106 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 107 http_client: reqwest::Client, 108 file_storage: Arc<dyn FileStorage>, 109 ) -> Self { 110 Self { 111 storage, 112 config, 113 identity_resolver, 114 document_storage, 115 http_client, 116 file_storage, 117 } 118 } 119 120 /// Start processing badge events from the queue 121 pub async fn start_processing(&self, mut event_receiver: BadgeEventReceiver) -> Result<()> { 122 info!("Badge processor started"); 123 124 while let Some(event) = event_receiver.recv().await { 125 match &event { 126 AwardEvent::Commit { 127 did, 128 cid, 129 record, 130 rkey, 131 .. 132 } => { 133 if let Err(e) = self.handle_commit(did, rkey, cid, record).await { 134 error!( 135 "error-showcase-process-6 Failed to process create event: {}", 136 e 137 ); 138 } 139 } 140 AwardEvent::Delete { did, rkey, .. } => { 141 if let Err(e) = self.handle_delete(did, rkey).await { 142 error!( 143 "error-showcase-process-8 Failed to process delete event: {}", 144 e 145 ); 146 } 147 } 148 } 149 } 150 151 info!("Badge processor finished"); 152 Ok(()) 153 } 154 155 async fn handle_commit(&self, did: &str, rkey: &str, cid: &str, record: &Value) -> Result<()> { 156 info!("Processing award: {} for {}", rkey, did); 157 158 let aturi = format!("at://{did}/community.lexicon.badge.award/{rkey}"); 159 160 // Parse the award record 161 let award_record: AwardRecord = serde_json::from_value(record.clone())?; 162 tracing::debug!(?award_record, "processing award"); 163 164 let badge_from_issuer = self.config.badge_issuers.iter().any(|value| { 165 award_record 166 .badge 167 .uri 168 .starts_with(&format!("at://{value}/")) 169 }); 170 if !badge_from_issuer { 171 return Ok(()); 172 } 173 174 // Ensure identity is stored 175 let document = self.ensure_identity_stored(did).await?; 176 177 // Get or create badge 178 let badge = self.get_or_create_badge(&award_record.badge).await?; 179 180 let badge_aturi = ATURI::from_str(&award_record.badge.uri)?; 181 182 let badge_isser_document = self.ensure_identity_stored(&badge_aturi.authority).await?; 183 184 let issuer_pds_endpoint = { 185 badge_isser_document 186 .pds_endpoints() 187 .first() 188 .ok_or_else(|| ShowcaseError::ProcessIdentityResolutionFailed { 189 did: did.to_string(), 190 details: "No PDS endpoint found in DID document".to_string(), 191 }) 192 .cloned()? 193 }; 194 195 self.download_badge_image(issuer_pds_endpoint, &badge_isser_document.id, &badge) 196 .await?; 197 198 // Validate signatures 199 let validated_issuers = self 200 .validate_signatures(record, &document.id, "community.lexicon.badge.award") 201 .await?; 202 203 // Create award record 204 let award = Award { 205 aturi: aturi.to_string(), 206 cid: cid.to_string(), 207 did: document.id.clone(), 208 badge: award_record.badge.uri.clone(), 209 badge_cid: award_record.badge.cid.clone(), 210 badge_name: badge.name, 211 validated_issuers: serde_json::to_value(&validated_issuers)?, 212 created_at: Utc::now(), 213 updated_at: Utc::now(), 214 record: record.clone(), 215 }; 216 217 // Store award 218 let is_new = self.storage.upsert_award(&award).await?; 219 220 // Update badge count if this is a new award 221 if is_new { 222 self.storage 223 .increment_badge_count(&award.badge, &award.badge_cid) 224 .await?; 225 } 226 227 // Trim awards for this DID to max 100 228 self.storage.trim_awards_for_did(did, 100).await?; 229 230 info!("Successfully processed award: {}", aturi); 231 Ok(()) 232 } 233 234 async fn handle_delete(&self, did: &str, rkey: &str) -> Result<()> { 235 let aturi = format!("at://{did}/community.lexicon.badge.award/{rkey}"); 236 237 if let Some(award) = self.storage.delete_award(&aturi).await? { 238 // Decrement badge count 239 self.storage 240 .decrement_badge_count(&award.badge, &award.badge_cid) 241 .await?; 242 info!("Successfully deleted award: {}", aturi); 243 } 244 245 Ok(()) 246 } 247 248 async fn ensure_identity_stored(&self, did: &str) -> Result<Document> { 249 // Check if we already have this identity 250 if let Some(document) = self.document_storage.get_document_by_did(did).await? { 251 return Ok(document); 252 } 253 254 let document = self.identity_resolver.resolve(did).await?; 255 self.document_storage 256 .store_document(document.clone()) 257 .await?; 258 Ok(document) 259 } 260 261 async fn get_or_create_badge(&self, badge_ref: &StrongRef) -> Result<BadgeRecord> { 262 // Check if we already have this badge 263 if let Some(existing) = self 264 .storage 265 .get_badge(&badge_ref.uri, &badge_ref.cid) 266 .await? 267 { 268 let badge_record = serde_json::from_value::<BadgeRecord>(existing.record.clone())?; 269 return Ok(badge_record); 270 } 271 272 // Parse AT-URI to get DID and record key 273 let parts: Vec<&str> = badge_ref 274 .uri 275 .strip_prefix("at://") 276 .unwrap_or(&badge_ref.uri) 277 .split('/') 278 .collect(); 279 if parts.len() != 3 { 280 return Err(ShowcaseError::ProcessInvalidAturi { 281 uri: badge_ref.uri.clone(), 282 }); 283 } 284 285 let repo = parts[0]; 286 let rkey = parts[2]; 287 288 // Get the DID document to find PDS endpoint 289 let document = self.ensure_identity_stored(repo).await?; 290 let pds_endpoints = document.pds_endpoints(); 291 let pds_endpoint = 292 pds_endpoints 293 .first() 294 .ok_or_else(|| ShowcaseError::ProcessBadgeFetchFailed { 295 uri: format!("No PDS endpoint found for DID: {}", repo), 296 })?; 297 298 let badge_record = self 299 .fetch_badge_record(pds_endpoint, repo, rkey, &badge_ref.cid) 300 .await?; 301 let badge = Badge { 302 aturi: badge_ref.uri.clone(), 303 cid: badge_ref.cid.clone(), 304 name: badge_record.name.clone(), 305 image: badge_record.image.as_ref().and_then(|img| img.get_ref()), 306 created_at: Utc::now(), 307 updated_at: Utc::now(), 308 count: 0, 309 record: serde_json::to_value(&badge_record)?, 310 }; 311 312 self.storage.upsert_badge(&badge).await?; 313 Ok(badge_record) 314 } 315 316 async fn fetch_badge_record( 317 &self, 318 pds: &str, 319 did: &str, 320 rkey: &str, 321 cid: &str, 322 ) -> Result<BadgeRecord> { 323 let get_record_response = get_record( 324 &self.http_client, 325 None, 326 pds, 327 did, 328 "community.lexicon.badge.definition", 329 rkey, 330 Some(cid), 331 ) 332 .await?; 333 334 match get_record_response { 335 atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => { 336 serde_json::from_value(value.clone()).map_err(|e| { 337 ShowcaseError::ProcessBadgeRecordFetchFailed { 338 uri: format!("at://{}/community.lexicon.badge.definition/{}", did, rkey), 339 details: format!("Failed to deserialize record: {}", e), 340 } 341 }) 342 } 343 atproto_client::com::atproto::repo::GetRecordResponse::Error(simple_error) => { 344 Err(ShowcaseError::ProcessBadgeRecordFetchFailed { 345 uri: format!("at://{}/community.lexicon.badge.definition/{}", did, rkey), 346 details: format!("Get record returned an error: {}", simple_error.error_message()), 347 }) 348 } 349 } 350 } 351 352 async fn download_badge_image(&self, pds: &str, did: &str, badge: &BadgeRecord) -> Result<()> { 353 if let Some(ref image_blob) = badge.image { 354 let image_ref = match image_blob.get_ref() { 355 Some(ref_val) => ref_val, 356 None => return Ok(()), // No image reference 357 }; 358 359 let image_path = format!("{}.png", image_ref); 360 361 // Check if image already exists 362 if self.file_storage.file_exists(&image_path).await? { 363 return Ok(()); 364 } 365 366 // Download and process image 367 match self 368 .download_and_process_image(pds, did, &image_ref, &image_path) 369 .await 370 { 371 Ok(()) => { 372 info!("Downloaded badge image: {}", image_ref); 373 } 374 Err(e) => { 375 warn!( 376 "error-showcase-process-11 Failed to download badge image {}: {}", 377 image_ref, e 378 ); 379 } 380 } 381 } 382 383 Ok(()) 384 } 385 386 async fn download_and_process_image( 387 &self, 388 pds: &str, 389 did: &str, 390 blob_ref: &str, 391 output_path: &str, 392 ) -> Result<()> { 393 // Fetch the blob from PDS 394 let image_bytes = get_blob(&self.http_client, pds, did, blob_ref).await?; 395 396 // 1. Check size limit (3MB = 3 * 1024 * 1024 bytes) 397 const MAX_SIZE: usize = 3 * 1024 * 1024; 398 if image_bytes.len() > MAX_SIZE { 399 return Err(ShowcaseError::ProcessImageTooLarge { 400 size: image_bytes.len(), 401 }); 402 } 403 404 // 2. Try to load and detect image format 405 let img = image::load_from_memory(&image_bytes).map_err(|e| { 406 ShowcaseError::ProcessImageDecodeFailed { 407 details: e.to_string(), 408 } 409 })?; 410 411 // Check if format is supported (JPG, PNG, WebP) 412 let format = image::guess_format(&image_bytes).map_err(|e| { 413 ShowcaseError::ProcessImageDecodeFailed { 414 details: format!("Could not detect image format: {}", e), 415 } 416 })?; 417 418 match format { 419 ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::WebP => { 420 // Supported formats 421 } 422 _ => { 423 return Err(ShowcaseError::ProcessUnsupportedImageFormat { 424 format: format!("{:?}", format), 425 }); 426 } 427 } 428 429 // 3. Check original dimensions (minimum 512x512) 430 let (original_width, original_height) = img.dimensions(); 431 if original_width < 512 || original_height < 512 { 432 return Err(ShowcaseError::ProcessImageTooSmall { 433 width: original_width, 434 height: original_height, 435 }); 436 } 437 438 // 4. Resize to height 512 while preserving aspect ratio 439 let aspect_ratio = original_width as f32 / original_height as f32; 440 let new_height = 512; 441 let new_width = (new_height as f32 * aspect_ratio) as u32; 442 443 // 5. Check that width after resize is >= 512 444 if new_width < 512 { 445 return Err(ShowcaseError::ProcessImageWidthTooSmall { width: new_width }); 446 } 447 448 // Perform the resize 449 let mut resized_img = 450 img.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3); 451 452 // 6. Center crop the width to exactly 512 pixels if needed 453 let final_img = if new_width > 512 { 454 let crop_x = (new_width - 512) / 2; 455 resized_img.crop(crop_x, 0, 512, 512) 456 } else { 457 resized_img 458 }; 459 460 // Save as PNG to byte buffer 461 let mut png_buffer = std::io::Cursor::new(Vec::new()); 462 final_img.write_to(&mut png_buffer, ImageFormat::Png)?; 463 let png_bytes = png_buffer.into_inner(); 464 465 // Write the processed image using file storage 466 self.file_storage 467 .write_file(output_path, &png_bytes) 468 .await?; 469 470 info!( 471 "Processed badge image {}: {}x{} -> 512x512", 472 blob_ref, original_width, original_height 473 ); 474 Ok(()) 475 } 476 477 async fn validate_signatures( 478 &self, 479 record: &serde_json::Value, 480 repository: &str, 481 collection: &str, 482 ) -> Result<Vec<String>> { 483 let mut validated_issuers = Vec::new(); 484 485 // Extract signatures from the record 486 let signatures = record 487 .get("signatures") 488 .and_then(|v| v.as_array()) 489 .ok_or(ShowcaseError::ProcessNoSignaturesField)?; 490 491 for sig_obj in signatures { 492 // Extract the issuer from the signature object 493 let signature_issuer = sig_obj 494 .get("issuer") 495 .and_then(|v| v.as_str()) 496 .ok_or(ShowcaseError::ProcessMissingIssuerField)?; 497 498 // Retrieve the DID document for the issuer 499 let did_document = match self 500 .document_storage 501 .get_document_by_did(signature_issuer) 502 .await 503 { 504 Ok(Some(doc)) => doc, 505 Ok(None) => { 506 warn!( 507 "error-showcase-process-16 Failed to retrieve DID document for issuer {}: not found", 508 signature_issuer 509 ); 510 continue; 511 } 512 Err(e) => { 513 warn!( 514 "error-showcase-process-16 Failed to retrieve DID document for issuer {}: {}", 515 signature_issuer, e 516 ); 517 continue; 518 } 519 }; 520 521 // Iterate over all keys available in the DID document 522 for key_string in did_document.did_keys() { 523 // Decode the key using identify_key 524 let key_data = match identify_key(key_string) { 525 Ok(key_data) => key_data, 526 Err(e) => { 527 warn!( 528 "error-showcase-process-17 Failed to decode key {} for issuer {}: {}", 529 key_string, signature_issuer, e 530 ); 531 continue; 532 } 533 }; 534 535 // Attempt to verify the signature with this key 536 match self 537 .verify_signature( 538 signature_issuer, 539 &key_data, 540 record, 541 repository, 542 collection, 543 sig_obj, 544 ) 545 .await 546 { 547 Ok(()) => { 548 validated_issuers.push(signature_issuer.to_string()); 549 info!( 550 "Validated signature from trusted issuer {} using key: {}", 551 signature_issuer, key_string 552 ); 553 break; // Stop trying other keys once we find one that works 554 } 555 Err(_) => { 556 // Continue trying other keys - don't warn on each failure as this is expected 557 continue; 558 } 559 } 560 } 561 } 562 563 Ok(validated_issuers) 564 } 565 566 async fn verify_signature( 567 &self, 568 issuer: &str, 569 key_data: &KeyData, 570 record: &serde_json::Value, 571 repository: &str, 572 collection: &str, 573 sig_obj: &serde_json::Value, 574 ) -> Result<()> { 575 // Reconstruct the $sig object as per the reference implementation 576 let mut sig_variable = sig_obj.clone(); 577 578 if let Some(sig_map) = sig_variable.as_object_mut() { 579 sig_map.remove("signature"); 580 sig_map.insert("repository".to_string(), json!(repository)); 581 sig_map.insert("collection".to_string(), json!(collection)); 582 } 583 584 // Create the signed record for verification 585 let mut signed_record = record.clone(); 586 if let Some(record_map) = signed_record.as_object_mut() { 587 record_map.remove("signatures"); 588 record_map.insert("$sig".to_string(), sig_variable); 589 } 590 591 // Serialize the record using IPLD DAG-CBOR 592 let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record).map_err(|e| { 593 ShowcaseError::ProcessRecordSerializationFailed { 594 details: e.to_string(), 595 } 596 })?; 597 598 // Get the signature value and decode it 599 let signature_value = sig_obj 600 .get("signature") 601 .and_then(|v| v.as_str()) 602 .ok_or(ShowcaseError::ProcessMissingSignatureField)?; 603 604 let (_, signature_bytes) = multibase::decode(signature_value).map_err(|e| { 605 ShowcaseError::ProcessSignatureDecodingFailed { 606 details: e.to_string(), 607 } 608 })?; 609 610 // Validate the signature 611 validate(key_data, &signature_bytes, &serialized_record).map_err(|e| { 612 ShowcaseError::ProcessCryptographicValidationFailed { 613 issuer: issuer.to_string(), 614 details: e.to_string(), 615 } 616 })?; 617 618 Ok(()) 619 } 620} 621 622#[cfg(test)] 623mod tests { 624 use super::*; 625 #[cfg(feature = "sqlite")] 626 use crate::storage::{LocalFileStorage, SqliteStorage, SqliteStorageDidDocumentStorage}; 627 use atproto_identity::model::Document; 628 use serde_json::json; 629 use std::collections::HashMap; 630 631 #[cfg(feature = "sqlite")] 632 #[tokio::test] 633 async fn test_validate_signatures_no_signatures_field() { 634 let config = Arc::new(Config::default()); 635 let storage = Arc::new(SqliteStorage::new( 636 sqlx::SqlitePool::connect(":memory:").await.unwrap(), 637 )); 638 let identity_resolver = create_mock_identity_resolver(); 639 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone())); 640 let http_client = reqwest::Client::new(); 641 642 let processor = BadgeProcessor::new( 643 storage as Arc<dyn Storage>, 644 config, 645 identity_resolver, 646 document_storage as Arc<dyn DidDocumentStorage + Send + Sync>, 647 http_client, 648 create_test_file_storage(), 649 ); 650 651 // Record without signatures field 652 let record = json!({ 653 "did": "did:plc:test", 654 "badge": { 655 "$type": "strongRef", 656 "uri": "at://did:plc:issuer/community.lexicon.badge.definition/test", 657 "cid": "bafyreiabc123" 658 }, 659 "issued": "2023-01-01T00:00:00Z" 660 }); 661 662 let result = processor 663 .validate_signatures(&record, "did:plc:test", "community.lexicon.badge.award") 664 .await; 665 666 // Should return error because no signatures field 667 assert!(result.is_err()); 668 if let Err(ShowcaseError::ProcessNoSignaturesField) = result { 669 // Expected error 670 } else { 671 panic!("Expected ProcessNoSignaturesField error"); 672 } 673 } 674 675 #[cfg(feature = "sqlite")] 676 #[tokio::test] 677 async fn test_validate_signatures_untrusted_issuer() { 678 let mut config = Config::default(); 679 config.badge_issuers = vec!["did:plc:trusted".to_string()]; 680 let config = Arc::new(config); 681 682 let storage = Arc::new(SqliteStorage::new( 683 sqlx::SqlitePool::connect(":memory:").await.unwrap(), 684 )); 685 let identity_resolver = create_mock_identity_resolver(); 686 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone())); 687 let http_client = reqwest::Client::new(); 688 689 let processor = BadgeProcessor::new( 690 storage as Arc<dyn Storage>, 691 config, 692 identity_resolver, 693 document_storage as Arc<dyn DidDocumentStorage + Send + Sync>, 694 http_client, 695 create_test_file_storage(), 696 ); 697 698 // Record with signature from untrusted issuer 699 let record = json!({ 700 "did": "did:plc:test", 701 "badge": { 702 "$type": "strongRef", 703 "uri": "at://did:plc:issuer/community.lexicon.badge.definition/test", 704 "cid": "bafyreiabc123" 705 }, 706 "issued": "2023-01-01T00:00:00Z", 707 "signatures": [{ 708 "issuer": "did:plc:untrusted", 709 "issuedAt": "2023-01-01T00:00:00Z", 710 "signature": "mEiDqZ4..." 711 }] 712 }); 713 714 let result = processor 715 .validate_signatures(&record, "did:plc:test", "community.lexicon.badge.award") 716 .await; 717 718 // Should succeed but return empty list (untrusted issuer ignored) 719 assert!(result.is_ok()); 720 assert_eq!(result.unwrap().len(), 0); 721 } 722 723 #[cfg(feature = "sqlite")] 724 #[tokio::test] 725 async fn test_validate_signatures_no_did_keys() { 726 let mut config = Config::default(); 727 config.badge_issuers = vec!["did:plc:trusted".to_string()]; 728 let config = Arc::new(config); 729 730 let storage = Arc::new(SqliteStorage::new( 731 sqlx::SqlitePool::connect(":memory:").await.unwrap(), 732 )); 733 storage.migrate().await.unwrap(); // Initialize database tables 734 735 let identity_resolver = create_mock_identity_resolver(); 736 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone())); 737 let http_client = reqwest::Client::new(); 738 739 // Create a mock DID document with no keys (using a Document that has an empty did_keys() result) 740 let document = Document { 741 id: "did:plc:trusted".to_string(), 742 also_known_as: vec![], 743 service: vec![], 744 verification_method: vec![], // Empty verification methods means no keys 745 extra: HashMap::new(), 746 }; 747 748 // Store the document in our storage 749 document_storage.store_document(document).await.unwrap(); 750 751 let processor = BadgeProcessor::new( 752 storage as Arc<dyn Storage>, 753 config, 754 identity_resolver, 755 document_storage as Arc<dyn DidDocumentStorage + Send + Sync>, 756 http_client, 757 create_test_file_storage(), 758 ); 759 760 // Record with signature from trusted issuer but no valid keys in DID document 761 let record = json!({ 762 "did": "did:plc:test", 763 "badge": { 764 "$type": "strongRef", 765 "uri": "at://did:plc:issuer/community.lexicon.badge.definition/test", 766 "cid": "bafyreiabc123" 767 }, 768 "issued": "2023-01-01T00:00:00Z", 769 "signatures": [{ 770 "issuer": "did:plc:trusted", 771 "issuedAt": "2023-01-01T00:00:00Z", 772 "signature": "mEiDqZ4..." 773 }] 774 }); 775 776 let result = processor 777 .validate_signatures(&record, "did:plc:test", "community.lexicon.badge.award") 778 .await; 779 780 // Should succeed but return empty list (no valid keys to verify with) 781 assert!(result.is_ok()); 782 assert_eq!(result.unwrap().len(), 0); 783 } 784 785 #[cfg(feature = "sqlite")] 786 #[tokio::test] 787 async fn test_image_validation_too_large() { 788 let config = Arc::new(Config::default()); 789 let storage = Arc::new(SqliteStorage::new( 790 sqlx::SqlitePool::connect(":memory:").await.unwrap(), 791 )); 792 let identity_resolver = create_mock_identity_resolver(); 793 let document_storage = Arc::new(SqliteStorageDidDocumentStorage::new(storage.clone())); 794 let http_client = reqwest::Client::new(); 795 796 let _processor = BadgeProcessor::new( 797 storage, 798 config, 799 identity_resolver, 800 document_storage, 801 http_client, 802 create_test_file_storage(), 803 ); 804 805 // Create test image bytes larger than 3MB 806 let large_image_bytes = vec![0u8; 4 * 1024 * 1024]; // 4MB 807 808 // We can't easily test this without mocking get_blob, but we can test the size check logic directly 809 assert!(large_image_bytes.len() > 3 * 1024 * 1024); 810 } 811 812 #[tokio::test] 813 async fn test_image_validation_unsupported_format() { 814 // This would require creating actual image bytes of unsupported format 815 // For now, we can verify the error types exist and compile correctly 816 let error = ShowcaseError::ProcessUnsupportedImageFormat { 817 format: "BMP".to_string(), 818 }; 819 assert!(error.to_string().contains("Unsupported image format")); 820 } 821 822 #[tokio::test] 823 async fn test_image_validation_too_small() { 824 let error = ShowcaseError::ProcessImageTooSmall { 825 width: 256, 826 height: 256, 827 }; 828 assert!(error.to_string().contains("256x256")); 829 assert!(error.to_string().contains("minimum is 512x512")); 830 } 831 832 #[tokio::test] 833 async fn test_image_validation_width_too_small_after_resize() { 834 let error = ShowcaseError::ProcessImageWidthTooSmall { width: 300 }; 835 assert!(error.to_string().contains("300")); 836 assert!(error.to_string().contains("minimum is 512")); 837 } 838 839 #[tokio::test] 840 async fn test_center_crop_logic() { 841 // Test the center crop calculation logic 842 // If we have an image that's 1024x512 after resize, it should be center cropped to 512x512 843 let original_width = 1024u32; 844 let target_width = 512u32; 845 846 // This is the same calculation used in download_and_process_image 847 let crop_x = (original_width - target_width) / 2; 848 849 // Should crop from x=256 to get the center 512 pixels 850 assert_eq!(crop_x, 256); 851 852 // Test with different widths 853 let wide_width = 768u32; 854 let crop_x_wide = (wide_width - target_width) / 2; 855 assert_eq!(crop_x_wide, 128); // Should crop 128 pixels from each side 856 } 857 858 fn create_mock_identity_resolver() -> IdentityResolver { 859 // Create a mock resolver - in a real test, you'd want to properly mock this 860 // For now, we'll create one with default DNS resolver and HTTP client 861 use atproto_identity::resolve::{InnerIdentityResolver, create_resolver}; 862 863 let dns_resolver = create_resolver(&[]); 864 let http_client = reqwest::Client::new(); 865 866 IdentityResolver(Arc::new(InnerIdentityResolver { 867 dns_resolver, 868 http_client, 869 plc_hostname: "plc.directory".to_string(), 870 })) 871 } 872 873 #[cfg(feature = "sqlite")] 874 fn create_test_file_storage() -> Arc<dyn FileStorage> { 875 // Create a temporary directory for test file storage 876 // Note: In a real test, you'd want to ensure this gets cleaned up properly 877 // For simplicity, we'll use a simple path that gets cleaned up by the OS 878 let temp_dir = "/tmp/showcase_test_storage"; 879 Arc::new(LocalFileStorage::new(temp_dir.to_string())) 880 } 881}