🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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 frogue::entities::patch_proposals::{MergePolicy, Status};
use frogue::entities::tickets::State;
use frogue::entities::{comments, patch_proposals, repositories, tickets, users};
use miette::IntoDiagnostic;
use sea_orm::{ActiveModelTrait, Database, Set};
use support::{Client, Server, free_port};

/// 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(())
}