Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

test(lexicon): test schemas and resolution integration tests

lewis.moe 6f2abaaf 5bbf1fe1

verified
+661
+291
crates/tranquil-lexicon/src/test_schemas.rs
··· 1 + use crate::registry::LexiconRegistry; 2 + use crate::schema::LexiconDoc; 3 + 4 + pub(crate) fn test_registry() -> LexiconRegistry { 5 + let mut registry = LexiconRegistry::new(); 6 + all().into_iter().for_each(|doc| registry.register(doc)); 7 + registry 8 + } 9 + 10 + fn parse(json: serde_json::Value) -> LexiconDoc { 11 + serde_json::from_value(json).expect("invalid test schema JSON") 12 + } 13 + 14 + fn all() -> Vec<LexiconDoc> { 15 + [ 16 + basic_schema(), 17 + profile_schema(), 18 + with_ref_schema(), 19 + strong_ref_schema(), 20 + with_reply_schema(), 21 + images_schema(), 22 + external_schema(), 23 + with_gate_schema(), 24 + with_did_schema(), 25 + nullable_schema(), 26 + required_nullable_schema(), 27 + ] 28 + .into() 29 + } 30 + 31 + fn basic_schema() -> LexiconDoc { 32 + parse(serde_json::json!({ 33 + "lexicon": 1, 34 + "id": "com.test.basic", 35 + "defs": { 36 + "main": { 37 + "type": "record", 38 + "record": { 39 + "type": "object", 40 + "required": ["text", "createdAt"], 41 + "properties": { 42 + "text": {"type": "string", "maxLength": 100, "maxGraphemes": 50}, 43 + "createdAt": {"type": "string", "format": "datetime"}, 44 + "count": {"type": "integer", "minimum": 0, "maximum": 100}, 45 + "active": {"type": "boolean"}, 46 + "tags": { 47 + "type": "array", "maxLength": 3, 48 + "items": {"type": "string", "maxLength": 50} 49 + }, 50 + "langs": { 51 + "type": "array", "maxLength": 2, 52 + "items": {"type": "string", "format": "language"} 53 + } 54 + } 55 + } 56 + } 57 + } 58 + })) 59 + } 60 + 61 + fn profile_schema() -> LexiconDoc { 62 + parse(serde_json::json!({ 63 + "lexicon": 1, 64 + "id": "com.test.profile", 65 + "defs": { 66 + "main": { 67 + "type": "record", 68 + "record": { 69 + "type": "object", 70 + "properties": { 71 + "displayName": {"type": "string", "maxGraphemes": 10, "maxLength": 100}, 72 + "description": {"type": "string", "maxGraphemes": 50, "maxLength": 500}, 73 + "avatar": {"type": "blob", "accept": ["image/png", "image/jpeg"], "maxSize": 1000000} 74 + } 75 + } 76 + } 77 + } 78 + })) 79 + } 80 + 81 + fn with_ref_schema() -> LexiconDoc { 82 + parse(serde_json::json!({ 83 + "lexicon": 1, 84 + "id": "com.test.withref", 85 + "defs": { 86 + "main": { 87 + "type": "record", 88 + "record": { 89 + "type": "object", 90 + "required": ["subject", "createdAt"], 91 + "properties": { 92 + "subject": {"type": "ref", "ref": "com.test.strongref"}, 93 + "createdAt": {"type": "string", "format": "datetime"} 94 + } 95 + } 96 + } 97 + } 98 + })) 99 + } 100 + 101 + fn strong_ref_schema() -> LexiconDoc { 102 + parse(serde_json::json!({ 103 + "lexicon": 1, 104 + "id": "com.test.strongref", 105 + "defs": { 106 + "main": { 107 + "type": "object", 108 + "required": ["uri", "cid"], 109 + "properties": { 110 + "uri": {"type": "string", "format": "at-uri"}, 111 + "cid": {"type": "string", "format": "cid"} 112 + } 113 + } 114 + } 115 + })) 116 + } 117 + 118 + fn with_reply_schema() -> LexiconDoc { 119 + parse(serde_json::json!({ 120 + "lexicon": 1, 121 + "id": "com.test.withreply", 122 + "defs": { 123 + "main": { 124 + "type": "record", 125 + "record": { 126 + "type": "object", 127 + "required": ["text", "createdAt"], 128 + "properties": { 129 + "text": {"type": "string"}, 130 + "createdAt": {"type": "string", "format": "datetime"}, 131 + "reply": {"type": "ref", "ref": "#replyRef"}, 132 + "embed": { 133 + "type": "union", 134 + "refs": ["com.test.images", "com.test.external"] 135 + } 136 + } 137 + } 138 + }, 139 + "replyRef": { 140 + "type": "object", 141 + "required": ["root", "parent"], 142 + "properties": { 143 + "root": {"type": "ref", "ref": "com.test.strongref"}, 144 + "parent": {"type": "ref", "ref": "com.test.strongref"} 145 + } 146 + } 147 + } 148 + })) 149 + } 150 + 151 + fn images_schema() -> LexiconDoc { 152 + parse(serde_json::json!({ 153 + "lexicon": 1, 154 + "id": "com.test.images", 155 + "defs": { 156 + "main": { 157 + "type": "object", 158 + "required": ["images"], 159 + "properties": { 160 + "images": { 161 + "type": "array", "maxLength": 4, 162 + "items": {"type": "ref", "ref": "#image"} 163 + } 164 + } 165 + }, 166 + "image": { 167 + "type": "object", 168 + "required": ["image", "alt"], 169 + "properties": { 170 + "image": {"type": "blob", "accept": ["image/*"], "maxSize": 1000000}, 171 + "alt": {"type": "string"} 172 + } 173 + } 174 + } 175 + })) 176 + } 177 + 178 + fn external_schema() -> LexiconDoc { 179 + parse(serde_json::json!({ 180 + "lexicon": 1, 181 + "id": "com.test.external", 182 + "defs": { 183 + "main": { 184 + "type": "object", 185 + "required": ["external"], 186 + "properties": { 187 + "external": {"type": "ref", "ref": "#external"} 188 + } 189 + }, 190 + "external": { 191 + "type": "object", 192 + "required": ["uri", "title", "description"], 193 + "properties": { 194 + "uri": {"type": "string", "format": "uri"}, 195 + "title": {"type": "string"}, 196 + "description": {"type": "string"} 197 + } 198 + } 199 + } 200 + })) 201 + } 202 + 203 + fn with_gate_schema() -> LexiconDoc { 204 + parse(serde_json::json!({ 205 + "lexicon": 1, 206 + "id": "com.test.withgate", 207 + "defs": { 208 + "main": { 209 + "type": "record", 210 + "record": { 211 + "type": "object", 212 + "required": ["post", "createdAt"], 213 + "properties": { 214 + "post": {"type": "string", "format": "at-uri"}, 215 + "createdAt": {"type": "string", "format": "datetime"}, 216 + "rules": { 217 + "type": "array", "maxLength": 5, 218 + "items": {"type": "union", "refs": ["#disableRule"]} 219 + } 220 + } 221 + } 222 + }, 223 + "disableRule": { 224 + "type": "object", 225 + "properties": {} 226 + } 227 + } 228 + })) 229 + } 230 + 231 + fn with_did_schema() -> LexiconDoc { 232 + parse(serde_json::json!({ 233 + "lexicon": 1, 234 + "id": "com.test.withdid", 235 + "defs": { 236 + "main": { 237 + "type": "record", 238 + "record": { 239 + "type": "object", 240 + "required": ["subject", "createdAt"], 241 + "properties": { 242 + "subject": {"type": "string", "format": "did"}, 243 + "createdAt": {"type": "string", "format": "datetime"} 244 + } 245 + } 246 + } 247 + } 248 + })) 249 + } 250 + 251 + fn nullable_schema() -> LexiconDoc { 252 + parse(serde_json::json!({ 253 + "lexicon": 1, 254 + "id": "com.test.nullable", 255 + "defs": { 256 + "main": { 257 + "type": "record", 258 + "record": { 259 + "type": "object", 260 + "required": ["name"], 261 + "nullable": ["value"], 262 + "properties": { 263 + "name": {"type": "string"}, 264 + "value": {"type": "string"} 265 + } 266 + } 267 + } 268 + } 269 + })) 270 + } 271 + 272 + fn required_nullable_schema() -> LexiconDoc { 273 + parse(serde_json::json!({ 274 + "lexicon": 1, 275 + "id": "com.test.requirednullable", 276 + "defs": { 277 + "main": { 278 + "type": "record", 279 + "record": { 280 + "type": "object", 281 + "required": ["name", "value"], 282 + "nullable": ["value"], 283 + "properties": { 284 + "name": {"type": "string"}, 285 + "value": {"type": "string"} 286 + } 287 + } 288 + } 289 + } 290 + })) 291 + }
+370
crates/tranquil-lexicon/tests/resolve_integration.rs
··· 1 + #![cfg(feature = "resolve")] 2 + 3 + use std::time::Duration; 4 + 5 + use serde_json::json; 6 + use tranquil_lexicon::{ 7 + ResolveError, fetch_schema_from_pds, resolve_lexicon_from_did, resolve_pds_endpoint, 8 + }; 9 + use wiremock::matchers::{method, path, query_param}; 10 + use wiremock::{Mock, MockServer, ResponseTemplate}; 11 + 12 + fn mock_did_document(did: &str, pds_endpoint: &str) -> serde_json::Value { 13 + json!({ 14 + "@context": ["https://www.w3.org/ns/did/v1"], 15 + "id": did, 16 + "service": [{ 17 + "id": "#atproto_pds", 18 + "type": "AtprotoPersonalDataServer", 19 + "serviceEndpoint": pds_endpoint 20 + }] 21 + }) 22 + } 23 + 24 + fn mock_lexicon_schema(nsid: &str) -> serde_json::Value { 25 + json!({ 26 + "lexicon": 1, 27 + "id": nsid, 28 + "defs": { 29 + "main": { 30 + "type": "record", 31 + "key": "tid", 32 + "record": { 33 + "type": "object", 34 + "required": ["text", "createdAt"], 35 + "properties": { 36 + "text": { 37 + "type": "string", 38 + "maxLength": 1000, 39 + "maxGraphemes": 100 40 + }, 41 + "createdAt": { 42 + "type": "string", 43 + "format": "datetime" 44 + } 45 + } 46 + } 47 + } 48 + } 49 + }) 50 + } 51 + 52 + fn mock_get_record_response(nsid: &str) -> serde_json::Value { 53 + json!({ 54 + "uri": format!("at://did:plc:test123/com.atproto.lexicon.schema/{}", nsid), 55 + "cid": "bafyreiabcdef", 56 + "value": mock_lexicon_schema(nsid) 57 + }) 58 + } 59 + 60 + #[tokio::test] 61 + async fn test_resolve_pds_endpoint_from_plc() { 62 + let plc_server = MockServer::start().await; 63 + let did = "did:plc:testabcdef123"; 64 + 65 + Mock::given(method("GET")) 66 + .and(path(format!("/{}", did))) 67 + .respond_with( 68 + ResponseTemplate::new(200) 69 + .set_body_json(mock_did_document(did, "https://pds.example.com")), 70 + ) 71 + .mount(&plc_server) 72 + .await; 73 + 74 + let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri())) 75 + .await 76 + .unwrap(); 77 + assert_eq!(endpoint, "https://pds.example.com"); 78 + } 79 + 80 + #[tokio::test] 81 + async fn test_resolve_pds_endpoint_no_pds_service() { 82 + let plc_server = MockServer::start().await; 83 + let did = "did:plc:nopds123"; 84 + 85 + Mock::given(method("GET")) 86 + .and(path(format!("/{}", did))) 87 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 88 + "id": did, 89 + "service": [{ 90 + "type": "AtprotoLabeler", 91 + "serviceEndpoint": "https://labeler.example.com" 92 + }] 93 + }))) 94 + .mount(&plc_server) 95 + .await; 96 + 97 + let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await; 98 + assert!(matches!(result, Err(ResolveError::NoPdsEndpoint { .. }))); 99 + } 100 + 101 + #[tokio::test] 102 + async fn test_resolve_pds_endpoint_plc_not_found() { 103 + let plc_server = MockServer::start().await; 104 + let did = "did:plc:missing123"; 105 + 106 + Mock::given(method("GET")) 107 + .and(path(format!("/{}", did))) 108 + .respond_with(ResponseTemplate::new(404).set_body_string("not found")) 109 + .mount(&plc_server) 110 + .await; 111 + 112 + let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await; 113 + assert!(result.is_err()); 114 + } 115 + 116 + #[tokio::test] 117 + async fn test_resolve_pds_endpoint_unsupported_did_method() { 118 + let result = resolve_pds_endpoint("did:key:z6MkTest", None).await; 119 + assert!(matches!(result, Err(ResolveError::DidResolution { .. }))); 120 + } 121 + 122 + #[tokio::test] 123 + async fn test_resolve_pds_endpoint_multiple_services_picks_pds() { 124 + let plc_server = MockServer::start().await; 125 + let did = "did:plc:multi123"; 126 + 127 + Mock::given(method("GET")) 128 + .and(path(format!("/{}", did))) 129 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 130 + "id": did, 131 + "service": [ 132 + { 133 + "type": "AtprotoLabeler", 134 + "serviceEndpoint": "https://labeler.example.com" 135 + }, 136 + { 137 + "type": "BskyNotificationService", 138 + "serviceEndpoint": "https://notify.example.com" 139 + }, 140 + { 141 + "type": "AtprotoPersonalDataServer", 142 + "serviceEndpoint": "https://pds.example.com" 143 + } 144 + ] 145 + }))) 146 + .mount(&plc_server) 147 + .await; 148 + 149 + let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri())) 150 + .await 151 + .unwrap(); 152 + assert_eq!(endpoint, "https://pds.example.com"); 153 + } 154 + 155 + #[tokio::test] 156 + async fn test_fetch_schema_from_pds_success() { 157 + let pds_server = MockServer::start().await; 158 + let did = "did:plc:schemahost123"; 159 + let nsid = "com.example.custom.post"; 160 + 161 + Mock::given(method("GET")) 162 + .and(path("/xrpc/com.atproto.repo.getRecord")) 163 + .and(query_param("repo", did)) 164 + .and(query_param("collection", "com.atproto.lexicon.schema")) 165 + .and(query_param("rkey", nsid)) 166 + .respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid))) 167 + .mount(&pds_server) 168 + .await; 169 + 170 + let doc = fetch_schema_from_pds(&pds_server.uri(), did, nsid) 171 + .await 172 + .unwrap(); 173 + assert_eq!(doc.id, nsid); 174 + assert_eq!(doc.lexicon, 1); 175 + assert!(doc.defs.contains_key("main")); 176 + } 177 + 178 + #[tokio::test] 179 + async fn test_fetch_schema_missing_value_field() { 180 + let pds_server = MockServer::start().await; 181 + let did = "did:plc:test123"; 182 + let nsid = "com.example.missing"; 183 + 184 + Mock::given(method("GET")) 185 + .and(path("/xrpc/com.atproto.repo.getRecord")) 186 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 187 + "uri": "at://did:plc:test123/com.atproto.lexicon.schema/com.example.missing", 188 + "cid": "bafyreiabcdef" 189 + }))) 190 + .mount(&pds_server) 191 + .await; 192 + 193 + let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await; 194 + assert!(matches!(result, Err(ResolveError::SchemaFetch { .. }))); 195 + } 196 + 197 + #[tokio::test] 198 + async fn test_fetch_schema_invalid_lexicon_json() { 199 + let pds_server = MockServer::start().await; 200 + let did = "did:plc:test123"; 201 + let nsid = "com.example.bad"; 202 + 203 + Mock::given(method("GET")) 204 + .and(path("/xrpc/com.atproto.repo.getRecord")) 205 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 206 + "uri": "at://test", 207 + "cid": "bafyreiabcdef", 208 + "value": { 209 + "not_a_lexicon": true 210 + } 211 + }))) 212 + .mount(&pds_server) 213 + .await; 214 + 215 + let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await; 216 + assert!(matches!(result, Err(ResolveError::InvalidSchema(_)))); 217 + } 218 + 219 + #[tokio::test] 220 + async fn test_full_chain_plc_to_schema() { 221 + let plc_server = MockServer::start().await; 222 + let pds_server = MockServer::start().await; 223 + let did = "did:plc:fullchain123"; 224 + let nsid = "com.example.social.post"; 225 + 226 + Mock::given(method("GET")) 227 + .and(path(format!("/{}", did))) 228 + .respond_with( 229 + ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())), 230 + ) 231 + .mount(&plc_server) 232 + .await; 233 + 234 + Mock::given(method("GET")) 235 + .and(path("/xrpc/com.atproto.repo.getRecord")) 236 + .and(query_param("repo", did)) 237 + .and(query_param("collection", "com.atproto.lexicon.schema")) 238 + .and(query_param("rkey", nsid)) 239 + .respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid))) 240 + .mount(&pds_server) 241 + .await; 242 + 243 + let doc = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())) 244 + .await 245 + .unwrap(); 246 + assert_eq!(doc.id, nsid); 247 + assert_eq!(doc.lexicon, 1); 248 + } 249 + 250 + #[tokio::test] 251 + async fn test_full_chain_schema_id_mismatch_rejected() { 252 + let plc_server = MockServer::start().await; 253 + let pds_server = MockServer::start().await; 254 + let did = "did:plc:mismatch123"; 255 + let nsid = "com.example.requested.type"; 256 + 257 + Mock::given(method("GET")) 258 + .and(path(format!("/{}", did))) 259 + .respond_with( 260 + ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())), 261 + ) 262 + .mount(&plc_server) 263 + .await; 264 + 265 + Mock::given(method("GET")) 266 + .and(path("/xrpc/com.atproto.repo.getRecord")) 267 + .respond_with( 268 + ResponseTemplate::new(200) 269 + .set_body_json(mock_get_record_response("com.example.different.type")), 270 + ) 271 + .mount(&pds_server) 272 + .await; 273 + 274 + let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await; 275 + assert!(matches!(result, Err(ResolveError::InvalidSchema(_)))); 276 + } 277 + 278 + #[tokio::test] 279 + async fn test_full_chain_bad_lexicon_version_rejected() { 280 + let plc_server = MockServer::start().await; 281 + let pds_server = MockServer::start().await; 282 + let did = "did:plc:badver123"; 283 + let nsid = "com.example.versioned.type"; 284 + 285 + Mock::given(method("GET")) 286 + .and(path(format!("/{}", did))) 287 + .respond_with( 288 + ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())), 289 + ) 290 + .mount(&plc_server) 291 + .await; 292 + 293 + Mock::given(method("GET")) 294 + .and(path("/xrpc/com.atproto.repo.getRecord")) 295 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 296 + "uri": "at://test", 297 + "cid": "bafyreiabcdef", 298 + "value": { 299 + "lexicon": 2, 300 + "id": nsid, 301 + "defs": {} 302 + } 303 + }))) 304 + .mount(&pds_server) 305 + .await; 306 + 307 + let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await; 308 + assert!(matches!(result, Err(ResolveError::InvalidSchema(_)))); 309 + } 310 + 311 + #[tokio::test] 312 + async fn test_pds_trailing_slash_handled() { 313 + let pds_server = MockServer::start().await; 314 + let did = "did:plc:slash123"; 315 + let nsid = "com.example.slash.test"; 316 + 317 + Mock::given(method("GET")) 318 + .and(path("/xrpc/com.atproto.repo.getRecord")) 319 + .and(query_param("repo", did)) 320 + .and(query_param("rkey", nsid)) 321 + .respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid))) 322 + .mount(&pds_server) 323 + .await; 324 + 325 + let pds_url_with_slash = format!("{}/", pds_server.uri()); 326 + let doc = fetch_schema_from_pds(&pds_url_with_slash, did, nsid) 327 + .await 328 + .unwrap(); 329 + assert_eq!(doc.id, nsid); 330 + } 331 + 332 + #[tokio::test] 333 + async fn test_fetch_schema_error_status_gives_meaningful_error() { 334 + let pds_server = MockServer::start().await; 335 + let did = "did:plc:test123"; 336 + let nsid = "com.example.notfound"; 337 + 338 + Mock::given(method("GET")) 339 + .and(path("/xrpc/com.atproto.repo.getRecord")) 340 + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ 341 + "error": "RecordNotFound", 342 + "message": "record not found" 343 + }))) 344 + .mount(&pds_server) 345 + .await; 346 + 347 + let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await; 348 + let err = result.unwrap_err(); 349 + let err_msg = err.to_string(); 350 + assert!( 351 + !err_msg.contains("missing 'value' field"), 352 + "a 400 response should report the HTTP status, not a parse error. got: {}", 353 + err_msg 354 + ); 355 + } 356 + 357 + #[tokio::test] 358 + async fn test_plc_server_timeout() { 359 + let plc_server = MockServer::start().await; 360 + let did = "did:plc:timeout123"; 361 + 362 + Mock::given(method("GET")) 363 + .and(path(format!("/{}", did))) 364 + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30))) 365 + .mount(&plc_server) 366 + .await; 367 + 368 + let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await; 369 + assert!(result.is_err()); 370 + }