From afb7ad3d7bd10dcec32e2090cbf64f30db0c0368 Mon Sep 17 00:00:00 2001 From: aumetra Date: Sat, 11 Nov 2023 22:40:43 +0100 Subject: [PATCH] move tests into own files --- Cargo.lock | 8 + crates/kitsune-activitypub/Cargo.toml | 9 + crates/kitsune-activitypub/src/deliverer.rs | 4 - crates/kitsune-activitypub/src/fetcher.rs | 648 +----------------- .../tests/fetcher/basic.rs | 178 +++++ .../tests/fetcher/filter.rs | 99 +++ .../tests/fetcher/handle.rs | 37 + .../tests/fetcher/infinite.rs | 93 +++ .../tests/fetcher/origin.rs | 94 +++ .../tests/fetcher/webfinger.rs | 135 ++++ crates/kitsune-util/Cargo.toml | 1 + crates/kitsune-util/src/lib.rs | 3 + crates/kitsune-util/src/macros.rs | 2 +- crates/kitsune-webfinger/Cargo.toml | 8 +- crates/kitsune-webfinger/src/lib.rs | 1 + 15 files changed, 666 insertions(+), 654 deletions(-) create mode 100644 crates/kitsune-activitypub/tests/fetcher/basic.rs create mode 100644 crates/kitsune-activitypub/tests/fetcher/filter.rs create mode 100644 crates/kitsune-activitypub/tests/fetcher/handle.rs create mode 100644 crates/kitsune-activitypub/tests/fetcher/infinite.rs create mode 100644 crates/kitsune-activitypub/tests/fetcher/origin.rs create mode 100644 crates/kitsune-activitypub/tests/fetcher/webfinger.rs diff --git a/Cargo.lock b/Cargo.lock index 38024f223..bd8ea2620 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2828,25 +2828,32 @@ dependencies = [ "futures-util", "headers", "http", + "hyper", "iso8601-timestamp", "kitsune-cache", + "kitsune-config", "kitsune-db", "kitsune-embed", "kitsune-http-client", "kitsune-http-signatures", "kitsune-language", "kitsune-search", + "kitsune-test", "kitsune-type", "kitsune-util", "mime", "mime_guess", + "pretty_assertions", "pulldown-cmark", "rsa", "scoped-futures", "serde", + "serial_test", "sha2", "simd-json", "speedy-uuid", + "tokio", + "tower", "tracing", "typed-builder", "url", @@ -3254,6 +3261,7 @@ version = "0.0.1-pre.4" dependencies = [ "ammonia", "kitsune-type", + "tokio", ] [[package]] diff --git a/crates/kitsune-activitypub/Cargo.toml b/crates/kitsune-activitypub/Cargo.toml index 2fa73a0cd..a7d0296c4 100644 --- a/crates/kitsune-activitypub/Cargo.toml +++ b/crates/kitsune-activitypub/Cargo.toml @@ -39,3 +39,12 @@ url = "2.4.1" [target.'cfg(not(target_env = "msvc"))'.dependencies] sha2 = { version = "0.10.8", features = ["asm"] } + +[dev-dependencies] +hyper = "0.14.27" +kitsune-config = { path = "../kitsune-config" } +kitsune-test = { path = "../kitsune-test" } +pretty_assertions = "1.4.0" +serial_test = "2.0.0" +tokio = { version = "1.34.0", features = ["macros"] } +tower = { version = "0.4.13", default-features = false, features = ["util"] } diff --git a/crates/kitsune-activitypub/src/deliverer.rs b/crates/kitsune-activitypub/src/deliverer.rs index 67c7b1e4b..7ad93f1d4 100644 --- a/crates/kitsune-activitypub/src/deliverer.rs +++ b/crates/kitsune-activitypub/src/deliverer.rs @@ -28,10 +28,6 @@ pub struct Deliverer { impl Deliverer { /// Deliver the activity to an inbox - /// - /// # Panics - /// - /// - Panics in case the inbox URL isn't actually a valid URL #[instrument(skip_all, fields(%inbox_url, activity_url = %activity.id))] #[autometrics(track_concurrency)] pub async fn deliver( diff --git a/crates/kitsune-activitypub/src/fetcher.rs b/crates/kitsune-activitypub/src/fetcher.rs index 0c1a1d739..d53a29566 100644 --- a/crates/kitsune-activitypub/src/fetcher.rs +++ b/crates/kitsune-activitypub/src/fetcher.rs @@ -1,11 +1,11 @@ -use super::process_attachments; use crate::{ - activitypub::{process_new_object, ProcessNewObject}, consts::USER_AGENT, error::{ApiError, Error, Result}, + process_attachments, process_new_object, service::federation_filter::FederationFilterService, util::timestamp_to_uuid, webfinger::Webfinger, + ProcessNewObject, }; use async_recursion::async_recursion; use autometrics::autometrics; @@ -451,647 +451,3 @@ impl Fetcher { .expect("[Bug] Highest level fetch returned a `None`") } } - -#[cfg(test)] -mod test { - use super::MAX_FETCH_DEPTH; - use crate::{ - activitypub::Fetcher, - error::{ApiError, Error}, - service::federation_filter::FederationFilterService, - webfinger::Webfinger, - }; - use diesel::{QueryDsl, SelectableHelper}; - use diesel_async::RunQueryDsl; - use http::{header::CONTENT_TYPE, uri::PathAndQuery}; - use hyper::{Body, Request, Response, StatusCode, Uri}; - use iso8601_timestamp::Timestamp; - use kitsune_cache::NoopCache; - use kitsune_config::instance::FederationFilterConfiguration; - use kitsune_db::{ - model::{account::Account, media_attachment::MediaAttachment}, - schema::{accounts, media_attachments}, - }; - use kitsune_http_client::Client; - use kitsune_search::NoopSearchService; - use kitsune_test::{build_ap_response, database_test}; - use kitsune_type::{ - ap::{ - actor::{Actor, ActorType, PublicKey}, - ap_context, AttributedToField, Object, ObjectType, PUBLIC_IDENTIFIER, - }, - webfinger::{Link, Resource}, - }; - use pretty_assertions::assert_eq; - use scoped_futures::ScopedFutureExt; - use std::{ - convert::Infallible, - sync::{ - atomic::{AtomicU32, Ordering}, - Arc, - }, - }; - use tower::service_fn; - - #[tokio::test] - #[serial_test::serial] - async fn fetch_actor() { - database_test(|db_pool| async move { - let client = Client::builder().service(service_fn(handle)); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - let user = fetcher - .fetch_actor("https://corteximplant.com/users/0x0".into()) - .await - .expect("Fetch actor"); - - assert_eq!(user.username, "0x0"); - assert_eq!(user.domain, "corteximplant.com"); - assert_eq!(user.url, "https://corteximplant.com/users/0x0"); - assert_eq!( - user.inbox_url, - Some("https://corteximplant.com/users/0x0/inbox".into()) - ); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn fetch_actor_with_custom_acct() { - database_test(|db_pool| async move { - let mut jrd_base = include_bytes!("../../../../test-fixtures/0x0_jrd.json").to_owned(); - let jrd_body = simd_json::to_string(&Resource { - subject: "acct:0x0@joinkitsune.org".into(), - ..simd_json::from_slice(&mut jrd_base).unwrap() - }) - .unwrap(); - let client = service_fn(move |req: Request<_>| { - let jrd_body = jrd_body.clone(); - async move { - match ( - req.uri().authority().unwrap().as_str(), - req.uri().path_and_query().unwrap().as_str(), - ) { - ( - "corteximplant.com", - "/.well-known/webfinger?resource=acct:0x0@corteximplant.com", - ) - | ( - "joinkitsune.org", - "/.well-known/webfinger?resource=acct:0x0@joinkitsune.org", - ) => Ok::<_, Infallible>(Response::new(Body::from(jrd_body))), - _ => handle(req).await, - } - } - }); - let client = Client::builder().service(client); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - let user = fetcher - .fetch_actor("https://corteximplant.com/users/0x0".into()) - .await - .expect("Fetch actor"); - - assert_eq!(user.username, "0x0"); - assert_eq!(user.domain, "joinkitsune.org"); - assert_eq!(user.url, "https://corteximplant.com/users/0x0"); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn ignore_fake_webfinger_acct() { - database_test(|db_pool| async move { - let link = Link { - rel: "self".to_owned(), - r#type: Some("application/activity+json".to_owned()), - href: Some("https://social.whitehouse.gov/users/POTUS".to_owned()), - }; - let jrd = Resource { - subject: "acct:POTUS@whitehouse.gov".into(), - aliases: Vec::new(), - links: vec![link.clone()], - }; - let client = service_fn(move |req: Request<_>| { - let link = link.clone(); - let jrd = jrd.clone(); - async move { - match ( - req.uri().authority().unwrap().as_str(), - req.uri().path_and_query().unwrap().as_str(), - ) { - ( - "corteximplant.com", - "/.well-known/webfinger?resource=acct:0x0@corteximplant.com", - ) => { - let fake_jrd = Resource { - links: vec![Link { - href: Some("https://corteximplant.com/users/0x0".to_owned()), - ..link - }], - ..jrd - }; - let body = simd_json::to_string(&fake_jrd).unwrap(); - Ok::<_, Infallible>(Response::new(Body::from(body))) - } - ( - "whitehouse.gov", - "/.well-known/webfinger?resource=acct:POTUS@whitehouse.gov", - ) => { - let body = simd_json::to_string(&jrd).unwrap(); - Ok(Response::new(Body::from(body))) - } - _ => handle(req).await, - } - } - }); - let client = Client::builder().service(client); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - let user = fetcher - .fetch_actor("https://corteximplant.com/users/0x0".into()) - .await - .expect("Fetch actor"); - - assert_eq!(user.username, "0x0"); - assert_eq!(user.domain, "corteximplant.com"); - assert_eq!(user.url, "https://corteximplant.com/users/0x0"); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn fetch_note() { - database_test(|db_pool| async move { - let client = Client::builder().service(service_fn(handle)); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool.clone()) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - let note = fetcher - .fetch_object("https://corteximplant.com/@0x0/109501674056556919") - .await - .expect("Fetch note"); - assert_eq!( - note.url, - "https://corteximplant.com/users/0x0/statuses/109501674056556919" - ); - - let author = db_pool - .with_connection(|db_conn| { - accounts::table - .find(note.account_id) - .select(Account::as_select()) - .get_result::(db_conn) - .scoped() - }) - .await - .expect("Get author"); - - assert_eq!(author.username, "0x0"); - assert_eq!(author.url, "https://corteximplant.com/users/0x0"); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn fetch_infinitely_long_reply_chain() { - database_test(|db_pool| async move { - let request_counter = Arc::new(AtomicU32::new(0)); - let client = service_fn(move |req: Request<_>| { - let count = request_counter.fetch_add(1, Ordering::SeqCst); - assert!(MAX_FETCH_DEPTH * 3 >= count); - - async move { - let author_id = "https://example.com/users/1".to_owned(); - let author = Actor { - context: ap_context(), - id: author_id.clone(), - r#type: ActorType::Person, - name: None, - preferred_username: "InfiniteNotes".into(), - subject: None, - icon: None, - image: None, - manually_approves_followers: false, - public_key: PublicKey { - id: format!("{author_id}#main-key"), - owner: author_id, - // A 512-bit RSA public key generated as a placeholder - public_key_pem: "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK1v4oRbdBPi8oRL0M1GQqSWtkb9uE2L\nJCAgZK9KiVECNYvEASYor7DeMEu6BxR1E4XI2DlGkigClWXFhQDhos0CAwEAAQ==\n-----END PUBLIC KEY-----\n".into(), - }, - endpoints: None, - featured: None, - inbox: "https://example.com/inbox".into(), - outbox: None, - followers: None, - following: None, - published: Timestamp::UNIX_EPOCH, - }; - - if let Some(note_id) = req.uri().path_and_query().unwrap().as_str().strip_prefix("/notes/") { - let note_id = note_id.parse::().unwrap(); - let note = Object { - context: ap_context(), - id: format!("https://example.com/notes/{note_id}"), - r#type: ObjectType::Note, - attributed_to: AttributedToField::Url(author.id.clone()), - in_reply_to: Some(format!("https://example.com/notes/{}", note_id + 1)), - name: None, - summary: None, - content: String::new(), - media_type: None, - attachment: Vec::new(), - tag: Vec::new(), - sensitive: false, - published: Timestamp::UNIX_EPOCH, - to: vec![PUBLIC_IDENTIFIER.into()], - cc: Vec::new(), - }; - - let body = simd_json::to_string(¬e).unwrap(); - - Ok::<_, Infallible>(build_ap_response(body)) - } else if req.uri().path_and_query().unwrap() == Uri::try_from(&author.id).unwrap().path_and_query().unwrap() { - let body = simd_json::to_string(&author).unwrap(); - - Ok::<_, Infallible>(build_ap_response(body)) - } else { - handle(req).await - } - } - }); - let client = Client::builder().service(client); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - assert!(fetcher - .fetch_object("https://example.com/notes/0") - .await - .is_ok()); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn check_ap_id_authority() { - database_test(|db_pool| async move { - let builder = Fetcher::builder() - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())); - - let client = service_fn(|req: Request<_>| { - assert_ne!(req.uri().host(), Some("corteximplant.com")); - handle(req) - }); - let client = Client::builder().service(client); - let fetcher = builder - .clone() - .client(client.clone()) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .build(); - - // The mock HTTP client ensures that the fetcher doesn't access the correct server - // so this should return error - let _ = fetcher - .fetch_actor("https://example.com/users/0x0".into()) - .await - .unwrap_err(); - - let client = service_fn(|req: Request<_>| { - // Let `fetch_object` fetch `attributedTo` - if req.uri().path_and_query().map(PathAndQuery::as_str) != Some("/users/0x0") { - assert_ne!(req.uri().host(), Some("corteximplant.com")); - } - - handle(req) - }); - let client = Client::builder().service(client); - let fetcher = builder - .clone() - .client(client.clone()) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .build(); - - let _ = fetcher - .fetch_object("https://example.com/@0x0/109501674056556919") - .await - .unwrap_err(); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn check_ap_content_type() { - database_test(|db_pool| async move { - let client = service_fn(|req: Request<_>| async { - let mut res = handle(req).await.unwrap(); - res.headers_mut().remove(CONTENT_TYPE); - Ok::<_, Infallible>(res) - }); - let client = Client::builder().service(client); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - assert!(matches!( - fetcher - .fetch_object("https://corteximplant.com/users/0x0") - .await, - Err(Error::Api(ApiError::BadRequest)) - )); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn federation_allow() { - database_test(|db_pool| async move { - let builder = Fetcher::builder() - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Allow { - domains: vec!["corteximplant.com".into()], - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())); - - let client = service_fn( - #[allow(unreachable_code)] // https://github.com/rust-lang/rust/issues/67227 - |_: Request<_>| async { - panic!("Requested a denied domain") as Result, Infallible> - }, - ); - let client = Client::builder().service(client); - let fetcher = builder - .clone() - .client(client.clone()) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .build(); - - assert!(matches!( - fetcher.fetch_object("https://example.com/fakeobject").await, - Err(Error::Api(ApiError::Unauthorised)) - )); - assert!(matches!( - fetcher - .fetch_object("https://other.badstuff.com/otherfake") - .await, - Err(Error::Api(ApiError::Unauthorised)) - )); - - let client = Client::builder().service(service_fn(handle)); - let fetcher = builder - .clone() - .client(client.clone()) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .build(); - - assert!(matches!( - fetcher - .fetch_object("https://corteximplant.com/@0x0/109501674056556919") - .await, - Ok(..) - )); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn federation_deny() { - database_test(|db_pool| async move { - let client = service_fn( - #[allow(unreachable_code)] - |_: Request<_>| async { - panic!("Requested a denied domain") as Result, Infallible> - }, - ); - let client = Client::builder().service(client); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: vec!["example.com".into(), "*.badstuff.com".into()], - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - assert!(matches!( - fetcher.fetch_object("https://example.com/fakeobject").await, - Err(Error::Api(ApiError::Unauthorised)) - )); - assert!(matches!( - fetcher - .fetch_object("https://other.badstuff.com/otherfake") - .await, - Err(Error::Api(ApiError::Unauthorised)) - )); - }) - .await; - } - - #[tokio::test] - #[serial_test::serial] - async fn fetch_emoji() { - database_test(|db_pool| async move { - let client = Client::builder().service(service_fn(handle)); - - let fetcher = Fetcher::builder() - .client(client.clone()) - .db_pool(db_pool.clone()) - .embed_client(None) - .federation_filter( - FederationFilterService::new(&FederationFilterConfiguration::Deny { - domains: Vec::new(), - }) - .unwrap(), - ) - .search_backend(NoopSearchService) - .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) - .post_cache(Arc::new(NoopCache.into())) - .user_cache(Arc::new(NoopCache.into())) - .build(); - - let emoji = fetcher - .fetch_emoji("https://corteximplant.com/emojis/7952") - .await - .expect("Fetch emoji"); - assert_eq!(emoji.shortcode, "Blobhaj"); - assert_eq!(emoji.domain, Some(String::from("corteximplant.com"))); - - let media_attachment = db_pool - .with_connection(|db_conn| { - media_attachments::table - .find(emoji.media_attachment_id) - .select(MediaAttachment::as_select()) - .get_result::(db_conn) - .scoped() - }) - .await - .expect("Get media attachment"); - - assert_eq!( - media_attachment.remote_url, - Some(String::from( - "https://corteximplant.com/system/custom_emojis/images/000/007/952/original/33b7f12bd094b815.png" - ))); - }) - .await; - } - - async fn handle(req: Request) -> Result, Infallible> { - match req.uri().path_and_query().unwrap().as_str() { - "/users/0x0" => { - let body = include_str!("../../../../test-fixtures/0x0_actor.json"); - Ok::<_, Infallible>(build_ap_response(body)) - } - "/@0x0/109501674056556919" => { - let body = include_str!( - "../../../../test-fixtures/corteximplant.com_109501674056556919.json" - ); - Ok::<_, Infallible>(build_ap_response(body)) - } - "/users/0x0/statuses/109501659207519785" => { - let body = include_str!( - "../../../../test-fixtures/corteximplant.com_109501659207519785.json" - ); - Ok::<_, Infallible>(build_ap_response(body)) - } - "/emojis/7952" => { - let body = - include_str!("../../../../test-fixtures/corteximplant.com_emoji_7952.json"); - Ok::<_, Infallible>(build_ap_response(body)) - } - "/emojis/8933" => { - let body = - include_str!("../../../../test-fixtures/corteximplant.com_emoji_8933.json"); - Ok::<_, Infallible>(build_ap_response(body)) - } - "/.well-known/webfinger?resource=acct:0x0@corteximplant.com" => { - let body = include_str!("../../../../test-fixtures/0x0_jrd.json"); - Ok::<_, Infallible>(Response::new(Body::from(body))) - } - path if path.starts_with("/.well-known/webfinger?") => Ok::<_, Infallible>( - Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::empty()) - .unwrap(), - ), - path => panic!("HTTP client hit unexpected route: {path}"), - } - } -} diff --git a/crates/kitsune-activitypub/tests/fetcher/basic.rs b/crates/kitsune-activitypub/tests/fetcher/basic.rs new file mode 100644 index 000000000..5b00df58f --- /dev/null +++ b/crates/kitsune-activitypub/tests/fetcher/basic.rs @@ -0,0 +1,178 @@ +use self::handle::handle; +use super::MAX_FETCH_DEPTH; +use crate::{ + error::{ApiError, Error}, + service::federation_filter::FederationFilterService, + webfinger::Webfinger, + Fetcher, +}; +use diesel::{QueryDsl, SelectableHelper}; +use diesel_async::RunQueryDsl; +use http::{header::CONTENT_TYPE, uri::PathAndQuery}; +use hyper::{Body, Request, Response, StatusCode, Uri}; +use iso8601_timestamp::Timestamp; +use kitsune_cache::NoopCache; +use kitsune_config::instance::FederationFilterConfiguration; +use kitsune_db::{ + model::{account::Account, media_attachment::MediaAttachment}, + schema::{accounts, media_attachments}, +}; +use kitsune_http_client::Client; +use kitsune_search::NoopSearchService; +use kitsune_test::{build_ap_response, database_test}; +use kitsune_type::{ + ap::{ + actor::{Actor, ActorType, PublicKey}, + ap_context, AttributedToField, Object, ObjectType, PUBLIC_IDENTIFIER, + }, + webfinger::{Link, Resource}, +}; +use pretty_assertions::assert_eq; +use scoped_futures::ScopedFutureExt; +use std::{ + convert::Infallible, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, + }, +}; +use tower::service_fn; + +mod handle; + +#[tokio::test] +#[serial_test::serial] +async fn fetch_actor() { + database_test(|db_pool| async move { + let client = Client::builder().service(service_fn(handle)); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + let user = fetcher + .fetch_actor("https://corteximplant.com/users/0x0".into()) + .await + .expect("Fetch actor"); + + assert_eq!(user.username, "0x0"); + assert_eq!(user.domain, "corteximplant.com"); + assert_eq!(user.url, "https://corteximplant.com/users/0x0"); + assert_eq!( + user.inbox_url, + Some("https://corteximplant.com/users/0x0/inbox".into()) + ); + }) + .await; +} + +#[tokio::test] +#[serial_test::serial] +async fn fetch_emoji() { + database_test(|db_pool| async move { + let client = Client::builder().service(service_fn(handle)); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool.clone()) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + let emoji = fetcher + .fetch_emoji("https://corteximplant.com/emojis/7952") + .await + .expect("Fetch emoji"); + + assert_eq!(emoji.shortcode, "Blobhaj"); + assert_eq!(emoji.domain, Some(String::from("corteximplant.com"))); + + let media_attachment = db_pool + .with_connection(|db_conn| { + media_attachments::table + .find(emoji.media_attachment_id) + .select(MediaAttachment::as_select()) + .get_result::(db_conn) + .scoped() + }) + .await + .expect("Get media attachment"); + + assert_eq!( + media_attachment.remote_url, + Some(String::from( + "https://corteximplant.com/system/custom_emojis/images/000/007/952/original/33b7f12bd094b815.png" + )) + ); + }) + .await; +} + +#[tokio::test] +#[serial_test::serial] +async fn fetch_note() { + database_test(|db_pool| async move { + let client = Client::builder().service(service_fn(handle)); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool.clone()) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + let note = fetcher + .fetch_object("https://corteximplant.com/@0x0/109501674056556919") + .await + .expect("Fetch note"); + assert_eq!( + note.url, + "https://corteximplant.com/users/0x0/statuses/109501674056556919" + ); + + let author = db_pool + .with_connection(|db_conn| { + accounts::table + .find(note.account_id) + .select(Account::as_select()) + .get_result::(db_conn) + .scoped() + }) + .await + .expect("Get author"); + + assert_eq!(author.username, "0x0"); + assert_eq!(author.url, "https://corteximplant.com/users/0x0"); + }) + .await; +} diff --git a/crates/kitsune-activitypub/tests/fetcher/filter.rs b/crates/kitsune-activitypub/tests/fetcher/filter.rs new file mode 100644 index 000000000..e8e638548 --- /dev/null +++ b/crates/kitsune-activitypub/tests/fetcher/filter.rs @@ -0,0 +1,99 @@ +#[tokio::test] +#[serial_test::serial] +async fn federation_allow() { + database_test(|db_pool| async move { + let builder = Fetcher::builder() + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Allow { + domains: vec!["corteximplant.com".into()], + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())); + + let client = service_fn( + #[allow(unreachable_code)] // https://github.com/rust-lang/rust/issues/67227 + |_: Request<_>| async { + panic!("Requested a denied domain") as Result, Infallible> + }, + ); + let client = Client::builder().service(client); + let fetcher = builder + .clone() + .client(client.clone()) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .build(); + + assert!(matches!( + fetcher.fetch_object("https://example.com/fakeobject").await, + Err(Error::Api(ApiError::Unauthorised)) + )); + assert!(matches!( + fetcher + .fetch_object("https://other.badstuff.com/otherfake") + .await, + Err(Error::Api(ApiError::Unauthorised)) + )); + + let client = Client::builder().service(service_fn(handle)); + let fetcher = builder + .clone() + .client(client.clone()) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .build(); + + assert!(matches!( + fetcher + .fetch_object("https://corteximplant.com/@0x0/109501674056556919") + .await, + Ok(..) + )); + }) + .await; +} + +#[tokio::test] +#[serial_test::serial] +async fn federation_deny() { + database_test(|db_pool| async move { + let client = service_fn( + #[allow(unreachable_code)] + |_: Request<_>| async { + panic!("Requested a denied domain") as Result, Infallible> + }, + ); + let client = Client::builder().service(client); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: vec!["example.com".into(), "*.badstuff.com".into()], + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + assert!(matches!( + fetcher.fetch_object("https://example.com/fakeobject").await, + Err(Error::Api(ApiError::Unauthorised)) + )); + assert!(matches!( + fetcher + .fetch_object("https://other.badstuff.com/otherfake") + .await, + Err(Error::Api(ApiError::Unauthorised)) + )); + }) + .await; +} diff --git a/crates/kitsune-activitypub/tests/fetcher/handle.rs b/crates/kitsune-activitypub/tests/fetcher/handle.rs new file mode 100644 index 000000000..8c88ba452 --- /dev/null +++ b/crates/kitsune-activitypub/tests/fetcher/handle.rs @@ -0,0 +1,37 @@ +pub async fn handle(req: Request) -> Result, Infallible> { + match req.uri().path_and_query().unwrap().as_str() { + "/users/0x0" => { + let body = include_str!("../../../test-fixtures/0x0_actor.json"); + Ok::<_, Infallible>(build_ap_response(body)) + } + "/@0x0/109501674056556919" => { + let body = + include_str!("../../../test-fixtures/corteximplant.com_109501674056556919.json"); + Ok::<_, Infallible>(build_ap_response(body)) + } + "/users/0x0/statuses/109501659207519785" => { + let body = + include_str!("../../../test-fixtures/corteximplant.com_109501659207519785.json"); + Ok::<_, Infallible>(build_ap_response(body)) + } + "/emojis/7952" => { + let body = include_str!("../../../test-fixtures/corteximplant.com_emoji_7952.json"); + Ok::<_, Infallible>(build_ap_response(body)) + } + "/emojis/8933" => { + let body = include_str!("../../../test-fixtures/corteximplant.com_emoji_8933.json"); + Ok::<_, Infallible>(build_ap_response(body)) + } + "/.well-known/webfinger?resource=acct:0x0@corteximplant.com" => { + let body = include_str!("../../../test-fixtures/0x0_jrd.json"); + Ok::<_, Infallible>(Response::new(Body::from(body))) + } + path if path.starts_with("/.well-known/webfinger?") => Ok::<_, Infallible>( + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .unwrap(), + ), + path => panic!("HTTP client hit unexpected route: {path}"), + } +} diff --git a/crates/kitsune-activitypub/tests/fetcher/infinite.rs b/crates/kitsune-activitypub/tests/fetcher/infinite.rs new file mode 100644 index 000000000..89c66edaf --- /dev/null +++ b/crates/kitsune-activitypub/tests/fetcher/infinite.rs @@ -0,0 +1,93 @@ +#[tokio::test] +#[serial_test::serial] +async fn fetch_infinitely_long_reply_chain() { + database_test(|db_pool| async move { + let request_counter = Arc::new(AtomicU32::new(0)); + let client = service_fn(move |req: Request<_>| { + let count = request_counter.fetch_add(1, Ordering::SeqCst); + assert!(MAX_FETCH_DEPTH * 3 >= count); + + async move { + let author_id = "https://example.com/users/1".to_owned(); + let author = Actor { + context: ap_context(), + id: author_id.clone(), + r#type: ActorType::Person, + name: None, + preferred_username: "InfiniteNotes".into(), + subject: None, + icon: None, + image: None, + manually_approves_followers: false, + public_key: PublicKey { + id: format!("{author_id}#main-key"), + owner: author_id, + // A 512-bit RSA public key generated as a placeholder + public_key_pem: "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK1v4oRbdBPi8oRL0M1GQqSWtkb9uE2L\nJCAgZK9KiVECNYvEASYor7DeMEu6BxR1E4XI2DlGkigClWXFhQDhos0CAwEAAQ==\n-----END PUBLIC KEY-----\n".into(), + }, + endpoints: None, + featured: None, + inbox: "https://example.com/inbox".into(), + outbox: None, + followers: None, + following: None, + published: Timestamp::UNIX_EPOCH, + }; + + if let Some(note_id) = req.uri().path_and_query().unwrap().as_str().strip_prefix("/notes/") { + let note_id = note_id.parse::().unwrap(); + let note = Object { + context: ap_context(), + id: format!("https://example.com/notes/{note_id}"), + r#type: ObjectType::Note, + attributed_to: AttributedToField::Url(author.id.clone()), + in_reply_to: Some(format!("https://example.com/notes/{}", note_id + 1)), + name: None, + summary: None, + content: String::new(), + media_type: None, + attachment: Vec::new(), + tag: Vec::new(), + sensitive: false, + published: Timestamp::UNIX_EPOCH, + to: vec![PUBLIC_IDENTIFIER.into()], + cc: Vec::new(), + }; + + let body = simd_json::to_string(¬e).unwrap(); + + Ok::<_, Infallible>(build_ap_response(body)) + } else if req.uri().path_and_query().unwrap() == Uri::try_from(&author.id).unwrap().path_and_query().unwrap() { + let body = simd_json::to_string(&author).unwrap(); + + Ok::<_, Infallible>(build_ap_response(body)) + } else { + handle(req).await + } + } + }); + let client = Client::builder().service(client); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + assert!(fetcher + .fetch_object("https://example.com/notes/0") + .await + .is_ok()); + }) + .await; +} diff --git a/crates/kitsune-activitypub/tests/fetcher/origin.rs b/crates/kitsune-activitypub/tests/fetcher/origin.rs new file mode 100644 index 000000000..5a20ae74d --- /dev/null +++ b/crates/kitsune-activitypub/tests/fetcher/origin.rs @@ -0,0 +1,94 @@ +#[tokio::test] +#[serial_test::serial] +async fn check_ap_id_authority() { + database_test(|db_pool| async move { + let builder = Fetcher::builder() + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())); + + let client = service_fn(|req: Request<_>| { + assert_ne!(req.uri().host(), Some("corteximplant.com")); + handle(req) + }); + let client = Client::builder().service(client); + let fetcher = builder + .clone() + .client(client.clone()) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .build(); + + // The mock HTTP client ensures that the fetcher doesn't access the correct server + // so this should return error + let _ = fetcher + .fetch_actor("https://example.com/users/0x0".into()) + .await + .unwrap_err(); + + let client = service_fn(|req: Request<_>| { + // Let `fetch_object` fetch `attributedTo` + if req.uri().path_and_query().map(PathAndQuery::as_str) != Some("/users/0x0") { + assert_ne!(req.uri().host(), Some("corteximplant.com")); + } + + handle(req) + }); + let client = Client::builder().service(client); + let fetcher = builder + .clone() + .client(client.clone()) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .build(); + + let _ = fetcher + .fetch_object("https://example.com/@0x0/109501674056556919") + .await + .unwrap_err(); + }) + .await; +} + +#[tokio::test] +#[serial_test::serial] +async fn check_ap_content_type() { + database_test(|db_pool| async move { + let client = service_fn(|req: Request<_>| async { + let mut res = handle(req).await.unwrap(); + res.headers_mut().remove(CONTENT_TYPE); + Ok::<_, Infallible>(res) + }); + let client = Client::builder().service(client); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + assert!(matches!( + fetcher + .fetch_object("https://corteximplant.com/users/0x0") + .await, + Err(Error::Api(ApiError::BadRequest)) + )); + }) + .await; +} diff --git a/crates/kitsune-activitypub/tests/fetcher/webfinger.rs b/crates/kitsune-activitypub/tests/fetcher/webfinger.rs new file mode 100644 index 000000000..bc021c1f8 --- /dev/null +++ b/crates/kitsune-activitypub/tests/fetcher/webfinger.rs @@ -0,0 +1,135 @@ +#[tokio::test] +#[serial_test::serial] +async fn fetch_actor_with_custom_acct() { + database_test(|db_pool| async move { + let mut jrd_base = include_bytes!("../../../test-fixtures/0x0_jrd.json").to_owned(); + let jrd_body = simd_json::to_string(&Resource { + subject: "acct:0x0@joinkitsune.org".into(), + ..simd_json::from_slice(&mut jrd_base).unwrap() + }) + .unwrap(); + let client = service_fn(move |req: Request<_>| { + let jrd_body = jrd_body.clone(); + async move { + match ( + req.uri().authority().unwrap().as_str(), + req.uri().path_and_query().unwrap().as_str(), + ) { + ( + "corteximplant.com", + "/.well-known/webfinger?resource=acct:0x0@corteximplant.com", + ) + | ( + "joinkitsune.org", + "/.well-known/webfinger?resource=acct:0x0@joinkitsune.org", + ) => Ok::<_, Infallible>(Response::new(Body::from(jrd_body))), + _ => handle(req).await, + } + } + }); + let client = Client::builder().service(client); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + let user = fetcher + .fetch_actor("https://corteximplant.com/users/0x0".into()) + .await + .expect("Fetch actor"); + + assert_eq!(user.username, "0x0"); + assert_eq!(user.domain, "joinkitsune.org"); + assert_eq!(user.url, "https://corteximplant.com/users/0x0"); + }) + .await; +} + +#[tokio::test] +#[serial_test::serial] +async fn ignore_fake_webfinger_acct() { + database_test(|db_pool| async move { + let link = Link { + rel: "self".to_owned(), + r#type: Some("application/activity+json".to_owned()), + href: Some("https://social.whitehouse.gov/users/POTUS".to_owned()), + }; + let jrd = Resource { + subject: "acct:POTUS@whitehouse.gov".into(), + aliases: Vec::new(), + links: vec![link.clone()], + }; + let client = service_fn(move |req: Request<_>| { + let link = link.clone(); + let jrd = jrd.clone(); + async move { + match ( + req.uri().authority().unwrap().as_str(), + req.uri().path_and_query().unwrap().as_str(), + ) { + ( + "corteximplant.com", + "/.well-known/webfinger?resource=acct:0x0@corteximplant.com", + ) => { + let fake_jrd = Resource { + links: vec![Link { + href: Some("https://corteximplant.com/users/0x0".to_owned()), + ..link + }], + ..jrd + }; + let body = simd_json::to_string(&fake_jrd).unwrap(); + Ok::<_, Infallible>(Response::new(Body::from(body))) + } + ( + "whitehouse.gov", + "/.well-known/webfinger?resource=acct:POTUS@whitehouse.gov", + ) => { + let body = simd_json::to_string(&jrd).unwrap(); + Ok(Response::new(Body::from(body))) + } + _ => handle(req).await, + } + } + }); + let client = Client::builder().service(client); + + let fetcher = Fetcher::builder() + .client(client.clone()) + .db_pool(db_pool) + .embed_client(None) + .federation_filter( + FederationFilterService::new(&FederationFilterConfiguration::Deny { + domains: Vec::new(), + }) + .unwrap(), + ) + .search_backend(NoopSearchService) + .webfinger(Webfinger::with_client(client, Arc::new(NoopCache.into()))) + .post_cache(Arc::new(NoopCache.into())) + .user_cache(Arc::new(NoopCache.into())) + .build(); + + let user = fetcher + .fetch_actor("https://corteximplant.com/users/0x0".into()) + .await + .expect("Fetch actor"); + + assert_eq!(user.username, "0x0"); + assert_eq!(user.domain, "corteximplant.com"); + assert_eq!(user.url, "https://corteximplant.com/users/0x0"); + }) + .await; +} diff --git a/crates/kitsune-util/Cargo.toml b/crates/kitsune-util/Cargo.toml index 253e5ba43..b4f2b7015 100644 --- a/crates/kitsune-util/Cargo.toml +++ b/crates/kitsune-util/Cargo.toml @@ -6,3 +6,4 @@ version.workspace = true [dependencies] ammonia = "3.3.0" kitsune-type = { path = "../kitsune-type" } +tokio = { version = "1.34.0", features = ["macros"] } diff --git a/crates/kitsune-util/src/lib.rs b/crates/kitsune-util/src/lib.rs index aa895c502..6d3c19503 100644 --- a/crates/kitsune-util/src/lib.rs +++ b/crates/kitsune-util/src/lib.rs @@ -1,5 +1,8 @@ use std::ops::Deref; +#[doc(hidden)] +pub use tokio; + pub mod sanitize; mod macros; diff --git a/crates/kitsune-util/src/macros.rs b/crates/kitsune-util/src/macros.rs index 91e9c8136..57dc70007 100644 --- a/crates/kitsune-util/src/macros.rs +++ b/crates/kitsune-util/src/macros.rs @@ -57,7 +57,7 @@ macro_rules! try_join { fut } - ::tokio::try_join!( + $crate::tokio::try_join!( $( assert_send($try_future) ),+ ) }}; diff --git a/crates/kitsune-webfinger/Cargo.toml b/crates/kitsune-webfinger/Cargo.toml index 3ff317bce..a19572c59 100644 --- a/crates/kitsune-webfinger/Cargo.toml +++ b/crates/kitsune-webfinger/Cargo.toml @@ -8,14 +8,16 @@ autometrics = { version = "0.6.0", default-features = false } deadpool-redis = "0.13.0" futures-util = "0.3.29" http = "0.2.10" -hyper = "0.14.27" kitsune-cache = { path = "../kitsune-cache" } kitsune-http-client = { path = "../kitsune-http-client" } kitsune-type = { path = "../kitsune-type" } kitsune-util = { path = "../kitsune-util" } -pretty_assertions = "1.4.0" serde = { version = "1.0.192", features = ["derive"] } +tracing = "0.1.40" + +[dev-dependencies] +hyper = "0.14.27" +pretty_assertions = "1.4.0" simd-json = { version = "0.13.4", features = ["hints"] } tokio = { version = "1.34.0", features = ["macros"] } tower = { version = "0.4.13", default-features = false, features = ["util"] } -tracing = "0.1.40" diff --git a/crates/kitsune-webfinger/src/lib.rs b/crates/kitsune-webfinger/src/lib.rs index 9034b9e01..5ee9a101f 100644 --- a/crates/kitsune-webfinger/src/lib.rs +++ b/crates/kitsune-webfinger/src/lib.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use std::{sync::Arc, time::Duration}; const CACHE_DURATION: Duration = Duration::from_secs(10 * 60); // 10 minutes + /// Intended to allow up to one canonicalisation on the originating server, one cross-origin /// canonicalisation and one more canonicalisation on the destination server, /// e.g. `acct:a@example.com -> acct:A@example.com -> acct:A@example.net -> a@example.net`