//! Nodeinfo integration tests.
//!
//! These tests verify the nodeinfo 2.1 discovery and document endpoints:
//! - /.well-known/nodeinfo (JDD)
//! - /nodeinfo/2.1 (the actual document)
mod support;
use chrono::Utc;
use miette::IntoDiagnostic;
use sea_orm::{ActiveModelTrait, Database, Set};
use support::{Client, Server, free_port};
use frogue::entities::patch_proposals::{MergePolicy, Status};
use frogue::entities::tickets::State;
use frogue::entities::{comments, patch_proposals, repositories, tickets, users};
/// Test 1: JDD at /.well-known/nodeinfo has correct structure
#[tokio::test]
async fn nodeinfo_discovery_returns_correct_jdd() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let mut client = Client::new(port);
let response = client.get("/.well-known/nodeinfo");
assert_eq!(response.status, 200, "discovery should return 200");
let jdd: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
assert!(jdd["links"].is_array(), "should have links array");
let links = jdd["links"].as_array().unwrap();
assert_eq!(links.len(), 1, "should have exactly one link");
let link = &links[0];
assert_eq!(
link["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.1#",
"rel should match nodeinfo 2.1 schema"
);
let href = link["href"].as_str().unwrap();
assert!(
href.contains("/nodeinfo/2.1"),
"href should point to /nodeinfo/2.1"
);
Ok(())
}
/// Test 2: Document at /nodeinfo/2.1 has correct structure and content
#[tokio::test]
async fn nodeinfo_document_has_correct_fields() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Insert some test data
let now = Utc::now();
let user = users::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
name: Set("alice".to_string()),
email: Set("alice@example.org".to_string()),
password_hash: Set("irrelevant".to_string()),
..Default::default()
};
let user = user.insert(&db).await.into_diagnostic()?;
// Create a repository
let repo = repositories::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
owner_id: Set(user.id),
slug: Set("test-repo".to_string()),
visibility: Set(repositories::Visibility::Public),
default_branch: Set("main".to_string()),
next_ticket_number: Set(2),
next_patch_number: Set(2),
default_merge_policy: Set(repositories::MergePolicy::PreferFastForward),
created_at: Set(now.into()),
..Default::default()
};
let repo = repo.insert(&db).await.into_diagnostic()?;
// Create a ticket
let ticket = tickets::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
repository_id: Set(repo.id),
creator_id: Set(Some(user.id)),
number: Set(1),
summary: Set("Test ticket".to_string()),
state: Set(State::Open),
created_at: Set(now.into()),
updated_at: Set(now.into()),
..Default::default()
};
let ticket = ticket.insert(&db).await.into_diagnostic()?;
// Create a patch proposal
let patch = patch_proposals::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
repository_id: Set(repo.id),
creator_id: Set(Some(user.id)),
number: Set(1),
summary: Set("Test patch".to_string()),
source_ref: Set("feature".to_string()),
target_branch: Set("main".to_string()),
status: Set(Status::Open),
merge_policy: Set(MergePolicy::PreferFastForward),
created_at: Set(now.into()),
updated_at: Set(now.into()),
..Default::default()
};
patch.insert(&db).await.into_diagnostic()?;
// Create a comment
let comment = comments::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
author_id: Set(Some(user.id)),
ticket_id: Set(Some(ticket.id)),
content: Set("Test comment".to_string()),
content_format: Set("markdown".to_string()),
created_at: Set(now.into()),
..Default::default()
};
comment.insert(&db).await.into_diagnostic()?;
let mut client = Client::new(port);
let response = client.get("/nodeinfo/2.1");
assert_eq!(response.status, 200, "document should return 200");
assert!(
response
.content_type
.as_ref()
.unwrap()
.contains("application/json"),
"should be JSON"
);
let doc: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
// Check version
assert_eq!(doc["version"], "2.1", "version should be 2.1");
// Check software
assert_eq!(
doc["software"]["name"], "frogue",
"software name should be frogue"
);
assert!(
doc["software"]["version"].is_string(),
"software version should be present"
);
// Check protocols
let protocols = doc["protocols"].as_array().unwrap();
let protocol_strs: Vec<&str> = protocols.iter().map(|p| p.as_str().unwrap()).collect();
assert!(
protocol_strs.contains(&"activitypub"),
"should include activitypub protocol"
);
assert!(
protocol_strs.contains(&"forgefed"),
"should include forgefed protocol"
);
// Check services
assert!(
doc["services"]["inbound"].is_array(),
"services.inbound should be array"
);
assert!(
doc["services"]["outbound"].is_array(),
"services.outbound should be array"
);
// Check usage
assert!(
doc["usage"]["users"]["total"].is_number(),
"should have usage.users.total"
);
assert_eq!(doc["usage"]["users"]["total"], 1, "should count 1 user");
assert_eq!(
doc["usage"]["localPosts"], 2,
"localPosts should be tickets + patches (1 + 1 = 2)"
);
assert_eq!(
doc["usage"]["localComments"], 1,
"localComments should be 1"
);
// Check metadata
assert_eq!(
doc["metadata"]["repositoryCount"], 1,
"metadata should include repository count"
);
Ok(())
}
/// Test 3: Nodeinfo works when federation is disabled
#[tokio::test]
async fn nodeinfo_works_without_federation() -> miette::Result<()> {
let port = free_port();
let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
// Insert a user
let user = users::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
name: Set("bob".to_string()),
email: Set("bob@example.org".to_string()),
password_hash: Set("irrelevant".to_string()),
..Default::default()
};
user.insert(&db).await.into_diagnostic()?;
let mut client = Client::new(port);
// Discovery should still work
let response = client.get("/.well-known/nodeinfo");
assert_eq!(
response.status, 200,
"discovery should work without federation"
);
// Document should still work
let response = client.get("/nodeinfo/2.1");
assert_eq!(
response.status, 200,
"document should work without federation"
);
let doc: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
// Should not include activitypub when federation disabled
let protocols = doc["protocols"].as_array().unwrap();
let protocol_strs: Vec<&str> = protocols.iter().map(|p| p.as_str().unwrap()).collect();
assert!(
!protocol_strs.contains(&"activitypub"),
"should NOT include activitypub when federation disabled"
);
assert!(
protocol_strs.contains(&"forgefed"),
"should still include forgefed"
);
Ok(())
}
/// Test 4: openRegistrations reflects registration mode
#[tokio::test]
async fn nodeinfo_reflects_registration_mode() -> miette::Result<()> {
// Test with invite-only mode
let port = free_port();
let mut server = Server::spawn_with_env(
&format!("127.0.0.1:{port}"),
&[("FROGUE_REGISTRATION_MODE", "invite_only")],
);
server.wait_for_connection(port);
let mut client = Client::new(port);
let response = client.get("/nodeinfo/2.1");
assert_eq!(response.status, 200);
let doc: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
assert_eq!(
doc["openRegistrations"], false,
"should be false in invite-only mode"
);
Ok(())
}