An ATProto Lexicon validator for Gleam.
at main 9.4 kB view raw
1import gleeunit 2import gleeunit/should 3import honk/validation/formats 4 5pub fn main() { 6 gleeunit.main() 7} 8 9// ========== DATETIME TESTS ========== 10 11pub fn datetime_valid_test() { 12 formats.is_valid_rfc3339_datetime("2024-01-01T12:00:00Z") |> should.be_true 13 formats.is_valid_rfc3339_datetime("2024-01-01T12:00:00+00:00") 14 |> should.be_true 15 formats.is_valid_rfc3339_datetime("2024-01-01T12:00:00.123Z") 16 |> should.be_true 17 formats.is_valid_rfc3339_datetime("2024-12-31T23:59:59-05:00") 18 |> should.be_true 19} 20 21pub fn datetime_reject_negative_zero_timezone_test() { 22 // Should reject -00:00 per ISO-8601 (must use +00:00) 23 formats.is_valid_rfc3339_datetime("2024-01-01T12:00:00-00:00") 24 |> should.be_false 25} 26 27pub fn datetime_max_length_test() { 28 // 65 characters - should fail (max is 64) 29 let long_datetime = 30 "2024-01-01T12:00:00.12345678901234567890123456789012345678901234Z" 31 formats.is_valid_rfc3339_datetime(long_datetime) |> should.be_false 32} 33 34pub fn datetime_invalid_date_test() { 35 // February 30th doesn't exist - actual parsing should catch this 36 formats.is_valid_rfc3339_datetime("2024-02-30T12:00:00Z") |> should.be_false 37} 38 39pub fn datetime_empty_string_test() { 40 formats.is_valid_rfc3339_datetime("") |> should.be_false 41} 42 43// ========== HANDLE TESTS ========== 44 45pub fn handle_valid_test() { 46 formats.is_valid_handle("user.bsky.social") |> should.be_true 47 formats.is_valid_handle("alice.example.com") |> should.be_true 48 formats.is_valid_handle("test.co.uk") |> should.be_true 49} 50 51pub fn handle_reject_disallowed_tlds_test() { 52 formats.is_valid_handle("user.local") |> should.be_false 53 formats.is_valid_handle("server.arpa") |> should.be_false 54 formats.is_valid_handle("example.invalid") |> should.be_false 55 formats.is_valid_handle("app.localhost") |> should.be_false 56 formats.is_valid_handle("service.internal") |> should.be_false 57 formats.is_valid_handle("demo.example") |> should.be_false 58 formats.is_valid_handle("site.onion") |> should.be_false 59 formats.is_valid_handle("custom.alt") |> should.be_false 60} 61 62pub fn handle_max_length_test() { 63 // 254 characters - should fail (max is 253) 64 // Create: "a123456789" (10) + ".b123456789" (11) repeated = 254 total 65 let segment = "a123456789b123456789c123456789d123456789e123456789" 66 let long_handle = 67 segment 68 <> "." 69 <> segment 70 <> "." 71 <> segment 72 <> "." 73 <> segment 74 <> "." 75 <> segment 76 <> ".com" 77 // This creates exactly 254 chars 78 formats.is_valid_handle(long_handle) |> should.be_false 79} 80 81pub fn handle_requires_dot_test() { 82 // Handle must have at least one dot (be a domain) 83 formats.is_valid_handle("nodot") |> should.be_false 84} 85 86// ========== DID TESTS ========== 87 88pub fn did_valid_test() { 89 formats.is_valid_did("did:plc:z72i7hdynmk6r22z27h6tvur") |> should.be_true 90 formats.is_valid_did("did:web:example.com") |> should.be_true 91 formats.is_valid_did( 92 "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 93 ) 94 |> should.be_true 95} 96 97pub fn did_max_length_test() { 98 // Create a DID longer than 2048 chars - should fail 99 let long_did = "did:example:" <> string_repeat("a", 2040) 100 formats.is_valid_did(long_did) |> should.be_false 101} 102 103pub fn did_invalid_ending_test() { 104 // DIDs should not end with % 105 formats.is_valid_did("did:example:foo%") |> should.be_false 106} 107 108pub fn did_empty_test() { 109 formats.is_valid_did("") |> should.be_false 110} 111 112// ========== URI TESTS ========== 113 114pub fn uri_valid_test() { 115 formats.is_valid_uri("https://example.com") |> should.be_true 116 formats.is_valid_uri("http://example.com/path") |> should.be_true 117 formats.is_valid_uri("ftp://files.example.com") |> should.be_true 118} 119 120pub fn uri_max_length_test() { 121 // Create a URI longer than 8192 chars - should fail 122 let long_uri = "https://example.com/" <> string_repeat("a", 8180) 123 formats.is_valid_uri(long_uri) |> should.be_false 124} 125 126pub fn uri_lowercase_scheme_test() { 127 // Scheme must be lowercase 128 formats.is_valid_uri("HTTP://example.com") |> should.be_false 129 formats.is_valid_uri("HTTPS://example.com") |> should.be_false 130} 131 132pub fn uri_empty_test() { 133 formats.is_valid_uri("") |> should.be_false 134} 135 136// ========== AT-URI TESTS ========== 137 138pub fn at_uri_valid_test() { 139 formats.is_valid_at_uri("at://did:plc:z72i7hdynmk6r22z27h6tvur") 140 |> should.be_true 141 formats.is_valid_at_uri( 142 "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post", 143 ) 144 |> should.be_true 145 formats.is_valid_at_uri( 146 "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jui7kd54zh2y", 147 ) 148 |> should.be_true 149 formats.is_valid_at_uri("at://user.bsky.social/app.bsky.feed.post") 150 |> should.be_true 151} 152 153pub fn at_uri_max_length_test() { 154 // Create an AT-URI longer than 8192 chars - should fail 155 let long_path = string_repeat("a", 8180) 156 let long_at_uri = "at://did:plc:test/" <> long_path 157 formats.is_valid_at_uri(long_at_uri) |> should.be_false 158} 159 160pub fn at_uri_invalid_collection_test() { 161 // Collection must be a valid NSID (needs 3 segments) 162 formats.is_valid_at_uri("at://did:plc:z72i7hdynmk6r22z27h6tvur/invalid") 163 |> should.be_false 164} 165 166pub fn at_uri_empty_test() { 167 formats.is_valid_at_uri("") |> should.be_false 168} 169 170// ========== TID TESTS ========== 171 172pub fn tid_valid_test() { 173 formats.is_valid_tid("3jui7kd54zh2y") |> should.be_true 174 formats.is_valid_tid("2zzzzzzzzzzzy") |> should.be_true 175} 176 177pub fn tid_invalid_first_char_test() { 178 // First char must be [234567abcdefghij], not k-z 179 formats.is_valid_tid("kzzzzzzzzzzzz") |> should.be_false 180 formats.is_valid_tid("lzzzzzzzzzzzz") |> should.be_false 181 formats.is_valid_tid("zzzzzzzzzzzzz") |> should.be_false 182} 183 184pub fn tid_wrong_length_test() { 185 formats.is_valid_tid("3jui7kd54zh2") |> should.be_false 186 formats.is_valid_tid("3jui7kd54zh2yy") |> should.be_false 187} 188 189// ========== RECORD-KEY TESTS ========== 190 191pub fn record_key_valid_test() { 192 formats.is_valid_record_key("3jui7kd54zh2y") |> should.be_true 193 formats.is_valid_record_key("my-custom-key") |> should.be_true 194 formats.is_valid_record_key("key_with_underscores") |> should.be_true 195 formats.is_valid_record_key("key:with:colons") |> should.be_true 196} 197 198pub fn record_key_reject_dot_test() { 199 formats.is_valid_record_key(".") |> should.be_false 200} 201 202pub fn record_key_reject_dotdot_test() { 203 formats.is_valid_record_key("..") |> should.be_false 204} 205 206pub fn record_key_max_length_test() { 207 // 513 characters - should fail (max is 512) 208 let long_key = string_repeat("a", 513) 209 formats.is_valid_record_key(long_key) |> should.be_false 210} 211 212pub fn record_key_empty_test() { 213 formats.is_valid_record_key("") |> should.be_false 214} 215 216// ========== CID TESTS ========== 217 218pub fn cid_valid_test() { 219 // CIDv1 examples (base32, base58) 220 formats.is_valid_cid( 221 "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", 222 ) 223 |> should.be_true 224 formats.is_valid_cid( 225 "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", 226 ) 227 |> should.be_true 228 formats.is_valid_cid("QmQg1v4o9xdT3Q1R8tNK3z9ZkRmg7FbQfZ1J2Z3K4M5N6P") 229 |> should.be_true 230} 231 232pub fn cid_reject_qmb_prefix_test() { 233 // CIDv0 starting with "Qmb" not allowed per atproto spec 234 formats.is_valid_cid("QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR") 235 |> should.be_false 236} 237 238pub fn cid_min_length_test() { 239 // 7 characters - should fail (min is 8) 240 formats.is_valid_cid("abc1234") |> should.be_false 241} 242 243pub fn cid_max_length_test() { 244 // 257 characters - should fail (max is 256) 245 let long_cid = string_repeat("a", 257) 246 formats.is_valid_cid(long_cid) |> should.be_false 247} 248 249pub fn cid_invalid_chars_test() { 250 // Contains invalid characters 251 formats.is_valid_cid("bafybei@invalid!") |> should.be_false 252 formats.is_valid_cid("bafy bei space") |> should.be_false 253} 254 255pub fn cid_empty_test() { 256 formats.is_valid_cid("") |> should.be_false 257} 258 259// ========== RAW CID TESTS ========== 260 261// Test valid raw CID (bafkrei prefix = CIDv1 + raw multicodec 0x55) 262pub fn valid_raw_cid_test() { 263 formats.is_valid_raw_cid( 264 "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", 265 ) 266 |> should.be_true 267} 268 269// Test dag-cbor CID rejected (bafyrei prefix = CIDv1 + dag-cbor multicodec 0x71) 270pub fn invalid_raw_cid_dag_cbor_test() { 271 formats.is_valid_raw_cid( 272 "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 273 ) 274 |> should.be_false 275} 276 277// Test CIDv0 rejected for raw CID 278pub fn invalid_raw_cid_v0_test() { 279 formats.is_valid_raw_cid("QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR") 280 |> should.be_false 281} 282 283// Test invalid CID rejected 284pub fn invalid_raw_cid_garbage_test() { 285 formats.is_valid_raw_cid("not-a-cid") 286 |> should.be_false 287} 288 289// ========== LANGUAGE TESTS ========== 290 291pub fn language_valid_test() { 292 formats.is_valid_language_tag("en") |> should.be_true 293 formats.is_valid_language_tag("en-US") |> should.be_true 294 formats.is_valid_language_tag("zh-Hans-CN") |> should.be_true 295 formats.is_valid_language_tag("i-enochian") |> should.be_true 296} 297 298pub fn language_max_length_test() { 299 // 129 characters - should fail (max is 128) 300 let long_tag = "en-" <> string_repeat("a", 126) 301 formats.is_valid_language_tag(long_tag) |> should.be_false 302} 303 304pub fn language_empty_test() { 305 formats.is_valid_language_tag("") |> should.be_false 306} 307 308// ========== HELPER FUNCTIONS ========== 309 310fn string_repeat(s: String, n: Int) -> String { 311 case n <= 0 { 312 True -> "" 313 False -> s <> string_repeat(s, n - 1) 314 } 315}