app

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

commit bb4c426c25ea96cbad62578437d172a6a3b772ca
parent 7928e8fc5d2dfbfc87b9793ef68f20646739653e
Author: triesap <tyson@radroots.org>
Date:   Fri,  2 Oct 2026 02:08:30 +0000

test: cover profile partitions and invalid-event exclusion

- Exercise all focused and operational markers through shared incoming admission.
- Preserve tolerant decimals, raw tags, and unknown optional quantity semantics.
- Reject malformed fields and invalid event authentication before authority.
- Verify bounded public recipes and the unchanged locked Rust and native inputs.

Diffstat:
Acore/crates/harvestcircle_nostr/tests/fixtures/availability_admission_v1.rs | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/harvestcircle_nostr/tests/focused_availability_admission.rs | 364+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 586 insertions(+), 0 deletions(-)

diff --git a/core/crates/harvestcircle_nostr/tests/fixtures/availability_admission_v1.rs b/core/crates/harvestcircle_nostr/tests/fixtures/availability_admission_v1.rs @@ -0,0 +1,222 @@ +//! Bounded public incoming tag recipes, independent of strict writer constructors. + +use radroots_event::food::availability::FoodAvailabilityError; +use radroots_event_codec::admission::food_availability::RadrootsFoodAvailabilityAdmissionError; +use radroots_event_codec::decode::food_availability::RadrootsFoodAvailabilityProjectionError; + +pub const CREATED_AT: u64 = 1_800_000_100; +pub const CONTENT: &str = "Public incoming availability conformance."; +pub const FOCUSED_MARKERS: [&str; 2] = ["radroots:price_unit", "radroots:quantity"]; +pub const OPERATIONAL_MARKERS: [&str; 3] = + ["radroots:primary_bin", "radroots:bin", "radroots:price"]; + +pub const BASE_TAGS: &[&[&str]] = &[ + &["d", "hcav-incoming-v1"], + &["title", "Incoming availability conformance"], + &["summary", "Public incoming protocol fixture"], + &["published_at", "1800000000"], + &["location", "Protocol test location"], + &["price", "0003.5000", "cad"], + &["radroots:price_unit", "lb"], + &["status", "active"], +]; + +pub struct Recipe { + pub content: String, + pub tags: Vec<Vec<String>>, +} + +pub enum Edit { + Remove(&'static str), + Replace(&'static str, &'static [&'static str]), + Append(&'static [&'static str]), + Content(&'static str), +} + +pub struct MalformedCase { + pub name: &'static str, + pub edits: &'static [Edit], + pub expected: RadrootsFoodAvailabilityAdmissionError, + pub code: &'static str, +} + +pub const MALFORMED_CASES: [MalformedCase; 18] = [ + MalformedCase { + name: "missing_title", + edits: &[Edit::Remove("title")], + expected: projection(RadrootsFoodAvailabilityProjectionError::TagInvalid), + code: "food_tag_invalid", + }, + MalformedCase { + name: "duplicate_title", + edits: &[Edit::Append(&["title", "Second title"])], + expected: projection(RadrootsFoodAvailabilityProjectionError::TagInvalid), + code: "food_tag_invalid", + }, + MalformedCase { + name: "title_extra_element", + edits: &[Edit::Replace("title", &["title", "Title", "extra"])], + expected: projection(RadrootsFoodAvailabilityProjectionError::TagInvalid), + code: "food_tag_invalid", + }, + MalformedCase { + name: "untrimmed_title", + edits: &[Edit::Replace("title", &["title", " Untrimmed title"])], + expected: domain(FoodAvailabilityError::TextInvalid), + code: "food_text_invalid", + }, + MalformedCase { + name: "identifier_whitespace", + edits: &[Edit::Replace("d", &["d", "bad identifier"])], + expected: domain(FoodAvailabilityError::IdentifierInvalid), + code: "food_identifier_invalid", + }, + MalformedCase { + name: "empty_content", + edits: &[Edit::Content("")], + expected: domain(FoodAvailabilityError::ContentMissing), + code: "food_content_missing", + }, + MalformedCase { + name: "future_publication", + edits: &[Edit::Replace( + "published_at", + &["published_at", "1800000101"], + )], + expected: domain(FoodAvailabilityError::PublishedAtFuture { + published_at: 1_800_000_101, + created_at: 1_800_000_100, + }), + code: "food_published_at_future", + }, + MalformedCase { + name: "price_exponent", + edits: &[Edit::Replace("price", &["price", "1e3", "CAD"])], + expected: domain(FoodAvailabilityError::PriceInvalid), + code: "price_invalid", + }, + MalformedCase { + name: "price_frequency", + edits: &[Edit::Replace("price", &["price", "1", "CAD", "day"])], + expected: projection(RadrootsFoodAvailabilityProjectionError::PriceFrequencyForbidden), + code: "price_frequency_forbidden", + }, + MalformedCase { + name: "currency_nonletter", + edits: &[Edit::Replace("price", &["price", "1", "C1D"])], + expected: domain(FoodAvailabilityError::PriceCurrencyInvalid), + code: "price_currency_invalid", + }, + MalformedCase { + name: "quantity_marker_without_price_unit", + edits: &[ + Edit::Remove("radroots:price_unit"), + Edit::Append(&["radroots:quantity", "2", "lb"]), + ], + expected: projection(RadrootsFoodAvailabilityProjectionError::PriceUnitMissing), + code: "price_unit_missing", + }, + MalformedCase { + name: "ungoverned_unit", + edits: &[Edit::Replace( + "radroots:price_unit", + &["radroots:price_unit", "crate"], + )], + expected: domain(FoodAvailabilityError::PriceUnitInvalid), + code: "price_unit_invalid", + }, + MalformedCase { + name: "one_element_price_unit", + edits: &[Edit::Replace( + "radroots:price_unit", + &["radroots:price_unit"], + )], + expected: domain(FoodAvailabilityError::PriceUnitInvalid), + code: "price_unit_invalid", + }, + MalformedCase { + name: "zero_quantity", + edits: &[Edit::Append(&["radroots:quantity", "000.00", "lb"])], + expected: domain(FoodAvailabilityError::QuantityZero), + code: "quantity_zero", + }, + MalformedCase { + name: "quantity_unit_mismatch", + edits: &[Edit::Append(&["radroots:quantity", "2", "kg"])], + expected: domain(FoodAvailabilityError::QuantityInvalid), + code: "quantity_invalid", + }, + MalformedCase { + name: "quantity_missing_unit", + edits: &[Edit::Append(&["radroots:quantity", "2"])], + expected: domain(FoodAvailabilityError::QuantityInvalid), + code: "quantity_invalid", + }, + MalformedCase { + name: "unsupported_status", + edits: &[Edit::Replace("status", &["status", "withdrawn"])], + expected: domain(FoodAvailabilityError::StatusInvalid), + code: "food_status_invalid", + }, + MalformedCase { + name: "missing_status", + edits: &[Edit::Remove("status")], + expected: projection(RadrootsFoodAvailabilityProjectionError::TagInvalid), + code: "food_tag_invalid", + }, +]; + +const fn projection( + error: RadrootsFoodAvailabilityProjectionError, +) -> RadrootsFoodAvailabilityAdmissionError { + RadrootsFoodAvailabilityAdmissionError::Projection(error) +} + +const fn domain(error: FoodAvailabilityError) -> RadrootsFoodAvailabilityAdmissionError { + projection(RadrootsFoodAvailabilityProjectionError::Domain(error)) +} + +pub fn raw_tags(tags: &[&[&str]]) -> Vec<Vec<String>> { + tags.iter() + .map(|tag| tag.iter().map(|value| (*value).to_string()).collect()) + .collect() +} + +pub fn focused_recipe() -> Recipe { + Recipe { + content: CONTENT.into(), + tags: raw_tags(BASE_TAGS), + } +} + +pub fn recipe_with_tags(tags: &[&[&str]]) -> Recipe { + Recipe { + content: CONTENT.into(), + tags: raw_tags(tags), + } +} + +pub fn edited_recipe(edits: &[Edit]) -> Recipe { + let mut recipe = focused_recipe(); + for edit in edits { + match edit { + Edit::Remove(name) => recipe.tags.retain(|tag| tag[0] != *name), + Edit::Replace(name, replacement) => { + let tag = recipe + .tags + .iter_mut() + .find(|tag| tag[0] == *name) + .expect("recipe field exists"); + *tag = replacement + .iter() + .map(|value| (*value).to_string()) + .collect(); + } + Edit::Append(tag) => recipe + .tags + .push(tag.iter().map(|value| (*value).to_string()).collect()), + Edit::Content(content) => recipe.content = (*content).into(), + } + } + recipe +} diff --git a/core/crates/harvestcircle_nostr/tests/focused_availability_admission.rs b/core/crates/harvestcircle_nostr/tests/focused_availability_admission.rs @@ -0,0 +1,364 @@ +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use radroots_event::envelope::EventEnvelope; +use radroots_event::listing::classified::{ + ClassifiedListingPartition, classify_classified_listing_tags, +}; +use radroots_event::wire::Nip01EventWire; +use radroots_event_codec::admission::food_availability::{ + RadrootsAdmittedFoodAvailabilityEvent, RadrootsFoodAvailabilityAdmissionError, + RadrootsFoodAvailabilityAdmissionOutcome, verify_and_admit_food_availability_event, +}; +use radroots_event_codec::decode::food_availability::{ + RadrootsFoodAvailabilityImageDiagnostic, RadrootsFoodAvailabilityProjectionError, +}; +use radroots_event_codec::verify::RadrootsNip01VerificationError; + +#[path = "fixtures/availability_admission_v1.rs"] +mod corpus; + +const MAX_RECIPE_BYTES: usize = 4 * 1024; +const MAX_RECIPE_TAGS: usize = 16; +const MAX_SIGNED_EVENT_BYTES: usize = 4 * 1024; +const MAX_RETAINED_CORPUS_BYTES: usize = 16 * 1024; +const RETAINED_CORPUS: &str = include_str!("fixtures/availability_admission_v1.rs"); + +struct SignedPublicEvent { + json: String, + author: String, +} + +fn sign_public_recipe(recipe: corpus::Recipe) -> SignedPublicEvent { + assert!(recipe.tags.len() <= MAX_RECIPE_TAGS); + let recipe_bytes = + recipe.content.len() + recipe.tags.iter().flatten().map(String::len).sum::<usize>(); + assert!(recipe_bytes <= MAX_RECIPE_BYTES); + let keys = Keys::generate(); + let author = keys.public_key().to_hex(); + let tags = recipe + .tags + .into_iter() + .map(|tag| Tag::parse(tag).expect("bounded raw incoming tag")) + .collect::<Vec<_>>(); + let event = EventBuilder::new(Kind::from(30402_u16), recipe.content) + .tags(tags) + .custom_created_at(Timestamp::from(corpus::CREATED_AT)) + .sign_with_keys(&keys) + .expect("isolated incoming fixture signing"); + drop(keys); + let json = event.as_json(); + assert!(json.len() <= MAX_SIGNED_EVENT_BYTES); + SignedPublicEvent { json, author } +} + +fn untrusted_envelope(json: &str) -> EventEnvelope { + assert!(json.len() <= MAX_SIGNED_EVENT_BYTES); + let wire = Nip01EventWire::parse_json_unverified(json) + .expect("bounded structurally valid public NIP-01 input"); + assert!(wire.extra.is_empty()); + wire.into_unverified_envelope() + .expect("untrusted public envelope") +} + +fn admit_public_json( + json: &str, +) -> Result<RadrootsFoodAvailabilityAdmissionOutcome, RadrootsFoodAvailabilityAdmissionError> { + verify_and_admit_food_availability_event(untrusted_envelope(json)) +} + +fn admitted(signed: &SignedPublicEvent) -> RadrootsAdmittedFoodAvailabilityEvent { + match admit_public_json(&signed.json).expect("shared tolerant admission") { + RadrootsFoodAvailabilityAdmissionOutcome::Admitted(event) => { + assert_eq!(event.event().author().to_hex(), signed.author); + assert_eq!(event.event().kind_u32(), 30402); + *event + } + _ => panic!("focused incoming recipe must be admitted"), + } +} + +enum ExpectedAdmission { + Focused, + Excluded(ClassifiedListingPartition), + Error(RadrootsFoodAvailabilityProjectionError), +} + +fn assert_partition( + recipe: corpus::Recipe, + partition: ClassifiedListingPartition, + expected: ExpectedAdmission, +) { + let signed = sign_public_recipe(recipe); + let envelope = untrusted_envelope(&signed.json); + assert_eq!(classify_classified_listing_tags(envelope.tags()), partition); + let result = verify_and_admit_food_availability_event(envelope); + match expected { + ExpectedAdmission::Focused => match result.expect("focused admission") { + RadrootsFoodAvailabilityAdmissionOutcome::Admitted(event) => { + assert_eq!(event.event().author().to_hex(), signed.author); + assert_eq!(event.projection().identifier().as_str(), "hcav-incoming-v1"); + } + _ => panic!("focused partition must produce an admitted listing"), + }, + ExpectedAdmission::Excluded(expected_partition) => { + match result.expect("verified exclusion") { + RadrootsFoodAvailabilityAdmissionOutcome::Excluded(candidate) => { + assert_eq!(candidate.partition(), expected_partition); + assert_eq!(candidate.event().author().to_hex(), signed.author); + } + _ => panic!("non-focused partition must be excluded"), + } + } + ExpectedAdmission::Error(error) => { + assert_eq!( + result.expect_err("exact profile refusal"), + RadrootsFoodAvailabilityAdmissionError::Projection(error) + ); + } + } +} + +#[test] +fn raw_marker_partitions_have_exact_admission_outcomes_before_field_validation() { + assert_eq!( + corpus::FOCUSED_MARKERS, + ["radroots:price_unit", "radroots:quantity"] + ); + assert_eq!( + corpus::OPERATIONAL_MARKERS, + ["radroots:primary_bin", "radroots:bin", "radroots:price"] + ); + let mut cases = 0; + assert_partition( + corpus::focused_recipe(), + ClassifiedListingPartition::FocusedFoodAvailability, + ExpectedAdmission::Focused, + ); + cases += 1; + for marker in corpus::FOCUSED_MARKERS { + assert_partition( + corpus::recipe_with_tags(&[&[marker]]), + ClassifiedListingPartition::FocusedFoodAvailability, + ExpectedAdmission::Error(RadrootsFoodAvailabilityProjectionError::TagInvalid), + ); + cases += 1; + } + for recipe in [ + corpus::recipe_with_tags(&[]), + corpus::edited_recipe(&[corpus::Edit::Remove("radroots:price_unit")]), + corpus::recipe_with_tags(&[ + &["RADROOTS:PRICE_UNIT", "lb"], + &["summary", "radroots:quantity"], + ]), + ] { + assert_partition( + recipe, + ClassifiedListingPartition::GenericNip99, + ExpectedAdmission::Excluded(ClassifiedListingPartition::GenericNip99), + ); + cases += 1; + } + for marker in corpus::OPERATIONAL_MARKERS { + // Raw marker presence selects exclusion before malformed shape or capability checks. + assert_partition( + corpus::recipe_with_tags(&[&[marker], &["delivery"]]), + ClassifiedListingPartition::OperationalListing, + ExpectedAdmission::Excluded(ClassifiedListingPartition::OperationalListing), + ); + cases += 1; + } + for focused in corpus::FOCUSED_MARKERS { + for operational in corpus::OPERATIONAL_MARKERS { + for markers in [[focused, operational], [operational, focused]] { + assert_partition( + corpus::recipe_with_tags(&[&[markers[0]], &[markers[1]], &["delivery"]]), + ClassifiedListingPartition::Ambiguous, + ExpectedAdmission::Error( + RadrootsFoodAvailabilityProjectionError::ProfileAmbiguous, + ), + ); + cases += 1; + } + } + } + assert_eq!(cases, 21); +} + +#[test] +fn tolerant_incoming_values_normalize_and_missing_quantity_stays_unknown() { + let signed = sign_public_recipe(corpus::focused_recipe()); + let event = admitted(&signed); + let projection = event.projection(); + assert_eq!(event.contract().id, "radroots.food.availability.v1"); + assert_eq!( + projection.content().as_str(), + "Public incoming availability conformance." + ); + assert_eq!(projection.identifier().as_str(), "hcav-incoming-v1"); + assert_eq!( + projection.title().as_str(), + "Incoming availability conformance" + ); + assert_eq!( + projection.summary().as_str(), + "Public incoming protocol fixture" + ); + assert_eq!(projection.location().as_str(), "Protocol test location"); + assert_eq!(projection.published_at().as_u64(), 1_800_000_000); + assert_eq!(projection.price().amount(), "3.5"); + assert_eq!(projection.price().currency().as_str(), "CAD"); + assert_eq!(projection.price().unit().as_str(), "lb"); + assert_eq!(projection.status().as_str(), "active"); + assert!(projection.quantity().is_none()); + assert!(projection.images().is_empty()); + assert!(projection.diagnostics().is_empty()); + // Admission preserves the authenticated input instead of rewriting normalized tags. + assert_eq!( + event.event().tags_as_vec(), + corpus::raw_tags(corpus::BASE_TAGS) + ); + + let signed = sign_public_recipe(corpus::edited_recipe(&[ + corpus::Edit::Append(&["radroots:quantity", "0012.5000", "lb"]), + corpus::Edit::Append(&["image", "https://images.example.test/incoming.webp"]), + corpus::Edit::Append(&["t", "conformance"]), + ])); + let event = admitted(&signed); + let projection = event.projection(); + let quantity = projection.quantity().expect("known incoming quantity"); + assert_eq!(quantity.amount(), "12.5"); + assert_eq!(quantity.unit().as_str(), "lb"); + assert_eq!(projection.images().len(), 1); + let image = &projection.images()[0]; + assert_eq!( + image.raw_tag(), + ["image", "https://images.example.test/incoming.webp"] + ); + assert_eq!( + image.url(), + Some("https://images.example.test/incoming.webp") + ); + assert!(image.dimensions().is_none()); + assert!(!image.qualifies()); + let expected_diagnostics = [ + RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid, + RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing, + ]; + assert_eq!(image.diagnostics(), expected_diagnostics); + assert_eq!(projection.diagnostics(), expected_diagnostics); +} + +#[test] +fn malformed_focused_fields_return_exact_shared_admission_errors() { + assert_eq!(corpus::MALFORMED_CASES.len(), 18); + let mut cases = 0; + for case in &corpus::MALFORMED_CASES { + let signed = sign_public_recipe(corpus::edited_recipe(case.edits)); + let error = admit_public_json(&signed.json).expect_err(case.name); + assert_eq!(error, case.expected, "{}", case.name); + assert_eq!(error.code(), case.code, "{}", case.name); + cases += 1; + } + assert_eq!(cases, 18); +} + +fn replace_public_field(json: &str, field: &str, original: &str, replacement: &str) -> String { + let original_field = format!("\"{field}\":\"{original}\""); + assert_eq!(json.matches(&original_field).count(), 1); + json.replacen( + &original_field, + &format!("\"{field}\":\"{replacement}\""), + 1, + ) +} + +#[test] +fn invalid_id_and_signature_never_admit_a_listing_or_author_across_partitions() { + let neighbor = sign_public_recipe(corpus::focused_recipe()); + assert_eq!( + admitted(&neighbor).event().author().to_hex(), + neighbor.author + ); + let mut refused = 0; + for recipe in [ + corpus::focused_recipe(), + corpus::recipe_with_tags(&[]), + corpus::recipe_with_tags(&[&["radroots:bin"]]), + corpus::recipe_with_tags(&[&["radroots:quantity"], &["radroots:price"]]), + ] { + let signed = sign_public_recipe(recipe); + let wire = + Nip01EventWire::parse_json_unverified(&signed.json).expect("public fixture wire"); + let first = if wire.id.starts_with('0') { '1' } else { '0' }; + let wrong_id = format!("{first}{}", &wire.id[1..]); + let invalid_id_json = replace_public_field(&signed.json, "id", &wire.id, &wrong_id); + let invalid_signature_json = + replace_public_field(&signed.json, "sig", &wire.sig, &"0".repeat(128)); + let both_invalid_json = + replace_public_field(&invalid_signature_json, "id", &wire.id, &wrong_id); + for json in [&invalid_id_json, &both_invalid_json] { + let result = admit_public_json(json); + assert!( + result.is_err(), + "no admitted or excluded event can expose an author" + ); + assert_eq!( + result.expect_err("ID verification precedes signature and profile admission"), + RadrootsFoodAvailabilityAdmissionError::Nip01Verification( + RadrootsNip01VerificationError::IdMismatch { + expected: wire.id.clone(), + actual: wrong_id.clone(), + } + ) + ); + refused += 1; + } + let result = admit_public_json(&invalid_signature_json); + assert!( + result.is_err(), + "invalid signature provides no listing or author authority" + ); + assert_eq!( + result.expect_err("signature verification precedes profile admission"), + RadrootsFoodAvailabilityAdmissionError::Nip01Verification( + RadrootsNip01VerificationError::SignatureInvalid + ) + ); + refused += 1; + } + assert_eq!(refused, 12); + // Each failed candidate leaves an unrelated authentic observation independently admissible. + assert_eq!( + admitted(&neighbor).event().author().to_hex(), + neighbor.author + ); + assert!(admitted(&neighbor).projection().quantity().is_none()); +} + +#[test] +fn retained_incoming_recipes_and_signed_artifacts_are_bounded_public_material() { + assert!(RETAINED_CORPUS.len() <= MAX_RETAINED_CORPUS_BYTES); + for forbidden in [ + "nsec1", + "SecretKey", + "secret_key", + "private_key", + "Keys::", + "credential", + ] { + assert!( + !RETAINED_CORPUS.contains(forbidden), + "public retained recipe source" + ); + } + let signed = sign_public_recipe(corpus::focused_recipe()); + for forbidden in ["nsec1", "secret_key", "private_key", "credential"] { + assert!(!signed.json.contains(forbidden), "public signed artifact"); + } + let wire = Nip01EventWire::parse_json(&signed.json).expect("public ID-verified artifact"); + assert!(wire.extra.is_empty()); + assert_eq!(wire.id.len(), 64); + assert_eq!(wire.pubkey.len(), 64); + assert_eq!(wire.sig.len(), 128); + assert_eq!(wire.pubkey, signed.author); + admitted(&signed); +}