lib

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

knowledge_fixtures.rs (27642B)


      1 #![cfg(feature = "knowledge")]
      2 
      3 mod support;
      4 
      5 use std::collections::BTreeSet;
      6 
      7 use radroots_event::contract::{
      8     ContractValidationError, EventClass, all_event_contracts, validate_event_contract_shape,
      9 };
     10 use radroots_event::envelope::kind::{
     11     KIND_CONTRIBUTION_ATTESTATION, KIND_KNOWLEDGE_CHANGE_PROPOSAL, KIND_KNOWLEDGE_CLAIM,
     12     KIND_KNOWLEDGE_FIELD_REPORT, KIND_KNOWLEDGE_RELATION, KIND_KNOWLEDGE_REVIEW, KIND_WIKI_ARTICLE,
     13 };
     14 use radroots_event::knowledge::{
     15     RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, WikiArticle,
     16 };
     17 use radroots_event::wire::Nip01EventWireParts;
     18 use radroots_event::{envelope::EventEnvelope, envelope::EventEnvelopeParts};
     19 use radroots_event_codec::decode::EventParseError;
     20 use radroots_event_codec::decode::knowledge::{
     21     wiki_article_from_event, wiki_merge_request_from_event,
     22 };
     23 use radroots_event_codec::encode::EventEncodeError;
     24 use radroots_event_codec::encode::knowledge::{
     25     contribution_attestation_to_wire_parts, evidence_bounty_to_wire_parts,
     26     knowledge_change_proposal_to_wire_parts, knowledge_claim_to_wire_parts,
     27     knowledge_field_report_to_wire_parts, knowledge_relation_to_wire_parts,
     28     knowledge_review_to_wire_parts, knowledge_source_to_wire_parts, wiki_article_to_wire_parts,
     29     wiki_merge_request_to_wire_parts, wiki_redirect_to_wire_parts,
     30 };
     31 #[cfg(feature = "manifests")]
     32 use radroots_event_codec::manifest::{contract_manifest_json, knowledge_contract_manifest};
     33 use radroots_event_codec::verify::{
     34     RadrootsDecodeError, RadrootsDecodedEvent, RadrootsNip01VerificationError,
     35     verify_and_decode_radroots_event,
     36 };
     37 use support::RELAY_PRIMARY_WSS;
     38 use support::knowledge::{
     39     RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES, RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS,
     40     RadrootsKnowledgeFixture, RadrootsKnowledgeFixtureCase, hex_64, knowledge_claim,
     41     knowledge_field_report, knowledge_valid_fixtures, wiki_article, wiki_merge_request,
     42     wiki_redirect,
     43 };
     44 
     45 fn event_from_parts(parts: Nip01EventWireParts) -> EventEnvelope {
     46     EventEnvelope::new(EventEnvelopeParts {
     47         id: hex_64('0'),
     48         author: hex_64('a'),
     49         created_at: 1_800_000_000,
     50         kind: parts.kind,
     51         tags: parts.tags,
     52         content: parts.content,
     53         sig: "1".repeat(128),
     54     })
     55     .unwrap()
     56 }
     57 
     58 fn sign_parts(parts: Nip01EventWireParts) -> EventEnvelope {
     59     let tags = parts
     60         .tags
     61         .into_iter()
     62         .map(nostr::Tag::parse)
     63         .collect::<Result<Vec<_>, _>>()
     64         .expect("tags");
     65     let keys =
     66         nostr::Keys::parse("0101010101010101010101010101010101010101010101010101010101010101")
     67             .expect("keys");
     68     let kind = u16::try_from(parts.kind).expect("knowledge event kind must fit NIP-01");
     69     let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind), parts.content)
     70         .tags(tags)
     71         .custom_created_at(nostr::Timestamp::from_secs(1_800_000_000))
     72         .sign_with_keys(&keys)
     73         .expect("signed event");
     74     EventEnvelope::new(EventEnvelopeParts {
     75         id: event.id.to_hex(),
     76         author: event.pubkey.to_hex(),
     77         created_at: event.created_at.as_secs(),
     78         kind: u32::from(event.kind.as_u16()),
     79         tags: event
     80             .tags
     81             .as_slice()
     82             .iter()
     83             .map(|tag| tag.as_slice().to_vec())
     84             .collect(),
     85         content: event.content,
     86         sig: event.sig.to_string(),
     87     })
     88     .unwrap()
     89 }
     90 
     91 fn event_with_parts(
     92     event: &EventEnvelope,
     93     tags: Vec<Vec<String>>,
     94     content: String,
     95     sig: String,
     96 ) -> EventEnvelope {
     97     EventEnvelope::new(EventEnvelopeParts {
     98         id: event.id_hex().to_string(),
     99         author: event.author().to_hex().to_string(),
    100         created_at: event.created_at_u64(),
    101         kind: event.kind_u32(),
    102         tags,
    103         content,
    104         sig,
    105     })
    106     .unwrap()
    107 }
    108 
    109 fn mutate_tags(event: &mut EventEnvelope, update: impl FnOnce(&mut Vec<Vec<String>>)) {
    110     let mut tags = event.tags_as_vec();
    111     update(&mut tags);
    112     *event = event_with_parts(
    113         event,
    114         tags,
    115         event.content().to_string(),
    116         event.signature_hex().to_string(),
    117     );
    118 }
    119 
    120 fn replace_content(event: &mut EventEnvelope, content: String) {
    121     *event = event_with_parts(
    122         event,
    123         event.tags_as_vec(),
    124         content,
    125         event.signature_hex().to_string(),
    126     );
    127 }
    128 
    129 fn replace_sig(event: &mut EventEnvelope, sig: String) {
    130     *event = event_with_parts(event, event.tags_as_vec(), event.content().to_string(), sig);
    131 }
    132 
    133 fn parts_for_fixture(fixture: &RadrootsKnowledgeFixture) -> Nip01EventWireParts {
    134     match fixture {
    135         RadrootsKnowledgeFixture::WikiArticle(value) => wiki_article_to_wire_parts(value).unwrap(),
    136         RadrootsKnowledgeFixture::WikiRedirect(value) => {
    137             wiki_redirect_to_wire_parts(value).unwrap()
    138         }
    139         RadrootsKnowledgeFixture::WikiMergeRequest(value) => {
    140             wiki_merge_request_to_wire_parts(value).unwrap()
    141         }
    142         RadrootsKnowledgeFixture::KnowledgeSource(value) => {
    143             knowledge_source_to_wire_parts(value).unwrap()
    144         }
    145         RadrootsKnowledgeFixture::KnowledgeClaim(value) => {
    146             knowledge_claim_to_wire_parts(value).unwrap()
    147         }
    148         RadrootsKnowledgeFixture::KnowledgeRelation(value) => {
    149             knowledge_relation_to_wire_parts(value).unwrap()
    150         }
    151         RadrootsKnowledgeFixture::KnowledgeReview(value) => {
    152             knowledge_review_to_wire_parts(value).unwrap()
    153         }
    154         RadrootsKnowledgeFixture::KnowledgeFieldReport(value) => {
    155             knowledge_field_report_to_wire_parts(value).unwrap()
    156         }
    157         RadrootsKnowledgeFixture::EvidenceBounty(value) => {
    158             evidence_bounty_to_wire_parts(value).unwrap()
    159         }
    160         RadrootsKnowledgeFixture::KnowledgeChangeProposal(value) => {
    161             knowledge_change_proposal_to_wire_parts(value).unwrap()
    162         }
    163         RadrootsKnowledgeFixture::ContributionAttestation(value) => {
    164             contribution_attestation_to_wire_parts(value).unwrap()
    165         }
    166     }
    167 }
    168 
    169 fn fixture_by_id<'a>(
    170     fixtures: &'a [RadrootsKnowledgeFixtureCase],
    171     id: &str,
    172 ) -> &'a RadrootsKnowledgeFixtureCase {
    173     fixtures
    174         .iter()
    175         .find(|fixture| fixture.id == id)
    176         .unwrap_or_else(|| panic!("missing fixture {id}"))
    177 }
    178 
    179 fn has_exact_tag(tags: &[Vec<String>], expected: &[&str]) -> bool {
    180     tags.iter().any(|tag| {
    181         tag.iter()
    182             .map(|entry| entry.as_str())
    183             .eq(expected.iter().copied())
    184     })
    185 }
    186 
    187 #[test]
    188 fn golden_knowledge_fixtures_cover_every_contract() {
    189     let fixtures = knowledge_valid_fixtures();
    190     let fixture_contracts = fixtures
    191         .iter()
    192         .map(|fixture| fixture.contract_id)
    193         .collect::<BTreeSet<_>>();
    194     let registry_contracts = all_event_contracts()
    195         .iter()
    196         .filter(|contract| RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS.contains(&contract.id))
    197         .map(|contract| contract.id)
    198         .collect::<BTreeSet<_>>();
    199 
    200     assert_eq!(fixture_contracts, registry_contracts);
    201 
    202     for fixture in &fixtures {
    203         let event = event_from_parts(parts_for_fixture(&fixture.data));
    204         validate_event_contract_shape(&event, fixture.contract_id).unwrap();
    205         assert_eq!(event.kind_u32(), fixture.kind, "{}", fixture.id);
    206     }
    207 
    208     let article_parts = parts_for_fixture(&fixture_by_id(&fixtures, "wiki_article_valid").data);
    209     let fork_address = format!("{}:{}:soil-health", KIND_WIKI_ARTICLE, hex_64('a'));
    210     let defer_address = format!("{}:{}:soil-health-v2", KIND_WIKI_ARTICLE, hex_64('a'));
    211     assert!(has_exact_tag(
    212         &article_parts.tags,
    213         &["a", fork_address.as_str(), RELAY_PRIMARY_WSS, "fork",]
    214     ));
    215     assert!(has_exact_tag(
    216         &article_parts.tags,
    217         &["e", hex_64('b').as_str(), RELAY_PRIMARY_WSS, "fork"]
    218     ));
    219     assert!(has_exact_tag(
    220         &article_parts.tags,
    221         &["a", defer_address.as_str(), RELAY_PRIMARY_WSS, "defer",]
    222     ));
    223     assert!(has_exact_tag(
    224         &article_parts.tags,
    225         &["e", hex_64('c').as_str(), RELAY_PRIMARY_WSS, "defer"]
    226     ));
    227 
    228     let merge_without_base =
    229         parts_for_fixture(&fixture_by_id(&fixtures, "wiki_merge_request_without_base_valid").data);
    230     assert!(has_exact_tag(
    231         &merge_without_base.tags,
    232         &["e", hex_64('f').as_str(), "", "source"]
    233     ));
    234     assert!(
    235         !merge_without_base
    236             .tags
    237             .iter()
    238             .any(|tag| tag == &vec!["e".to_string(), hex_64('e'), String::new()])
    239     );
    240 }
    241 
    242 #[test]
    243 fn adversarial_knowledge_fixtures_reject_at_expected_stages() {
    244     let malformed = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    245         .iter()
    246         .find(|fixture| fixture.id == "malformed_tags")
    247         .unwrap();
    248     let mut malformed_event =
    249         event_from_parts(knowledge_claim_to_wire_parts(&knowledge_claim()).unwrap());
    250     mutate_tags(&mut malformed_event, |tags| {
    251         tags.push(vec![
    252             "contract".to_string(),
    253             RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(),
    254         ]);
    255     });
    256     let error = validate_event_contract_shape(&malformed_event, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA)
    257         .unwrap_err();
    258     assert_eq!(malformed.pipeline_stage, "contract_validation");
    259     assert_eq!(malformed.expected_error_code, error.code());
    260 
    261     let wrong_schema = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    262         .iter()
    263         .find(|fixture| fixture.id == "wrong_schema")
    264         .unwrap();
    265     let mut wrong_schema_event =
    266         event_from_parts(knowledge_claim_to_wire_parts(&knowledge_claim()).unwrap());
    267     let mut wrong_schema_value: serde_json::Value =
    268         serde_json::from_str(wrong_schema_event.content()).unwrap();
    269     wrong_schema_value["schema"] = serde_json::Value::from("radroots.knowledge.relation.v1");
    270     replace_content(
    271         &mut wrong_schema_event,
    272         serde_json::to_string(&wrong_schema_value).unwrap(),
    273     );
    274     let error = validate_event_contract_shape(&wrong_schema_event, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA)
    275         .unwrap_err();
    276     assert_eq!(wrong_schema.pipeline_stage, "contract_validation");
    277     assert_eq!(wrong_schema.expected_error_code, error.code());
    278 
    279     let missing_contract = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    280         .iter()
    281         .find(|fixture| fixture.id == "missing_contract_id")
    282         .unwrap();
    283     let mut missing_contract_event =
    284         event_from_parts(knowledge_claim_to_wire_parts(&knowledge_claim()).unwrap());
    285     mutate_tags(&mut missing_contract_event, |tags| {
    286         tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some("contract"));
    287     });
    288     let signed = sign_parts(Nip01EventWireParts {
    289         kind: missing_contract_event.kind_u32(),
    290         content: missing_contract_event.content().to_string(),
    291         tags: missing_contract_event.tags_as_vec(),
    292     });
    293     let error = verify_and_decode_radroots_event(signed).unwrap_err();
    294     assert_eq!(missing_contract.pipeline_stage, error.code());
    295     assert!(matches!(error, RadrootsDecodeError::ContractValidation(_)));
    296 
    297     let private_coordinates = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    298         .iter()
    299         .find(|fixture| fixture.id == "private_coordinate_leakage")
    300         .unwrap();
    301     let mut private_event =
    302         event_from_parts(knowledge_field_report_to_wire_parts(&knowledge_field_report()).unwrap());
    303     let mut private_value: serde_json::Value =
    304         serde_json::from_str(private_event.content()).unwrap();
    305     private_value["context"]["latitude"] = serde_json::Value::from("45.0000");
    306     replace_content(
    307         &mut private_event,
    308         serde_json::to_string(&private_value).unwrap(),
    309     );
    310     let signed = sign_parts(Nip01EventWireParts {
    311         kind: private_event.kind_u32(),
    312         content: private_event.content().to_string(),
    313         tags: private_event.tags_as_vec(),
    314     });
    315     let error = verify_and_decode_radroots_event(signed).unwrap_err();
    316     assert_eq!(private_coordinates.pipeline_stage, error.code());
    317     match error {
    318         RadrootsDecodeError::EventParse(error) => {
    319             assert_eq!(private_coordinates.expected_error_code, error.code());
    320         }
    321         error => panic!("{error:?}"),
    322     }
    323 
    324     let unsupported = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    325         .iter()
    326         .find(|fixture| fixture.id == "unsupported_contract_shape")
    327         .unwrap();
    328     let mut unsupported_event =
    329         event_from_parts(knowledge_claim_to_wire_parts(&knowledge_claim()).unwrap());
    330     mutate_tags(&mut unsupported_event, |tags| {
    331         for tag in tags {
    332             if tag.first().map(|value| value.as_str()) == Some("contract") {
    333                 tag[1] = "radroots.knowledge.unsupported.v1".to_string();
    334             }
    335         }
    336     });
    337     let signed = sign_parts(Nip01EventWireParts {
    338         kind: unsupported_event.kind_u32(),
    339         content: unsupported_event.content().to_string(),
    340         tags: unsupported_event.tags_as_vec(),
    341     });
    342     let error = verify_and_decode_radroots_event(signed).unwrap_err();
    343     assert_eq!(unsupported.pipeline_stage, error.code());
    344     assert!(matches!(error, RadrootsDecodeError::ContractValidation(_)));
    345 }
    346 
    347 #[test]
    348 fn nip54_and_signature_adversarial_fixtures_are_rejected() {
    349     let invalid_d_tag = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    350         .iter()
    351         .find(|fixture| fixture.id == "invalid_nip54_d_tag")
    352         .unwrap();
    353     let mut article: WikiArticle = wiki_article();
    354     article.d_tag = "Soil Health".to_string();
    355     let error = wiki_article_to_wire_parts(&article).unwrap_err();
    356     assert_eq!(invalid_d_tag.pipeline_stage, "encode");
    357     assert_eq!(invalid_d_tag.expected_error_code, error.code());
    358     assert!(matches!(error, EventEncodeError::InvalidField("d_tag")));
    359 
    360     let invalid_redirect = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    361         .iter()
    362         .find(|fixture| fixture.id == "invalid_redirect_target_kind")
    363         .unwrap();
    364     let mut redirect = wiki_redirect();
    365     redirect.target.kind = 30023;
    366     let error = wiki_redirect_to_wire_parts(&redirect).unwrap_err();
    367     assert_eq!(invalid_redirect.pipeline_stage, "encode");
    368     assert_eq!(invalid_redirect.expected_error_code, error.code());
    369     assert!(matches!(
    370         error,
    371         EventEncodeError::InvalidField("wiki_redirect.target")
    372     ));
    373 
    374     let missing_source = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    375         .iter()
    376         .find(|fixture| fixture.id == "merge_request_missing_source_marker")
    377         .unwrap();
    378     let mut missing_source_event =
    379         event_from_parts(wiki_merge_request_to_wire_parts(&wiki_merge_request()).unwrap());
    380     mutate_tags(&mut missing_source_event, |tags| {
    381         tags.retain(|tag| {
    382             !(tag.first().map(|value| value.as_str()) == Some("e")
    383                 && tag.last().map(|value| value.as_str()) == Some("source"))
    384         });
    385     });
    386     let error = wiki_merge_request_from_event(missing_source_event).unwrap_err();
    387     assert_eq!(missing_source.pipeline_stage, "event_parse");
    388     assert_eq!(missing_source.expected_error_code, error.code());
    389     assert!(matches!(error, EventParseError::InvalidTag("e")));
    390 
    391     let json_guard = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    392         .iter()
    393         .find(|fixture| fixture.id == "merge_request_json_content_guard")
    394         .unwrap();
    395     let merge_parts = wiki_merge_request_to_wire_parts(&wiki_merge_request()).unwrap();
    396     assert_eq!(json_guard.pipeline_stage, "wire_shape");
    397     assert_eq!(json_guard.expected_error_code, "plain_text_content");
    398     assert_eq!(merge_parts.content, "Merge synthetic soil article updates");
    399     assert!(serde_json::from_str::<serde_json::Value>(&merge_parts.content).is_err());
    400     assert!(!merge_parts.content.contains("target_article"));
    401 
    402     let orphan_fork = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    403         .iter()
    404         .find(|fixture| fixture.id == "orphan_fork_marker")
    405         .unwrap();
    406     let mut orphan_fork_event =
    407         event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    408     let mut removed_fork_event = false;
    409     mutate_tags(&mut orphan_fork_event, |tags| {
    410         tags.retain(|tag| {
    411             if !removed_fork_event
    412                 && tag.first().map(|value| value.as_str()) == Some("e")
    413                 && tag.last().map(|value| value.as_str()) == Some("fork")
    414             {
    415                 removed_fork_event = true;
    416                 false
    417             } else {
    418                 true
    419             }
    420         });
    421     });
    422     let error = wiki_article_from_event(orphan_fork_event).unwrap_err();
    423     assert_eq!(orphan_fork.pipeline_stage, "event_parse");
    424     assert_eq!(orphan_fork.expected_error_code, error.code());
    425     assert!(matches!(error, EventParseError::InvalidTag("a")));
    426 
    427     let orphan_defer = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    428         .iter()
    429         .find(|fixture| fixture.id == "orphan_defer_marker")
    430         .unwrap();
    431     let mut orphan_defer_event =
    432         event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    433     let mut removed_defer_address = false;
    434     mutate_tags(&mut orphan_defer_event, |tags| {
    435         tags.retain(|tag| {
    436             if !removed_defer_address
    437                 && tag.first().map(|value| value.as_str()) == Some("a")
    438                 && tag.last().map(|value| value.as_str()) == Some("defer")
    439             {
    440                 removed_defer_address = true;
    441                 false
    442             } else {
    443                 true
    444             }
    445         });
    446     });
    447     let error = wiki_article_from_event(orphan_defer_event).unwrap_err();
    448     assert_eq!(orphan_defer.pipeline_stage, "event_parse");
    449     assert_eq!(orphan_defer.expected_error_code, error.code());
    450     assert!(matches!(error, EventParseError::InvalidTag("e")));
    451 
    452     let id_mismatch = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    453         .iter()
    454         .find(|fixture| fixture.id == "id_mismatch")
    455         .unwrap();
    456     let signed = sign_parts(knowledge_claim_to_wire_parts(&knowledge_claim()).unwrap());
    457     let mut mutated = signed.clone();
    458     let mutated_content = mutated.content().replace("Cover crops", "Compost");
    459     replace_content(&mut mutated, mutated_content);
    460     let error = verify_and_decode_radroots_event(mutated).unwrap_err();
    461     assert_eq!(id_mismatch.pipeline_stage, error.code());
    462     match error {
    463         RadrootsDecodeError::Nip01Verification(RadrootsNip01VerificationError::IdMismatch {
    464             ..
    465         }) => {}
    466         error => panic!("{error:?}"),
    467     }
    468 
    469     let signature_invalidity = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES
    470         .iter()
    471         .find(|fixture| fixture.id == "signature_invalidity")
    472         .unwrap();
    473     let mut bad_signature = signed;
    474     replace_sig(&mut bad_signature, "0".repeat(128));
    475     let error = verify_and_decode_radroots_event(bad_signature).unwrap_err();
    476     assert_eq!(signature_invalidity.pipeline_stage, error.code());
    477     match error {
    478         RadrootsDecodeError::Nip01Verification(
    479             RadrootsNip01VerificationError::SignatureInvalid,
    480         ) => {}
    481         error => panic!("{error:?}"),
    482     }
    483 }
    484 
    485 #[test]
    486 fn authoritative_knowledge_status_fields_are_rejected() {
    487     for field in [
    488         "review_status",
    489         "canon_status",
    490         "approved_for_canon",
    491         "rights_status",
    492         "trust_status",
    493         "trusted",
    494     ] {
    495         let mut event =
    496             event_from_parts(knowledge_claim_to_wire_parts(&knowledge_claim()).unwrap());
    497         let mut value: serde_json::Value = serde_json::from_str(event.content()).unwrap();
    498         value[field] = serde_json::Value::from("approved");
    499         replace_content(&mut event, serde_json::to_string(&value).unwrap());
    500         let error =
    501             validate_event_contract_shape(&event, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA).unwrap_err();
    502         assert_eq!(
    503             error,
    504             ContractValidationError::ForbiddenContentField {
    505                 contract_id: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
    506                 field,
    507             }
    508         );
    509     }
    510 }
    511 
    512 #[test]
    513 fn immutable_knowledge_contracts_are_regular_events() {
    514     let regular_ids = [
    515         (RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, KIND_KNOWLEDGE_CLAIM),
    516         ("radroots.knowledge.relation.v1", KIND_KNOWLEDGE_RELATION),
    517         ("radroots.knowledge.review.v1", KIND_KNOWLEDGE_REVIEW),
    518         (
    519             RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA,
    520             KIND_KNOWLEDGE_FIELD_REPORT,
    521         ),
    522         (
    523             "radroots.knowledge.change_proposal.v1",
    524             KIND_KNOWLEDGE_CHANGE_PROPOSAL,
    525         ),
    526         (
    527             "radroots.knowledge.contribution_attestation.v1",
    528             KIND_CONTRIBUTION_ATTESTATION,
    529         ),
    530     ];
    531     for (contract_id, kind) in regular_ids {
    532         let contract = all_event_contracts()
    533             .iter()
    534             .find(|contract| contract.id == contract_id)
    535             .unwrap();
    536         assert_eq!(contract.kind, kind);
    537         assert_eq!(contract.class, EventClass::Regular);
    538     }
    539 }
    540 
    541 #[test]
    542 fn verified_decode_exposes_representative_public_surface_events() {
    543     let fixture_ids = [
    544         "wiki_article_valid",
    545         "wiki_redirect_valid",
    546         "wiki_merge_request_without_base_valid",
    547         "knowledge_source_valid",
    548         "knowledge_claim_valid",
    549         "knowledge_review_valid",
    550         "knowledge_field_report_valid",
    551     ];
    552     for fixture in knowledge_valid_fixtures()
    553         .into_iter()
    554         .filter(|fixture| fixture_ids.contains(&fixture.id))
    555     {
    556         let signed = sign_parts(parts_for_fixture(&fixture.data));
    557         let decoded = verify_and_decode_radroots_event(signed).unwrap();
    558         match decoded {
    559             RadrootsDecodedEvent::WikiArticle(_)
    560             | RadrootsDecodedEvent::WikiRedirect(_)
    561             | RadrootsDecodedEvent::WikiMergeRequest(_)
    562             | RadrootsDecodedEvent::KnowledgeSource(_)
    563             | RadrootsDecodedEvent::KnowledgeClaim(_)
    564             | RadrootsDecodedEvent::KnowledgeReview(_)
    565             | RadrootsDecodedEvent::KnowledgeFieldReport(_) => {}
    566             decoded => panic!("{decoded:?}"),
    567         }
    568     }
    569 }
    570 
    571 #[cfg(feature = "manifests")]
    572 #[test]
    573 fn checked_in_manifest_and_decode_vectors_execute_every_declared_case() {
    574     const PACKAGED_VECTORS: &str = include_str!("fixtures/knowledge_manifest_and_decode.v1.json");
    575     let workspace_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
    576         .join("../../contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json");
    577     if let Ok(workspace_vectors) = std::fs::read_to_string(workspace_path) {
    578         assert_eq!(PACKAGED_VECTORS, workspace_vectors);
    579     }
    580 
    581     let suite: serde_json::Value = serde_json::from_str(PACKAGED_VECTORS).expect("vector suite");
    582     assert_eq!(suite["suite"], "knowledge_manifest_and_decode");
    583     assert_eq!(suite["contract_version"], "1.0.0");
    584     let vectors = suite["vectors"].as_array().expect("vectors");
    585     assert_eq!(vectors.len(), 7);
    586 
    587     let fixtures = knowledge_valid_fixtures();
    588     for vector in vectors {
    589         let id = vector["id"].as_str().expect("vector id");
    590         let kind = vector["kind"].as_str().expect("vector kind");
    591         let expected = vector["expected"].as_object().expect("expected object");
    592         match kind {
    593             "knowledge.contract_manifest_json.valid" => {
    594                 let manifest_json = contract_manifest_json().expect("manifest JSON");
    595                 let manifest: serde_json::Value =
    596                     serde_json::from_str(manifest_json.as_str()).expect("manifest value");
    597                 assert_eq!(
    598                     manifest["schema_version"], expected["schema_version"],
    599                     "{id}"
    600                 );
    601                 assert_eq!(
    602                     manifest["registry_version"], expected["registry_version"],
    603                     "{id}"
    604                 );
    605                 let contracts = manifest["contracts"].as_array().expect("contracts");
    606                 assert_eq!(
    607                     contracts.len(),
    608                     knowledge_contract_manifest().contracts.len(),
    609                     "{id}"
    610                 );
    611                 for field in expected["required_fields"]
    612                     .as_array()
    613                     .expect("required fields")
    614                 {
    615                     let field = field.as_str().expect("field name");
    616                     assert!(
    617                         manifest.get(field).is_some()
    618                             || contracts
    619                                 .iter()
    620                                 .all(|contract| contract.get(field).is_some()),
    621                         "{id}: missing required manifest field {field}"
    622                     );
    623                 }
    624             }
    625             "knowledge.verify_and_decode_event.valid" => {
    626                 let fixture_id = vector["input"]["fixture"].as_str().expect("fixture id");
    627                 let signed = sign_parts(parts_for_fixture(
    628                     &fixture_by_id(&fixtures, fixture_id).data,
    629                 ));
    630                 let decoded = verify_and_decode_radroots_event(signed).expect("decoded event");
    631                 assert!(
    632                     matches!(decoded, RadrootsDecodedEvent::KnowledgeClaim(_)),
    633                     "{id}"
    634                 );
    635                 assert_eq!(expected["decoded_variant"], "KnowledgeClaim", "{id}");
    636                 assert_eq!(
    637                     expected["contract_id"], RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
    638                     "{id}"
    639                 );
    640             }
    641             "knowledge.verify_and_decode_event.invalid" => {
    642                 let mut parts = knowledge_claim_to_wire_parts(&knowledge_claim()).expect("parts");
    643                 parts
    644                     .tags
    645                     .retain(|tag| tag.first().map(String::as_str) != Some("contract"));
    646                 let error = verify_and_decode_radroots_event(sign_parts(parts))
    647                     .expect_err("missing contract must fail");
    648                 assert!(
    649                     matches!(error, RadrootsDecodeError::ContractValidation(_)),
    650                     "{id}"
    651                 );
    652                 assert_eq!(expected["error_class"], "decode_error", "{id}");
    653                 assert_eq!(expected["stage"], error.code(), "{id}");
    654             }
    655             "knowledge.nip54.wiki_article_tags.valid"
    656             | "knowledge.nip54.wiki_redirect_tags.valid"
    657             | "knowledge.nip54.wiki_merge_request.valid" => {
    658                 let fixture_id = vector["input"]["fixture"].as_str().expect("fixture id");
    659                 let parts = parts_for_fixture(&fixture_by_id(&fixtures, fixture_id).data);
    660                 if let Some(content) = expected.get("content") {
    661                     assert_eq!(content, parts.content.as_str(), "{id}");
    662                 }
    663                 for (field, value) in expected {
    664                     if field.ends_with("_tag") {
    665                         let expected_tag = value
    666                             .as_array()
    667                             .expect("expected tag")
    668                             .iter()
    669                             .map(|value| value.as_str().expect("tag value").to_owned())
    670                             .collect::<Vec<_>>();
    671                         assert!(parts.tags.contains(&expected_tag), "{id}: missing {field}");
    672                     }
    673                 }
    674                 if expected.get("base_version_event_id") == Some(&serde_json::Value::Null) {
    675                     assert!(
    676                         !parts.tags.iter().any(|tag| {
    677                             tag == &vec!["e".to_owned(), hex_64('e'), String::new()]
    678                         }),
    679                         "{id}: unexpected base event tag"
    680                     );
    681                 }
    682             }
    683             kind => panic!("{id}: unsupported vector kind {kind}"),
    684         }
    685     }
    686 }