lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

blossom_conformance.rs (22520B)


      1 #![cfg(feature = "blossom")]
      2 
      3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
      4 use nostr::Keys as RadrootsNostrKeys;
      5 use radroots_blossom::{
      6     Sha256,
      7     authorization::{
      8         AuthoredUploadClaim, AuthorizationClaim, AuthorizationContent, AuthorizationTarget,
      9         AuthorizationValidation, RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS,
     10         RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND,
     11         ServerDomain, ServerScopeRequirement,
     12     },
     13 };
     14 use radroots_event::{SignedEvent, wire::Nip01EventWire};
     15 use radroots_nostr::blossom::{
     16     AuthorizationError, SignedAuthorization, decode_verify_authorization_header,
     17     encode_authorization_header, encode_signed_event_authorization_header, sign_authorization,
     18 };
     19 use radroots_nostr::{
     20     event::{
     21         Event as RadrootsNostrEvent, Kind as RadrootsNostrKind, Timestamp as RadrootsNostrTimestamp,
     22     },
     23     tag::Tag as RadrootsNostrTag,
     24 };
     25 use serde::Deserialize;
     26 use serde_json::Value;
     27 use std::{borrow::Cow, fs, path::Path};
     28 
     29 const PACKAGED_VECTORS: &str = include_str!("fixtures/bud11_nostr_adapter.v1.json");
     30 const WORKSPACE_VECTOR_PATH: &str =
     31     "../../contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json";
     32 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
     33 
     34 const SECRET_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
     35 const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
     36 const SERVER: &str = "cdn.example.com";
     37 const CONTENT: &str = "Upload Victoria farm photo";
     38 const CREATED_AT: u64 = 1_700_000_000;
     39 const LIFETIME: u64 = RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS;
     40 
     41 #[derive(Debug, Deserialize)]
     42 struct Suite {
     43     suite: String,
     44     contract_version: String,
     45     vectors: Vec<Vector>,
     46 }
     47 
     48 #[derive(Debug, Deserialize)]
     49 struct Vector {
     50     id: String,
     51     kind: String,
     52     input: Value,
     53     expected: Value,
     54 }
     55 
     56 #[test]
     57 fn checked_in_blossom_bud11_nostr_vectors_execute_against_public_api() {
     58     let vectors = conformance_vectors();
     59     let suite: Suite = serde_json::from_str(&vectors).expect("BUD-11 Nostr vectors must parse");
     60     assert_eq!(suite.suite, "blossom_bud11_nostr_adapter");
     61     assert_eq!(suite.contract_version, "1.0.0");
     62     assert!(!suite.vectors.is_empty());
     63 
     64     for vector in &suite.vectors {
     65         execute_vector(vector);
     66     }
     67 }
     68 
     69 fn conformance_vectors() -> Cow<'static, str> {
     70     let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH);
     71     match fs::read_to_string(&workspace_path) {
     72         Ok(canonical) => {
     73             assert_eq!(
     74                 canonical,
     75                 PACKAGED_VECTORS,
     76                 "packaged BUD-11 Nostr vectors must match {}",
     77                 workspace_path.display()
     78             );
     79             Cow::Owned(canonical)
     80         }
     81         Err(error)
     82             if error.kind() == std::io::ErrorKind::NotFound
     83                 && !Path::new(env!("CARGO_MANIFEST_DIR"))
     84                     .join(WORKSPACE_CONTRACT_MARKER_PATH)
     85                     .is_file() =>
     86         {
     87             Cow::Borrowed(PACKAGED_VECTORS)
     88         }
     89         Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
     90     }
     91 }
     92 
     93 fn execute_vector(vector: &Vector) {
     94     assert_embedded_event_matches_header(vector);
     95     let validation = vector_validation(vector);
     96     match vector.kind.as_str() {
     97         "blossom.bud11.nostr.decode_verify.valid" => {
     98             let verified =
     99                 decode_verify_authorization_header(input_str(vector, "header"), &validation)
    100                     .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
    101             assert_eq!(
    102                 verified.event_id().to_string(),
    103                 expected_str(vector, "event_id"),
    104                 "{}",
    105                 vector.id
    106             );
    107             assert_eq!(
    108                 verified.author().to_string(),
    109                 expected_str(vector, "pubkey"),
    110                 "{}",
    111                 vector.id
    112             );
    113             assert_eq!(
    114                 verified.claim().action().as_str(),
    115                 expected_str(vector, "action"),
    116                 "{}",
    117                 vector.id
    118             );
    119             assert_eq!(
    120                 verified.claim().content().as_str(),
    121                 expected_str(vector, "content"),
    122                 "{}",
    123                 vector.id
    124             );
    125             assert_eq!(
    126                 verified.claim().created_at(),
    127                 expected_u64(vector, "created_at"),
    128                 "{}",
    129                 vector.id
    130             );
    131             assert_eq!(
    132                 verified.claim().expiration(),
    133                 expected_u64(vector, "expiration"),
    134                 "{}",
    135                 vector.id
    136             );
    137             assert_eq!(
    138                 verified
    139                     .claim()
    140                     .hashes()
    141                     .iter()
    142                     .map(ToString::to_string)
    143                     .collect::<Vec<_>>(),
    144                 expected_strings(vector, "hashes"),
    145                 "{}",
    146                 vector.id
    147             );
    148             assert_eq!(
    149                 verified
    150                     .claim()
    151                     .server_domains()
    152                     .iter()
    153                     .map(ToString::to_string)
    154                     .collect::<Vec<_>>(),
    155                 expected_strings(vector, "servers"),
    156                 "{}",
    157                 vector.id
    158             );
    159         }
    160         "blossom.bud11.nostr.decode_verify.invalid" => {
    161             let error =
    162                 decode_verify_authorization_header(input_str(vector, "header"), &validation)
    163                     .expect_err("invalid BUD-11 Nostr vector must fail");
    164             assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id);
    165             if let Some(actual_kind) = vector.expected.get("actual_kind") {
    166                 assert_eq!(
    167                     error,
    168                     AuthorizationError::InvalidEventKind {
    169                         actual: actual_kind.as_u64().expect("actual_kind must be u64"),
    170                     },
    171                     "{}",
    172                     vector.id
    173                 );
    174             }
    175         }
    176         kind => panic!("{} uses unsupported vector kind {kind}", vector.id),
    177     }
    178 }
    179 
    180 fn assert_embedded_event_matches_header(vector: &Vector) {
    181     let Some(event_json) = vector.input.get("event_json").and_then(Value::as_str) else {
    182         return;
    183     };
    184     let payload = header_payload(input_str(vector, "header"));
    185     let decoded = URL_SAFE_NO_PAD
    186         .decode(payload)
    187         .unwrap_or_else(|error| panic!("{} header failed Base64url decode: {error}", vector.id));
    188     assert_eq!(decoded, event_json.as_bytes(), "{}", vector.id);
    189 }
    190 
    191 fn header_payload(header: &str) -> &str {
    192     let first_space = header
    193         .as_bytes()
    194         .iter()
    195         .position(|byte| *byte == b' ')
    196         .expect("authorization header requires SP");
    197     assert!(
    198         header.as_bytes()[..first_space].eq_ignore_ascii_case(b"Nostr"),
    199         "authorization header requires Nostr scheme"
    200     );
    201     let payload_start = header.as_bytes()[first_space..]
    202         .iter()
    203         .position(|byte| *byte != b' ')
    204         .map_or(header.len(), |offset| first_space + offset);
    205     &header[payload_start..]
    206 }
    207 
    208 fn vector_validation(vector: &Vector) -> AuthorizationValidation {
    209     let validation = vector.input.get("validation").expect("validation input");
    210     let target = validation.get("target").expect("validation target");
    211     let target_type = target
    212         .get("type")
    213         .and_then(Value::as_str)
    214         .expect("validation target type");
    215     let target_hash = || {
    216         Sha256::from_hex(
    217             target
    218                 .get("hash")
    219                 .and_then(Value::as_str)
    220                 .expect("validation target hash"),
    221         )
    222         .expect("valid validation target hash")
    223     };
    224     let target = match target_type {
    225         "get_blob" => AuthorizationTarget::GetBlob(target_hash()),
    226         "upload" => AuthorizationTarget::Upload(target_hash()),
    227         "list" => AuthorizationTarget::List,
    228         "delete_blob" => AuthorizationTarget::DeleteBlob(target_hash()),
    229         "mirror" => AuthorizationTarget::Mirror(target_hash()),
    230         "media" => AuthorizationTarget::Media(target_hash()),
    231         other => panic!("{} has unsupported target {other}", vector.id),
    232     };
    233     let server = ServerDomain::parse(
    234         validation
    235             .get("server_domain")
    236             .and_then(Value::as_str)
    237             .expect("validation server_domain"),
    238     )
    239     .expect("valid validation server_domain");
    240     let server_scope = match validation
    241         .get("server_scope")
    242         .and_then(Value::as_str)
    243         .expect("validation server_scope")
    244     {
    245         "optional_any_match" => ServerScopeRequirement::OptionalAnyMatch,
    246         "required_any_match" => ServerScopeRequirement::RequiredAnyMatch,
    247         other => panic!("{} has unsupported server scope {other}", vector.id),
    248     };
    249     AuthorizationValidation::new(
    250         target,
    251         server,
    252         server_scope,
    253         validation
    254             .get("now")
    255             .and_then(Value::as_u64)
    256             .expect("validation now"),
    257         validation
    258             .get("max_created_age")
    259             .and_then(Value::as_u64)
    260             .expect("validation max_created_age"),
    261     )
    262     .expect("valid vector validation")
    263 }
    264 
    265 fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str {
    266     vector
    267         .input
    268         .get(field)
    269         .and_then(Value::as_str)
    270         .unwrap_or_else(|| panic!("{} missing string input {field}", vector.id))
    271 }
    272 
    273 fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str {
    274     vector
    275         .expected
    276         .get(field)
    277         .and_then(Value::as_str)
    278         .unwrap_or_else(|| panic!("{} missing string expectation {field}", vector.id))
    279 }
    280 
    281 fn expected_u64(vector: &Vector, field: &str) -> u64 {
    282     vector
    283         .expected
    284         .get(field)
    285         .and_then(Value::as_u64)
    286         .unwrap_or_else(|| panic!("{} missing u64 expectation {field}", vector.id))
    287 }
    288 
    289 fn expected_strings(vector: &Vector, field: &str) -> Vec<String> {
    290     vector
    291         .expected
    292         .get(field)
    293         .and_then(Value::as_array)
    294         .unwrap_or_else(|| panic!("{} missing array expectation {field}", vector.id))
    295         .iter()
    296         .map(|value| {
    297             value
    298                 .as_str()
    299                 .unwrap_or_else(|| panic!("{} has non-string {field} value", vector.id))
    300                 .to_owned()
    301         })
    302         .collect()
    303 }
    304 
    305 fn keys() -> RadrootsNostrKeys {
    306     RadrootsNostrKeys::parse(SECRET_KEY).expect("fixed test secret key")
    307 }
    308 
    309 fn hash() -> Sha256 {
    310     Sha256::from_hex(HASH).expect("fixed test hash")
    311 }
    312 
    313 fn server() -> ServerDomain {
    314     ServerDomain::parse(SERVER).expect("fixed test server")
    315 }
    316 
    317 fn authored_claim() -> AuthoredUploadClaim {
    318     AuthoredUploadClaim::new(
    319         AuthorizationContent::parse(CONTENT).expect("fixed test content"),
    320         server(),
    321         hash(),
    322         CREATED_AT,
    323         LIFETIME,
    324     )
    325     .expect("fixed authored claim")
    326 }
    327 
    328 fn validation() -> AuthorizationValidation {
    329     AuthorizationValidation::new(
    330         AuthorizationTarget::Upload(hash()),
    331         server(),
    332         ServerScopeRequirement::RequiredAnyMatch,
    333         CREATED_AT + 1,
    334         RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS,
    335     )
    336     .expect("fixed validation policy")
    337 }
    338 
    339 fn raw_header(event: &RadrootsNostrEvent) -> String {
    340     let json = serde_json::to_vec(event).expect("test event JSON");
    341     raw_json_header(&String::from_utf8(json).expect("test event JSON is UTF-8"))
    342 }
    343 
    344 fn raw_json_header(json: &str) -> String {
    345     format!("Nostr {}", URL_SAFE_NO_PAD.encode(json))
    346 }
    347 
    348 fn event_from_header(header: &str) -> RadrootsNostrEvent {
    349     let json = URL_SAFE_NO_PAD
    350         .decode(header_payload(header))
    351         .expect("test authorization payload");
    352     serde_json::from_slice(&json).expect("test authorization event")
    353 }
    354 
    355 fn sign_raw(kind: u16, content: &str, tags: Vec<RadrootsNostrTag>) -> RadrootsNostrEvent {
    356     nostr::EventBuilder::new(RadrootsNostrKind::Custom(kind), content)
    357         .tags(tags)
    358         .custom_created_at(RadrootsNostrTimestamp::from_secs(CREATED_AT))
    359         .sign_with_keys(&keys())
    360         .expect("raw test event signs")
    361 }
    362 
    363 fn exact_signed_event(event: &RadrootsNostrEvent) -> SignedEvent {
    364     let raw_json = serde_json::to_string(event).expect("test event JSON");
    365     let wire = Nip01EventWire::parse_json(&raw_json).expect("test event wire");
    366     SignedEvent::from_wire_verified_id(wire, raw_json).expect("test event has a verified ID")
    367 }
    368 
    369 #[test]
    370 fn blossom_authored_claim_signs_and_roundtrips_without_signature_assumptions() {
    371     let claim = authored_claim();
    372     let signed = sign_authorization(&keys(), &claim).expect("sign authored authorization");
    373     let wire = claim.wire_parts();
    374 
    375     let header = encode_authorization_header(&signed);
    376     let event = event_from_header(header.as_str());
    377 
    378     assert_eq!(event.kind.as_u16(), wire.kind());
    379     assert_eq!(event.created_at.as_secs(), wire.created_at());
    380     assert_eq!(event.content, wire.content());
    381     assert_eq!(
    382         event
    383             .tags
    384             .iter()
    385             .map(|tag| tag.as_slice().to_vec())
    386             .collect::<Vec<_>>(),
    387         wire.tags()
    388     );
    389     assert!(event.verify_id());
    390     assert!(event.verify_signature());
    391     assert_eq!(signed.event_id(), event.id);
    392     assert_eq!(signed.author(), event.pubkey);
    393     assert_eq!(signed.created_at(), event.created_at);
    394 
    395     assert!(header.as_str().starts_with("Nostr "));
    396     assert!(!header.as_str().contains('='));
    397     assert_eq!(header.as_ref(), header.as_str());
    398     assert!(!format!("{header:?}").contains(header.as_str()));
    399     assert!(!format!("{signed:?}").contains(&event.sig.to_string()));
    400 
    401     let verified =
    402         decode_verify_authorization_header(header.as_str(), &validation()).expect("verify header");
    403     assert_eq!(verified.event_id(), event.id);
    404     assert_eq!(verified.author(), keys().public_key());
    405     assert_eq!(verified.created_at(), event.created_at);
    406     assert_eq!(verified.claim().content().to_string(), CONTENT);
    407     assert_eq!(verified.claim().created_at(), CREATED_AT);
    408     assert_eq!(verified.claim().action().to_string(), "upload");
    409     assert_eq!(verified.claim().expiration(), CREATED_AT + LIFETIME);
    410     assert_eq!(verified.claim().server_domains(), &[server()]);
    411     assert_eq!(verified.claim().hashes(), &[hash()]);
    412     assert!(!format!("{verified:?}").contains(&event.sig.to_string()));
    413 }
    414 
    415 #[test]
    416 fn opaque_signed_event_conversion_rechecks_kind_and_signature() {
    417     let signed = sign_authorization(&keys(), &authored_claim()).expect("sign authorization");
    418     let expected_header = encode_authorization_header(&signed);
    419     let event = event_from_header(expected_header.as_str());
    420     let exact_event = exact_signed_event(&event);
    421 
    422     let converted =
    423         SignedAuthorization::from_signed_event(&exact_event).expect("convert exact BUD-11 event");
    424     assert_eq!(converted.event_id(), event.id);
    425     assert_eq!(converted.author(), event.pubkey);
    426     assert_eq!(converted.created_at(), event.created_at);
    427     assert_eq!(
    428         encode_signed_event_authorization_header(&exact_event).unwrap(),
    429         expected_header
    430     );
    431 
    432     let wrong_kind = exact_signed_event(&sign_raw(1, "", Vec::new()));
    433     assert_eq!(
    434         SignedAuthorization::from_signed_event(&wrong_kind),
    435         Err(AuthorizationError::InvalidEventKind { actual: 1 })
    436     );
    437 
    438     let mut invalid_signature = event;
    439     invalid_signature.sig = sign_raw(
    440         RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND,
    441         "different",
    442         Vec::new(),
    443     )
    444     .sig;
    445     assert!(invalid_signature.verify_id());
    446     assert!(!invalid_signature.verify_signature());
    447     assert_eq!(
    448         SignedAuthorization::from_signed_event(&exact_signed_event(&invalid_signature)),
    449         Err(AuthorizationError::InvalidEventSignature)
    450     );
    451 }
    452 
    453 #[test]
    454 fn blossom_header_rejects_noncanonical_and_malformed_encodings() {
    455     let signed =
    456         sign_authorization(&keys(), &authored_claim()).expect("sign authored authorization");
    457     let valid = encode_authorization_header(&signed).into_string();
    458     let policy = validation();
    459 
    460     for accepted in [
    461         valid.replacen("Nostr ", "nostr ", 1),
    462         valid.replacen("Nostr ", "NOSTR   ", 1),
    463     ] {
    464         decode_verify_authorization_header(&accepted, &policy)
    465             .expect("auth-scheme is case-insensitive and accepts 1*SP");
    466     }
    467 
    468     let cases = [
    469         (
    470             format!(" {valid}"),
    471             AuthorizationError::InvalidHeaderWhitespace,
    472         ),
    473         (
    474             "Nostr e 30".to_owned(),
    475             AuthorizationError::InvalidHeaderWhitespace,
    476         ),
    477         ("Nostr".to_owned(), AuthorizationError::InvalidHeaderScheme),
    478         ("Nostr ".to_owned(), AuthorizationError::EmptyHeaderPayload),
    479         (
    480             format!("{valid}="),
    481             AuthorizationError::HeaderPaddingForbidden,
    482         ),
    483         (
    484             "Nostr +".to_owned(),
    485             AuthorizationError::InvalidHeaderBase64,
    486         ),
    487         (
    488             "Nostr Zh".to_owned(),
    489             AuthorizationError::NonCanonicalHeaderBase64,
    490         ),
    491         ("Nostr _w".to_owned(), AuthorizationError::InvalidHeaderUtf8),
    492         ("Nostr e30".to_owned(), AuthorizationError::InvalidEventJson),
    493     ];
    494 
    495     for (header, expected) in cases {
    496         assert_eq!(
    497             decode_verify_authorization_header(&header, &policy),
    498             Err(expected),
    499             "{header}"
    500         );
    501     }
    502 }
    503 
    504 #[test]
    505 fn blossom_header_rejects_kind_narrowing_and_json_shape_laundering() {
    506     let signed =
    507         sign_authorization(&keys(), &authored_claim()).expect("sign authored authorization");
    508     let encoded = encode_authorization_header(&signed);
    509     let event_json =
    510         serde_json::to_string(&event_from_header(encoded.as_str())).expect("event JSON");
    511     let policy = validation();
    512 
    513     let cases = [
    514         (
    515             event_json.replacen("\"kind\":24242", "\"kind\":89778", 1),
    516             AuthorizationError::InvalidEventKind { actual: 89_778 },
    517         ),
    518         (
    519             format!(
    520                 "{},\"unknown\":true}}",
    521                 event_json.strip_suffix('}').unwrap()
    522             ),
    523             AuthorizationError::InvalidEventJson,
    524         ),
    525         (
    526             event_json.replacen("\"content\":", "\"unknown\":", 1),
    527             AuthorizationError::InvalidEventJson,
    528         ),
    529         (
    530             event_json.replacen("\"kind\":24242", "\"kind\":24242,\"kind\":24242", 1),
    531             AuthorizationError::InvalidEventJson,
    532         ),
    533         (
    534             event_json.replacen("\"kind\":24242", "\"kind\":\"24242\"", 1),
    535             AuthorizationError::InvalidEventJson,
    536         ),
    537         ("[]".to_owned(), AuthorizationError::InvalidEventJson),
    538         ("{".to_owned(), AuthorizationError::InvalidEventJson),
    539     ];
    540 
    541     for (json, expected) in cases {
    542         assert_eq!(
    543             decode_verify_authorization_header(&raw_json_header(&json), &policy,),
    544             Err(expected),
    545             "{json}"
    546         );
    547     }
    548 }
    549 
    550 #[test]
    551 fn blossom_header_authenticates_before_claim_parsing() {
    552     let policy = validation();
    553 
    554     let wrong_kind = sign_raw(1, "", Vec::new());
    555     assert_eq!(
    556         decode_verify_authorization_header(&raw_header(&wrong_kind), &policy),
    557         Err(AuthorizationError::InvalidEventKind { actual: 1 })
    558     );
    559 
    560     let malformed_claim = sign_raw(RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, "", Vec::new());
    561     let mut bad_id = malformed_claim.clone();
    562     bad_id.content.push('x');
    563     assert_eq!(
    564         decode_verify_authorization_header(&raw_header(&bad_id), &policy),
    565         Err(AuthorizationError::InvalidEventId)
    566     );
    567 
    568     let mut bad_signature = malformed_claim.clone();
    569     bad_signature.sig = sign_raw(
    570         RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND,
    571         "different",
    572         Vec::new(),
    573     )
    574     .sig;
    575     assert!(bad_signature.verify_id());
    576     assert!(!bad_signature.verify_signature());
    577     assert_eq!(
    578         decode_verify_authorization_header(&raw_header(&bad_signature), &policy),
    579         Err(AuthorizationError::InvalidEventSignature)
    580     );
    581 
    582     let raw_tags: Vec<Vec<String>> = Vec::new();
    583     let pure_error = AuthorizationClaim::parse("", CREATED_AT, &raw_tags)
    584         .expect_err("empty content is not a claim");
    585     let adapter_error = decode_verify_authorization_header(&raw_header(&malformed_claim), &policy)
    586         .expect_err("authenticated malformed claim must fail");
    587     assert_eq!(adapter_error.code(), pure_error.code());
    588     assert_eq!(adapter_error.blossom_claim_error(), Some(&pure_error));
    589     assert_eq!(adapter_error.to_string(), pure_error.to_string());
    590 
    591     let signed =
    592         sign_authorization(&keys(), &authored_claim()).expect("sign authored authorization");
    593     let media_policy = AuthorizationValidation::new(
    594         AuthorizationTarget::Media(hash()),
    595         server(),
    596         ServerScopeRequirement::RequiredAnyMatch,
    597         CREATED_AT + 1,
    598         RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS,
    599     )
    600     .expect("media validation policy");
    601     let validation_error = decode_verify_authorization_header(
    602         encode_authorization_header(&signed).as_str(),
    603         &media_policy,
    604     )
    605     .expect_err("upload claim must not authorize media endpoint");
    606     assert_eq!(validation_error.code(), "authorization_action_mismatch");
    607 }
    608 
    609 #[test]
    610 fn blossom_adapter_error_codes_are_stable_and_distinct() {
    611     let errors = [
    612         AuthorizationError::InvalidHeaderWhitespace,
    613         AuthorizationError::InvalidHeaderScheme,
    614         AuthorizationError::EmptyHeaderPayload,
    615         AuthorizationError::HeaderPaddingForbidden,
    616         AuthorizationError::InvalidHeaderBase64,
    617         AuthorizationError::NonCanonicalHeaderBase64,
    618         AuthorizationError::InvalidHeaderUtf8,
    619         AuthorizationError::InvalidEventJson,
    620         AuthorizationError::InvalidEventKind { actual: 1 },
    621         AuthorizationError::InvalidEventId,
    622         AuthorizationError::InvalidEventSignature,
    623         AuthorizationError::EventSigning,
    624     ];
    625     let mut codes = errors
    626         .iter()
    627         .map(AuthorizationError::code)
    628         .collect::<Vec<_>>();
    629     let before = codes.len();
    630     codes.sort_unstable();
    631     codes.dedup();
    632     assert_eq!(codes.len(), before);
    633     assert!(errors.iter().all(|error| !error.to_string().is_empty()));
    634     assert_eq!(errors[0].blossom_claim_error(), None);
    635 }