diff --git a/crates/tranquil-lexicon/src/test_schemas.rs b/crates/tranquil-lexicon/src/test_schemas.rs new file mode 100644 index 0000000..c589d91 --- /dev/null +++ b/crates/tranquil-lexicon/src/test_schemas.rs @@ -0,0 +1,291 @@ +use crate::registry::LexiconRegistry; +use crate::schema::LexiconDoc; + +pub(crate) fn test_registry() -> LexiconRegistry { + let mut registry = LexiconRegistry::new(); + all().into_iter().for_each(|doc| registry.register(doc)); + registry +} + +fn parse(json: serde_json::Value) -> LexiconDoc { + serde_json::from_value(json).expect("invalid test schema JSON") +} + +fn all() -> Vec { + [ + basic_schema(), + profile_schema(), + with_ref_schema(), + strong_ref_schema(), + with_reply_schema(), + images_schema(), + external_schema(), + with_gate_schema(), + with_did_schema(), + nullable_schema(), + required_nullable_schema(), + ] + .into() +} + +fn basic_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.basic", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["text", "createdAt"], + "properties": { + "text": {"type": "string", "maxLength": 100, "maxGraphemes": 50}, + "createdAt": {"type": "string", "format": "datetime"}, + "count": {"type": "integer", "minimum": 0, "maximum": 100}, + "active": {"type": "boolean"}, + "tags": { + "type": "array", "maxLength": 3, + "items": {"type": "string", "maxLength": 50} + }, + "langs": { + "type": "array", "maxLength": 2, + "items": {"type": "string", "format": "language"} + } + } + } + } + } + })) +} + +fn profile_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.profile", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "properties": { + "displayName": {"type": "string", "maxGraphemes": 10, "maxLength": 100}, + "description": {"type": "string", "maxGraphemes": 50, "maxLength": 500}, + "avatar": {"type": "blob", "accept": ["image/png", "image/jpeg"], "maxSize": 1000000} + } + } + } + } + })) +} + +fn with_ref_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.withref", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": {"type": "ref", "ref": "com.test.strongref"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } + })) +} + +fn strong_ref_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.strongref", + "defs": { + "main": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": {"type": "string", "format": "at-uri"}, + "cid": {"type": "string", "format": "cid"} + } + } + } + })) +} + +fn with_reply_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.withreply", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["text", "createdAt"], + "properties": { + "text": {"type": "string"}, + "createdAt": {"type": "string", "format": "datetime"}, + "reply": {"type": "ref", "ref": "#replyRef"}, + "embed": { + "type": "union", + "refs": ["com.test.images", "com.test.external"] + } + } + } + }, + "replyRef": { + "type": "object", + "required": ["root", "parent"], + "properties": { + "root": {"type": "ref", "ref": "com.test.strongref"}, + "parent": {"type": "ref", "ref": "com.test.strongref"} + } + } + } + })) +} + +fn images_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.images", + "defs": { + "main": { + "type": "object", + "required": ["images"], + "properties": { + "images": { + "type": "array", "maxLength": 4, + "items": {"type": "ref", "ref": "#image"} + } + } + }, + "image": { + "type": "object", + "required": ["image", "alt"], + "properties": { + "image": {"type": "blob", "accept": ["image/*"], "maxSize": 1000000}, + "alt": {"type": "string"} + } + } + } + })) +} + +fn external_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.external", + "defs": { + "main": { + "type": "object", + "required": ["external"], + "properties": { + "external": {"type": "ref", "ref": "#external"} + } + }, + "external": { + "type": "object", + "required": ["uri", "title", "description"], + "properties": { + "uri": {"type": "string", "format": "uri"}, + "title": {"type": "string"}, + "description": {"type": "string"} + } + } + } + })) +} + +fn with_gate_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.withgate", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["post", "createdAt"], + "properties": { + "post": {"type": "string", "format": "at-uri"}, + "createdAt": {"type": "string", "format": "datetime"}, + "rules": { + "type": "array", "maxLength": 5, + "items": {"type": "union", "refs": ["#disableRule"]} + } + } + } + }, + "disableRule": { + "type": "object", + "properties": {} + } + } + })) +} + +fn with_did_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.withdid", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": {"type": "string", "format": "did"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } + })) +} + +fn nullable_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.nullable", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["name"], + "nullable": ["value"], + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"} + } + } + } + } + })) +} + +fn required_nullable_schema() -> LexiconDoc { + parse(serde_json::json!({ + "lexicon": 1, + "id": "com.test.requirednullable", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "required": ["name", "value"], + "nullable": ["value"], + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"} + } + } + } + } + })) +} diff --git a/crates/tranquil-lexicon/tests/resolve_integration.rs b/crates/tranquil-lexicon/tests/resolve_integration.rs new file mode 100644 index 0000000..f9e0518 --- /dev/null +++ b/crates/tranquil-lexicon/tests/resolve_integration.rs @@ -0,0 +1,370 @@ +#![cfg(feature = "resolve")] + +use std::time::Duration; + +use serde_json::json; +use tranquil_lexicon::{ + ResolveError, fetch_schema_from_pds, resolve_lexicon_from_did, resolve_pds_endpoint, +}; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn mock_did_document(did: &str, pds_endpoint: &str) -> serde_json::Value { + json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": pds_endpoint + }] + }) +} + +fn mock_lexicon_schema(nsid: &str) -> serde_json::Value { + json!({ + "lexicon": 1, + "id": nsid, + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["text", "createdAt"], + "properties": { + "text": { + "type": "string", + "maxLength": 1000, + "maxGraphemes": 100 + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }) +} + +fn mock_get_record_response(nsid: &str) -> serde_json::Value { + json!({ + "uri": format!("at://did:plc:test123/com.atproto.lexicon.schema/{}", nsid), + "cid": "bafyreiabcdef", + "value": mock_lexicon_schema(nsid) + }) +} + +#[tokio::test] +async fn test_resolve_pds_endpoint_from_plc() { + let plc_server = MockServer::start().await; + let did = "did:plc:testabcdef123"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(mock_did_document(did, "https://pds.example.com")), + ) + .mount(&plc_server) + .await; + + let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri())) + .await + .unwrap(); + assert_eq!(endpoint, "https://pds.example.com"); +} + +#[tokio::test] +async fn test_resolve_pds_endpoint_no_pds_service() { + let plc_server = MockServer::start().await; + let did = "did:plc:nopds123"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": did, + "service": [{ + "type": "AtprotoLabeler", + "serviceEndpoint": "https://labeler.example.com" + }] + }))) + .mount(&plc_server) + .await; + + let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await; + assert!(matches!(result, Err(ResolveError::NoPdsEndpoint { .. }))); +} + +#[tokio::test] +async fn test_resolve_pds_endpoint_plc_not_found() { + let plc_server = MockServer::start().await; + let did = "did:plc:missing123"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with(ResponseTemplate::new(404).set_body_string("not found")) + .mount(&plc_server) + .await; + + let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_resolve_pds_endpoint_unsupported_did_method() { + let result = resolve_pds_endpoint("did:key:z6MkTest", None).await; + assert!(matches!(result, Err(ResolveError::DidResolution { .. }))); +} + +#[tokio::test] +async fn test_resolve_pds_endpoint_multiple_services_picks_pds() { + let plc_server = MockServer::start().await; + let did = "did:plc:multi123"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": did, + "service": [ + { + "type": "AtprotoLabeler", + "serviceEndpoint": "https://labeler.example.com" + }, + { + "type": "BskyNotificationService", + "serviceEndpoint": "https://notify.example.com" + }, + { + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://pds.example.com" + } + ] + }))) + .mount(&plc_server) + .await; + + let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri())) + .await + .unwrap(); + assert_eq!(endpoint, "https://pds.example.com"); +} + +#[tokio::test] +async fn test_fetch_schema_from_pds_success() { + let pds_server = MockServer::start().await; + let did = "did:plc:schemahost123"; + let nsid = "com.example.custom.post"; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .and(query_param("repo", did)) + .and(query_param("collection", "com.atproto.lexicon.schema")) + .and(query_param("rkey", nsid)) + .respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid))) + .mount(&pds_server) + .await; + + let doc = fetch_schema_from_pds(&pds_server.uri(), did, nsid) + .await + .unwrap(); + assert_eq!(doc.id, nsid); + assert_eq!(doc.lexicon, 1); + assert!(doc.defs.contains_key("main")); +} + +#[tokio::test] +async fn test_fetch_schema_missing_value_field() { + let pds_server = MockServer::start().await; + let did = "did:plc:test123"; + let nsid = "com.example.missing"; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": "at://did:plc:test123/com.atproto.lexicon.schema/com.example.missing", + "cid": "bafyreiabcdef" + }))) + .mount(&pds_server) + .await; + + let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await; + assert!(matches!(result, Err(ResolveError::SchemaFetch { .. }))); +} + +#[tokio::test] +async fn test_fetch_schema_invalid_lexicon_json() { + let pds_server = MockServer::start().await; + let did = "did:plc:test123"; + let nsid = "com.example.bad"; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": "at://test", + "cid": "bafyreiabcdef", + "value": { + "not_a_lexicon": true + } + }))) + .mount(&pds_server) + .await; + + let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await; + assert!(matches!(result, Err(ResolveError::InvalidSchema(_)))); +} + +#[tokio::test] +async fn test_full_chain_plc_to_schema() { + let plc_server = MockServer::start().await; + let pds_server = MockServer::start().await; + let did = "did:plc:fullchain123"; + let nsid = "com.example.social.post"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with( + ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())), + ) + .mount(&plc_server) + .await; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .and(query_param("repo", did)) + .and(query_param("collection", "com.atproto.lexicon.schema")) + .and(query_param("rkey", nsid)) + .respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid))) + .mount(&pds_server) + .await; + + let doc = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())) + .await + .unwrap(); + assert_eq!(doc.id, nsid); + assert_eq!(doc.lexicon, 1); +} + +#[tokio::test] +async fn test_full_chain_schema_id_mismatch_rejected() { + let plc_server = MockServer::start().await; + let pds_server = MockServer::start().await; + let did = "did:plc:mismatch123"; + let nsid = "com.example.requested.type"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with( + ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())), + ) + .mount(&plc_server) + .await; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(mock_get_record_response("com.example.different.type")), + ) + .mount(&pds_server) + .await; + + let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await; + assert!(matches!(result, Err(ResolveError::InvalidSchema(_)))); +} + +#[tokio::test] +async fn test_full_chain_bad_lexicon_version_rejected() { + let plc_server = MockServer::start().await; + let pds_server = MockServer::start().await; + let did = "did:plc:badver123"; + let nsid = "com.example.versioned.type"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with( + ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())), + ) + .mount(&plc_server) + .await; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": "at://test", + "cid": "bafyreiabcdef", + "value": { + "lexicon": 2, + "id": nsid, + "defs": {} + } + }))) + .mount(&pds_server) + .await; + + let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await; + assert!(matches!(result, Err(ResolveError::InvalidSchema(_)))); +} + +#[tokio::test] +async fn test_pds_trailing_slash_handled() { + let pds_server = MockServer::start().await; + let did = "did:plc:slash123"; + let nsid = "com.example.slash.test"; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .and(query_param("repo", did)) + .and(query_param("rkey", nsid)) + .respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid))) + .mount(&pds_server) + .await; + + let pds_url_with_slash = format!("{}/", pds_server.uri()); + let doc = fetch_schema_from_pds(&pds_url_with_slash, did, nsid) + .await + .unwrap(); + assert_eq!(doc.id, nsid); +} + +#[tokio::test] +async fn test_fetch_schema_error_status_gives_meaningful_error() { + let pds_server = MockServer::start().await; + let did = "did:plc:test123"; + let nsid = "com.example.notfound"; + + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "RecordNotFound", + "message": "record not found" + }))) + .mount(&pds_server) + .await; + + let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await; + let err = result.unwrap_err(); + let err_msg = err.to_string(); + assert!( + !err_msg.contains("missing 'value' field"), + "a 400 response should report the HTTP status, not a parse error. got: {}", + err_msg + ); +} + +#[tokio::test] +async fn test_plc_server_timeout() { + let plc_server = MockServer::start().await; + let did = "did:plc:timeout123"; + + Mock::given(method("GET")) + .and(path(format!("/{}", did))) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30))) + .mount(&plc_server) + .await; + + let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await; + assert!(result.is_err()); +}