An ATProto Lexicon validator for Gleam.
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}