app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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 }