focused_availability_admission.rs (14133B)
1 use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; 2 use radroots_event::envelope::EventEnvelope; 3 use radroots_event::listing::classified::{ 4 ClassifiedListingPartition, classify_classified_listing_tags, 5 }; 6 use radroots_event::wire::Nip01EventWire; 7 use radroots_event_codec::admission::food_availability::{ 8 RadrootsAdmittedFoodAvailabilityEvent, RadrootsFoodAvailabilityAdmissionError, 9 RadrootsFoodAvailabilityAdmissionOutcome, verify_and_admit_food_availability_event, 10 }; 11 use radroots_event_codec::decode::food_availability::{ 12 RadrootsFoodAvailabilityImageDiagnostic, RadrootsFoodAvailabilityProjectionError, 13 }; 14 use radroots_event_codec::verify::RadrootsNip01VerificationError; 15 16 #[path = "fixtures/availability_admission_v1.rs"] 17 mod corpus; 18 19 const MAX_RECIPE_BYTES: usize = 4 * 1024; 20 const MAX_RECIPE_TAGS: usize = 16; 21 const MAX_SIGNED_EVENT_BYTES: usize = 4 * 1024; 22 const MAX_RETAINED_CORPUS_BYTES: usize = 16 * 1024; 23 const RETAINED_CORPUS: &str = include_str!("fixtures/availability_admission_v1.rs"); 24 25 struct SignedPublicEvent { 26 json: String, 27 author: String, 28 } 29 30 fn sign_public_recipe(recipe: corpus::Recipe) -> SignedPublicEvent { 31 assert!(recipe.tags.len() <= MAX_RECIPE_TAGS); 32 let recipe_bytes = 33 recipe.content.len() + recipe.tags.iter().flatten().map(String::len).sum::<usize>(); 34 assert!(recipe_bytes <= MAX_RECIPE_BYTES); 35 let keys = Keys::generate(); 36 let author = keys.public_key().to_hex(); 37 let tags = recipe 38 .tags 39 .into_iter() 40 .map(|tag| Tag::parse(tag).expect("bounded raw incoming tag")) 41 .collect::<Vec<_>>(); 42 let event = EventBuilder::new(Kind::from(30402_u16), recipe.content) 43 .tags(tags) 44 .custom_created_at(Timestamp::from(corpus::CREATED_AT)) 45 .sign_with_keys(&keys) 46 .expect("isolated incoming fixture signing"); 47 drop(keys); 48 let json = event.as_json(); 49 assert!(json.len() <= MAX_SIGNED_EVENT_BYTES); 50 SignedPublicEvent { json, author } 51 } 52 53 fn untrusted_envelope(json: &str) -> EventEnvelope { 54 assert!(json.len() <= MAX_SIGNED_EVENT_BYTES); 55 let wire = Nip01EventWire::parse_json_unverified(json) 56 .expect("bounded structurally valid public NIP-01 input"); 57 assert!(wire.extra.is_empty()); 58 wire.into_unverified_envelope() 59 .expect("untrusted public envelope") 60 } 61 62 fn admit_public_json( 63 json: &str, 64 ) -> Result<RadrootsFoodAvailabilityAdmissionOutcome, RadrootsFoodAvailabilityAdmissionError> { 65 verify_and_admit_food_availability_event(untrusted_envelope(json)) 66 } 67 68 fn admitted(signed: &SignedPublicEvent) -> RadrootsAdmittedFoodAvailabilityEvent { 69 match admit_public_json(&signed.json).expect("shared tolerant admission") { 70 RadrootsFoodAvailabilityAdmissionOutcome::Admitted(event) => { 71 assert_eq!(event.event().author().to_hex(), signed.author); 72 assert_eq!(event.event().kind_u32(), 30402); 73 *event 74 } 75 _ => panic!("focused incoming recipe must be admitted"), 76 } 77 } 78 79 enum ExpectedAdmission { 80 Focused, 81 Excluded(ClassifiedListingPartition), 82 Error(RadrootsFoodAvailabilityProjectionError), 83 } 84 85 fn assert_partition( 86 recipe: corpus::Recipe, 87 partition: ClassifiedListingPartition, 88 expected: ExpectedAdmission, 89 ) { 90 let signed = sign_public_recipe(recipe); 91 let envelope = untrusted_envelope(&signed.json); 92 assert_eq!(classify_classified_listing_tags(envelope.tags()), partition); 93 let result = verify_and_admit_food_availability_event(envelope); 94 match expected { 95 ExpectedAdmission::Focused => match result.expect("focused admission") { 96 RadrootsFoodAvailabilityAdmissionOutcome::Admitted(event) => { 97 assert_eq!(event.event().author().to_hex(), signed.author); 98 assert_eq!(event.projection().identifier().as_str(), "hcav-incoming-v1"); 99 } 100 _ => panic!("focused partition must produce an admitted listing"), 101 }, 102 ExpectedAdmission::Excluded(expected_partition) => { 103 match result.expect("verified exclusion") { 104 RadrootsFoodAvailabilityAdmissionOutcome::Excluded(candidate) => { 105 assert_eq!(candidate.partition(), expected_partition); 106 assert_eq!(candidate.event().author().to_hex(), signed.author); 107 } 108 _ => panic!("non-focused partition must be excluded"), 109 } 110 } 111 ExpectedAdmission::Error(error) => { 112 assert_eq!( 113 result.expect_err("exact profile refusal"), 114 RadrootsFoodAvailabilityAdmissionError::Projection(error) 115 ); 116 } 117 } 118 } 119 120 #[test] 121 fn raw_marker_partitions_have_exact_admission_outcomes_before_field_validation() { 122 assert_eq!( 123 corpus::FOCUSED_MARKERS, 124 ["radroots:price_unit", "radroots:quantity"] 125 ); 126 assert_eq!( 127 corpus::OPERATIONAL_MARKERS, 128 ["radroots:primary_bin", "radroots:bin", "radroots:price"] 129 ); 130 let mut cases = 0; 131 assert_partition( 132 corpus::focused_recipe(), 133 ClassifiedListingPartition::FocusedFoodAvailability, 134 ExpectedAdmission::Focused, 135 ); 136 cases += 1; 137 for marker in corpus::FOCUSED_MARKERS { 138 assert_partition( 139 corpus::recipe_with_tags(&[&[marker]]), 140 ClassifiedListingPartition::FocusedFoodAvailability, 141 ExpectedAdmission::Error(RadrootsFoodAvailabilityProjectionError::TagInvalid), 142 ); 143 cases += 1; 144 } 145 for recipe in [ 146 corpus::recipe_with_tags(&[]), 147 corpus::edited_recipe(&[corpus::Edit::Remove("radroots:price_unit")]), 148 corpus::recipe_with_tags(&[ 149 &["RADROOTS:PRICE_UNIT", "lb"], 150 &["summary", "radroots:quantity"], 151 ]), 152 ] { 153 assert_partition( 154 recipe, 155 ClassifiedListingPartition::GenericNip99, 156 ExpectedAdmission::Excluded(ClassifiedListingPartition::GenericNip99), 157 ); 158 cases += 1; 159 } 160 for marker in corpus::OPERATIONAL_MARKERS { 161 // Raw marker presence selects exclusion before malformed shape or capability checks. 162 assert_partition( 163 corpus::recipe_with_tags(&[&[marker], &["delivery"]]), 164 ClassifiedListingPartition::OperationalListing, 165 ExpectedAdmission::Excluded(ClassifiedListingPartition::OperationalListing), 166 ); 167 cases += 1; 168 } 169 for focused in corpus::FOCUSED_MARKERS { 170 for operational in corpus::OPERATIONAL_MARKERS { 171 for markers in [[focused, operational], [operational, focused]] { 172 assert_partition( 173 corpus::recipe_with_tags(&[&[markers[0]], &[markers[1]], &["delivery"]]), 174 ClassifiedListingPartition::Ambiguous, 175 ExpectedAdmission::Error( 176 RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous, 177 ), 178 ); 179 cases += 1; 180 } 181 } 182 } 183 assert_eq!(cases, 21); 184 } 185 186 #[test] 187 fn tolerant_incoming_values_normalize_and_missing_quantity_stays_unknown() { 188 let signed = sign_public_recipe(corpus::focused_recipe()); 189 let event = admitted(&signed); 190 let projection = event.projection(); 191 assert_eq!(event.contract().id, "radroots.food.availability.v1"); 192 assert_eq!( 193 projection.content().as_str(), 194 "Public incoming availability conformance." 195 ); 196 assert_eq!(projection.identifier().as_str(), "hcav-incoming-v1"); 197 assert_eq!( 198 projection.title().as_str(), 199 "Incoming availability conformance" 200 ); 201 assert_eq!( 202 projection.summary().as_str(), 203 "Public incoming protocol fixture" 204 ); 205 assert_eq!(projection.location().as_str(), "Protocol test location"); 206 assert_eq!(projection.published_at().as_u64(), 1_800_000_000); 207 assert_eq!(projection.price().amount(), "3.5"); 208 assert_eq!(projection.price().currency().as_str(), "CAD"); 209 assert_eq!(projection.price().unit().as_str(), "lb"); 210 assert_eq!(projection.status().as_str(), "active"); 211 assert!(projection.quantity().is_none()); 212 assert!(projection.images().is_empty()); 213 assert!(projection.diagnostics().is_empty()); 214 // Admission preserves the authenticated input instead of rewriting normalized tags. 215 assert_eq!( 216 event.event().tags_as_vec(), 217 corpus::raw_tags(corpus::BASE_TAGS) 218 ); 219 220 let signed = sign_public_recipe(corpus::edited_recipe(&[ 221 corpus::Edit::Append(&["radroots:quantity", "0012.5000", "lb"]), 222 corpus::Edit::Append(&["image", "https://images.example.test/incoming.webp"]), 223 corpus::Edit::Append(&["t", "conformance"]), 224 ])); 225 let event = admitted(&signed); 226 let projection = event.projection(); 227 let quantity = projection.quantity().expect("known incoming quantity"); 228 assert_eq!(quantity.amount(), "12.5"); 229 assert_eq!(quantity.unit().as_str(), "lb"); 230 assert_eq!(projection.images().len(), 1); 231 let image = &projection.images()[0]; 232 assert_eq!( 233 image.raw_tag(), 234 ["image", "https://images.example.test/incoming.webp"] 235 ); 236 assert_eq!( 237 image.url(), 238 Some("https://images.example.test/incoming.webp") 239 ); 240 assert!(image.dimensions().is_none()); 241 assert!(!image.qualifies()); 242 let expected_diagnostics = [ 243 RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid, 244 RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing, 245 ]; 246 assert_eq!(image.diagnostics(), expected_diagnostics); 247 assert_eq!(projection.diagnostics(), expected_diagnostics); 248 } 249 250 #[test] 251 fn malformed_focused_fields_return_exact_shared_admission_errors() { 252 assert_eq!(corpus::MALFORMED_CASES.len(), 18); 253 let mut cases = 0; 254 for case in &corpus::MALFORMED_CASES { 255 let signed = sign_public_recipe(corpus::edited_recipe(case.edits)); 256 let error = admit_public_json(&signed.json).expect_err(case.name); 257 assert_eq!(error, case.expected, "{}", case.name); 258 assert_eq!(error.code(), case.code, "{}", case.name); 259 cases += 1; 260 } 261 assert_eq!(cases, 18); 262 } 263 264 fn replace_public_field(json: &str, field: &str, original: &str, replacement: &str) -> String { 265 let original_field = format!("\"{field}\":\"{original}\""); 266 assert_eq!(json.matches(&original_field).count(), 1); 267 json.replacen( 268 &original_field, 269 &format!("\"{field}\":\"{replacement}\""), 270 1, 271 ) 272 } 273 274 #[test] 275 fn invalid_id_and_signature_never_admit_a_listing_or_author_across_partitions() { 276 let neighbor = sign_public_recipe(corpus::focused_recipe()); 277 assert_eq!( 278 admitted(&neighbor).event().author().to_hex(), 279 neighbor.author 280 ); 281 let mut refused = 0; 282 for recipe in [ 283 corpus::focused_recipe(), 284 corpus::recipe_with_tags(&[]), 285 corpus::recipe_with_tags(&[&["radroots:bin"]]), 286 corpus::recipe_with_tags(&[&["radroots:quantity"], &["radroots:price"]]), 287 ] { 288 let signed = sign_public_recipe(recipe); 289 let wire = 290 Nip01EventWire::parse_json_unverified(&signed.json).expect("public fixture wire"); 291 let first = if wire.id.starts_with('0') { '1' } else { '0' }; 292 let wrong_id = format!("{first}{}", &wire.id[1..]); 293 let invalid_id_json = replace_public_field(&signed.json, "id", &wire.id, &wrong_id); 294 let invalid_signature_json = 295 replace_public_field(&signed.json, "sig", &wire.sig, &"0".repeat(128)); 296 let both_invalid_json = 297 replace_public_field(&invalid_signature_json, "id", &wire.id, &wrong_id); 298 for json in [&invalid_id_json, &both_invalid_json] { 299 let result = admit_public_json(json); 300 assert!( 301 result.is_err(), 302 "no admitted or excluded event can expose an author" 303 ); 304 assert_eq!( 305 result.expect_err("ID verification precedes signature and profile admission"), 306 RadrootsFoodAvailabilityAdmissionError::Nip01Verification( 307 RadrootsNip01VerificationError::IdMismatch { 308 expected: wire.id.clone(), 309 actual: wrong_id.clone(), 310 } 311 ) 312 ); 313 refused += 1; 314 } 315 let result = admit_public_json(&invalid_signature_json); 316 assert!( 317 result.is_err(), 318 "invalid signature provides no listing or author authority" 319 ); 320 assert_eq!( 321 result.expect_err("signature verification precedes profile admission"), 322 RadrootsFoodAvailabilityAdmissionError::Nip01Verification( 323 RadrootsNip01VerificationError::SignatureInvalid 324 ) 325 ); 326 refused += 1; 327 } 328 assert_eq!(refused, 12); 329 // Each failed candidate leaves an unrelated authentic observation independently admissible. 330 assert_eq!( 331 admitted(&neighbor).event().author().to_hex(), 332 neighbor.author 333 ); 334 assert!(admitted(&neighbor).projection().quantity().is_none()); 335 } 336 337 #[test] 338 fn retained_incoming_recipes_and_signed_artifacts_are_bounded_public_material() { 339 assert!(RETAINED_CORPUS.len() <= MAX_RETAINED_CORPUS_BYTES); 340 for forbidden in [ 341 "nsec1", 342 "SecretKey", 343 "secret_key", 344 "private_key", 345 "Keys::", 346 "credential", 347 ] { 348 assert!( 349 !RETAINED_CORPUS.contains(forbidden), 350 "public retained recipe source" 351 ); 352 } 353 let signed = sign_public_recipe(corpus::focused_recipe()); 354 for forbidden in ["nsec1", "secret_key", "private_key", "credential"] { 355 assert!(!signed.json.contains(forbidden), "public signed artifact"); 356 } 357 let wire = Nip01EventWire::parse_json(&signed.json).expect("public ID-verified artifact"); 358 assert!(wire.extra.is_empty()); 359 assert_eq!(wire.id.len(), 64); 360 assert_eq!(wire.pubkey.len(), 64); 361 assert_eq!(wire.sig.len(), 128); 362 assert_eq!(wire.pubkey, signed.author); 363 admitted(&signed); 364 }