tests.rs (11822B)
1 use super::*; 2 3 fn h(character: char) -> String { 4 crate::test_fixtures::fixture_public_key_hex(character) 5 } 6 7 fn top_event_tags() -> Vec<Vec<String>> { 8 vec![ 9 vec!["p".to_string(), h('b')], 10 vec!["q".to_string(), h('f')], 11 vec!["K".to_string(), "30402".to_string()], 12 vec!["P".to_string(), h('b')], 13 vec!["k".to_string(), "30402".to_string()], 14 vec!["E".to_string(), h('a'), String::new(), h('b')], 15 vec!["e".to_string(), h('a'), String::new(), h('b')], 16 ] 17 } 18 19 #[test] 20 fn projects_unordered_top_event_and_preserves_supplemental_tags() { 21 let tags = top_event_tags(); 22 let projection = 23 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10).expect("projection"); 24 assert!(matches!( 25 projection.position(), 26 RadrootsInboundNip22CommentPosition::TopLevelEvent { reference } 27 if reference.author_hint().is_some() 28 )); 29 assert_eq!(projection.raw_tags(), tags); 30 assert!(projection.diagnostics().is_empty()); 31 } 32 33 #[test] 34 fn projects_top_address_with_distinct_optional_relay_hints() { 35 let coordinate = format!("31922:{}:market", h('b')); 36 let tags = vec![ 37 vec![ 38 "A".to_string(), 39 coordinate.clone(), 40 "wss://root.example".to_string(), 41 ], 42 vec!["K".to_string(), "31922".to_string()], 43 vec![ 44 "P".to_string(), 45 h('b'), 46 "wss://participant.example".to_string(), 47 ], 48 vec!["a".to_string(), coordinate], 49 vec![ 50 "e".to_string(), 51 h('e'), 52 "wss://revision.example".to_string(), 53 ], 54 vec!["k".to_string(), "31922".to_string()], 55 vec!["p".to_string(), h('b')], 56 ]; 57 let projection = 58 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10).expect("projection"); 59 let RadrootsInboundNip22CommentPosition::TopLevelAddress { 60 reference, 61 current_revision, 62 } = projection.position() 63 else { 64 panic!("top-level address"); 65 }; 66 assert_eq!(current_revision.event_id().to_hex(), h('e')); 67 assert!(reference.relay().is_none()); 68 assert_eq!( 69 current_revision.relay().expect("relay").as_str(), 70 "wss://revision.example" 71 ); 72 } 73 74 #[test] 75 fn direct_address_revision_cardinality_precedes_parent_coordinate_parsing() { 76 let coordinate = format!("31922:{}:market", h('b')); 77 let tags = vec![ 78 vec!["A".to_string(), coordinate, String::new()], 79 vec!["K".to_string(), "31922".to_string()], 80 vec!["P".to_string(), h('b')], 81 vec![ 82 "a".to_string(), 83 format!("031922:{}:market", h('b')), 84 String::new(), 85 ], 86 vec!["k".to_string(), "31922".to_string()], 87 vec!["p".to_string(), h('b')], 88 ]; 89 90 assert_eq!( 91 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10).unwrap_err(), 92 RadrootsNip22CommentProjectionError::RevisionMissing { actual: 0 } 93 ); 94 } 95 96 #[test] 97 fn nested_parent_uses_author_hint_and_keeps_mentions() { 98 let mut tags = top_event_tags(); 99 tags[4][1] = KIND_COMMENT.to_string(); 100 tags[6] = vec![ 101 "e".to_string(), 102 h('c'), 103 "wss://parent.example".to_string(), 104 h('d'), 105 ]; 106 tags[0][1] = h('d'); 107 tags.push(vec!["p".to_string(), h('e')]); 108 let projection = 109 project_nip22_comment_parts(KIND_COMMENT, &tags, "Reply", 10).expect("projection"); 110 let RadrootsInboundNip22CommentPosition::Nested { parent } = projection.position() else { 111 panic!("nested"); 112 }; 113 assert_eq!(parent.event_id().to_hex(), h('c')); 114 assert_eq!(parent.author().pubkey().to_hex(), h('d')); 115 assert_eq!(projection.mentions().len(), 1); 116 assert_eq!(projection.mentions()[0].pubkey().to_hex(), h('e')); 117 } 118 119 #[test] 120 fn missing_parent_hint_with_multiple_participants_is_ambiguous() { 121 let mut tags = top_event_tags(); 122 tags[4][1] = KIND_COMMENT.to_string(); 123 tags[6] = vec!["e".to_string(), h('c')]; 124 tags.push(vec!["p".to_string(), h('e')]); 125 assert_eq!( 126 project_nip22_comment_parts(KIND_COMMENT, &tags, "Reply", 10).unwrap_err(), 127 RadrootsNip22CommentProjectionError::ParentAuthorAmbiguous 128 ); 129 } 130 131 #[test] 132 fn diagnoses_same_tag_advisory_values_in_stable_order() { 133 let mut tags = top_event_tags(); 134 tags[5] = vec![ 135 "E".to_string(), 136 h('a'), 137 "WSS://bad.example".to_string(), 138 "not-an-author".to_string(), 139 ]; 140 let raw_tag = tags[5].clone(); 141 let projection = 142 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10).expect("projection"); 143 assert_eq!( 144 projection 145 .diagnostics() 146 .iter() 147 .map(|diagnostic| ( 148 diagnostic.code(), 149 diagnostic.tag_index(), 150 diagnostic.raw_tag() 151 )) 152 .collect::<Vec<_>>(), 153 vec![ 154 ("comment_root_relay_ignored", 5, raw_tag.as_slice()), 155 ("comment_root_author_hint_ignored", 5, raw_tag.as_slice()), 156 ] 157 ); 158 } 159 160 #[test] 161 fn hard_gates_mixed_duplicate_and_inconsistent_authority() { 162 let mut tags = top_event_tags(); 163 tags.push(vec!["A".to_string(), format!("30402:{}:listing", h('b'))]); 164 assert_eq!( 165 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10).unwrap_err(), 166 RadrootsNip22CommentProjectionError::RootCardinality { actual: 2 } 167 ); 168 169 let mut tags = top_event_tags(); 170 tags.push(vec!["K".to_string(), "30402".to_string()]); 171 assert!(matches!( 172 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10), 173 Err(RadrootsNip22CommentProjectionError::RootKindCardinality { actual: 2 }) 174 )); 175 176 let mut tags = top_event_tags(); 177 tags[3][1] = h('c'); 178 assert!(matches!( 179 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10), 180 Err(RadrootsNip22CommentProjectionError::RootAuthorMismatch { .. }) 181 )); 182 } 183 184 #[test] 185 fn rejects_reserved_external_root_and_parent_authority_forms() { 186 let mut external_root = top_event_tags(); 187 external_root.push(vec![ 188 "I".to_string(), 189 "https://example.com/root".to_string(), 190 ]); 191 assert!(matches!( 192 project_nip22_comment_parts(KIND_COMMENT, &external_root, "Comment", 10), 193 Err(RadrootsNip22CommentProjectionError::RootFormUnsupported { .. }) 194 )); 195 196 let mut external_parent = top_event_tags(); 197 external_parent.push(vec![ 198 "i".to_string(), 199 "https://example.com/parent".to_string(), 200 ]); 201 assert!(matches!( 202 project_nip22_comment_parts(KIND_COMMENT, &external_parent, "Comment", 10), 203 Err(RadrootsNip22CommentProjectionError::ParentFormUnsupported { .. }) 204 )); 205 } 206 207 #[test] 208 fn hard_gates_noncanonical_kind_tokens_and_valid_hint_mismatches() { 209 for value in ["030402", "+30402"] { 210 let mut tags = top_event_tags(); 211 tags[2][1] = value.to_string(); 212 assert!(matches!( 213 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10), 214 Err(RadrootsNip22CommentProjectionError::RootKindUnsupported { .. }) 215 )); 216 } 217 218 let mut tags = top_event_tags(); 219 tags[5][3] = h('c'); 220 assert!(matches!( 221 project_nip22_comment_parts(KIND_COMMENT, &tags, "Comment", 10), 222 Err(RadrootsNip22CommentProjectionError::RootAuthorMismatch { tag_index: 5 }) 223 )); 224 } 225 226 #[test] 227 fn uses_unicode_white_space_for_blank_content() { 228 let tags = top_event_tags(); 229 project_nip22_comment_parts(KIND_COMMENT, &tags, "\u{001c}", 10) 230 .expect("U+001C is not Unicode White_Space"); 231 assert_eq!( 232 project_nip22_comment_parts(KIND_COMMENT, &tags, "\u{00a0}", 10).unwrap_err(), 233 RadrootsNip22CommentProjectionError::ContentMissing 234 ); 235 } 236 237 #[test] 238 fn enforces_inbound_structural_tag_limits_exactly() { 239 let mut exact_tag_count = top_event_tags(); 240 exact_tag_count.extend( 241 (exact_tag_count.len()..RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT) 242 .map(|_| vec!["x".to_string()]), 243 ); 244 project_nip22_comment_parts(KIND_COMMENT, &exact_tag_count, "Comment", 10) 245 .expect("exact tag-count limit"); 246 247 let mut overflow_tag_count = exact_tag_count; 248 overflow_tag_count.push(vec!["x".to_string()]); 249 assert_eq!( 250 project_nip22_comment_parts(KIND_COMMENT, &overflow_tag_count, "Comment", 10).unwrap_err(), 251 RadrootsNip22CommentProjectionError::TagCountExceeded { 252 max: RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT, 253 actual: RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT + 1, 254 } 255 ); 256 257 let mut exact_element_count = top_event_tags(); 258 let authority_element_count = exact_element_count.iter().map(Vec::len).sum::<usize>(); 259 let supplemental_element_count = 260 RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT - authority_element_count; 261 let supplemental_tag_count = RADROOTS_NIP22_COMMENT_TAG_MAX_COUNT - exact_element_count.len(); 262 let four_element_tags = supplemental_tag_count - 10; 263 exact_element_count.extend((0..four_element_tags).map(|_| { 264 vec![ 265 "x".to_string(), 266 "x".to_string(), 267 "x".to_string(), 268 "x".to_string(), 269 ] 270 })); 271 exact_element_count.extend((0..10).map(|_| { 272 vec![ 273 "x".to_string(), 274 "x".to_string(), 275 "x".to_string(), 276 "x".to_string(), 277 "x".to_string(), 278 ] 279 })); 280 assert_eq!( 281 exact_element_count.iter().map(Vec::len).sum::<usize>(), 282 supplemental_element_count + authority_element_count 283 ); 284 project_nip22_comment_parts(KIND_COMMENT, &exact_element_count, "Comment", 10) 285 .expect("exact total tag-element limit"); 286 287 let mut overflow_element_count = exact_element_count; 288 overflow_element_count[0].push("x".to_string()); 289 assert_eq!( 290 project_nip22_comment_parts(KIND_COMMENT, &overflow_element_count, "Comment", 10) 291 .unwrap_err(), 292 RadrootsNip22CommentProjectionError::TagElementCountExceeded { 293 max: RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT, 294 actual: RADROOTS_NIP22_COMMENT_TAG_TOTAL_ELEMENT_MAX_COUNT + 1, 295 } 296 ); 297 } 298 299 #[test] 300 fn enforces_inbound_aggregate_tag_budget_exactly() { 301 let mut tags = top_event_tags(); 302 let current = tags 303 .iter() 304 .flat_map(|tag| tag.iter()) 305 .map(String::len) 306 .sum::<usize>(); 307 let remaining = RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES - current; 308 while tags 309 .iter() 310 .flat_map(|tag| tag.iter()) 311 .map(String::len) 312 .sum::<usize>() 313 + RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES 314 <= RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES 315 { 316 tags.push(vec![ 317 String::new(), 318 "x".repeat(RADROOTS_NIP22_COMMENT_TAG_ELEMENT_MAX_BYTES), 319 ]); 320 } 321 let now = tags 322 .iter() 323 .flat_map(|tag| tag.iter()) 324 .map(String::len) 325 .sum::<usize>(); 326 tags.push(vec![String::new(), "x".repeat(remaining - (now - current))]); 327 validate_tag_and_wire_budgets(&tags, "Comment", 10).expect("exact aggregate tag budget"); 328 tags.last_mut().expect("last tag")[1].push('x'); 329 assert!(matches!( 330 validate_tag_and_wire_budgets(&tags, "Comment", 10), 331 Err(RadrootsNip22CommentProjectionError::TagBytesExceeded { max, actual }) 332 if max == RADROOTS_NIP22_COMMENT_TAG_TOTAL_MAX_BYTES && actual == max + 1 333 )); 334 } 335 336 #[test] 337 fn verified_projection_api_cannot_accept_an_unverified_envelope() { 338 fn project( 339 event: &RadrootsSignatureVerifiedEvent, 340 ) -> Result<RadrootsInboundNip22CommentProjection, RadrootsNip22CommentProjectionError> { 341 project_verified_nip22_comment_event(event) 342 } 343 let _ = project; 344 }