//! Integration tests for the Bluesky channel using `wiremock` as a stand-in //! for `public.api.bsky.app`. use chrono::Utc; use kez_channels::bluesky::BlueskyChannel; use kez_channels::{Channel, ChannelError}; use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim}; use reqwest::Client; use serde_json::json; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn sign(subject: &str) -> SignedClaim { let secret = NostrSecret::generate(); let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); let subject = Identity::parse(subject).unwrap(); SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap() } fn channel_pointing_at(server: &MockServer) -> BlueskyChannel { let client = Client::builder() .user_agent("kez-channels-test/0.1") .build() .unwrap(); BlueskyChannel::with_base(client, server.uri()) } #[tokio::test] async fn verifies_compact_proof_in_post_text() { let server = MockServer::start().await; let signed = sign("bluesky:jason.bsky.social"); let compact = signed.to_compact().unwrap(); Mock::given(method("GET")) .and(path("/xrpc/app.bsky.feed.getAuthorFeed")) .and(query_param("actor", "jason.bsky.social")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [ { "post": { "record": { "text": "good morning" } } }, { "post": { "record": { "text": compact } } } ] }))) .mount(&server) .await; let channel = channel_pointing_at(&server); let identity = Identity::parse("bluesky:jason.bsky.social").unwrap(); let hit = channel.fetch_and_verify(&identity).await.unwrap(); assert_eq!(hit.proof, signed); } #[tokio::test] async fn verifies_markdown_fenced_proof_in_post() { let server = MockServer::start().await; let signed = sign("bluesky:jason.bsky.social"); let markdown = signed.to_markdown_proof().unwrap(); Mock::given(method("GET")) .and(path("/xrpc/app.bsky.feed.getAuthorFeed")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [{ "post": { "record": { "text": markdown } } }] }))) .mount(&server) .await; let channel = channel_pointing_at(&server); let identity = Identity::parse("bluesky:jason.bsky.social").unwrap(); let hit = channel.fetch_and_verify(&identity).await.unwrap(); assert_eq!(hit.proof, signed); } #[tokio::test] async fn rejects_proof_for_wrong_handle() { let server = MockServer::start().await; // Signed for mallory but posted on jason's feed. let signed = sign("bluesky:mallory.bsky.social"); let compact = signed.to_compact().unwrap(); Mock::given(method("GET")) .and(path("/xrpc/app.bsky.feed.getAuthorFeed")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [{ "post": { "record": { "text": compact } } }] }))) .mount(&server) .await; let channel = channel_pointing_at(&server); let identity = Identity::parse("bluesky:jason.bsky.social").unwrap(); let err = channel.fetch_and_verify(&identity).await.unwrap_err(); assert!( matches!(err, ChannelError::SubjectMismatch { .. }), "expected SubjectMismatch, got {err:?}" ); } #[tokio::test] async fn empty_feed_yields_not_found() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/xrpc/app.bsky.feed.getAuthorFeed")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [] }))) .mount(&server) .await; let channel = channel_pointing_at(&server); let identity = Identity::parse("bluesky:jason.bsky.social").unwrap(); let err = channel.fetch_and_verify(&identity).await.unwrap_err(); assert!(matches!(err, ChannelError::NotFound(_))); } #[tokio::test] async fn appview_error_status_is_unreachable() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/xrpc/app.bsky.feed.getAuthorFeed")) .respond_with(ResponseTemplate::new(503)) .mount(&server) .await; let channel = channel_pointing_at(&server); let identity = Identity::parse("bluesky:jason.bsky.social").unwrap(); let err = channel.fetch_and_verify(&identity).await.unwrap_err(); assert!(matches!(err, ChannelError::Unreachable(_))); }