fediverse_invite_service.rs 552 lignes · 20029 octets
//! Cross-instance team-membership invitations (RFC 0009).
//!
//! State machine `Pending → Accepted | Rejected | Revoked | Expired`.
//! On `Accept`, the service resolves the remote identity and inserts
//! a `team_members` row with `remote_identity_id` populated. The AP
//! activities themselves are produced by the `build_*_payload` helpers
//! in `fediverse_invite_payloads` and dispatched by
//! the consumer through [`crate::services::fediverse_outbound_service::FediverseOutboundService`].
//!

use chrono::{Duration as ChronoDuration, Utc};
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use std::time::Duration;
use uuid::Uuid;

use crate::config::FediverseConfig;
use crate::models::fediverse_pending_invite;
use rustwarden_core::error::AppError;
use rustwarden_core::models::{team, team_member};
use rustwarden_core::services::team_service::TeamRole;

/// Lifecycle of a [`fediverse_pending_invite::Model`] row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InviteStatus {
    /// Awaiting IdP decision.
    Pending,
    /// IdP user clicked Accept; the membership row exists.
    Accepted,
    /// IdP user clicked Reject.
    Rejected,
    /// Inviter cancelled the invitation.
    Revoked,
    /// `expires_at` reached without decision.
    Expired,
}

impl InviteStatus {
    /// Wire form stored in the `status` column.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Accepted => "accepted",
            Self::Rejected => "rejected",
            Self::Revoked => "revoked",
            Self::Expired => "expired",
        }
    }

    /// Best-effort parse — unknown values fall back to `Pending`.
    pub fn parse(raw: &str) -> Self {
        match raw.trim().to_ascii_lowercase().as_str() {
            "accepted" => Self::Accepted,
            "rejected" => Self::Rejected,
            "revoked" => Self::Revoked,
            "expired" => Self::Expired,
            _ => Self::Pending,
        }
    }
}

/// Stateless service exposing the invite state machine.
pub struct FediverseInviteService;

impl FediverseInviteService {
    /// Persist a `pending` invitation. The caller is expected to
    /// enqueue the AP `Invite{TeamMembership}` activity to the
    /// remote inbox via `FediverseOutboundService::enqueue` using
    /// [`build_invite_payload`].
    pub async fn invite_remote(
        db: &DatabaseConnection,
        team_id: Uuid,
        invited_handle: &str,
        role: TeamRole,
        invited_by_user_id: Uuid,
        ttl: Duration,
    ) -> Result<fediverse_pending_invite::Model, AppError> {
        let now = Utc::now();
        let expires_at = now
            + ChronoDuration::from_std(ttl).map_err(|e| {
                AppError::Internal(format!("ttl out of range for chrono::Duration: {e}"))
            })?;
        let id = Uuid::new_v4();
        let active = fediverse_pending_invite::ActiveModel {
            id: Set(id),
            activity_id: Set(format!("https://invite/{id}")), // tweak in handler with FediverseConfig if needed
            team_id: Set(team_id),
            invited_handle: Set(invited_handle.to_string()),
            invited_remote_identity_id: Set(None),
            role: Set(role.as_str().to_string()),
            invited_by_user_id: Set(invited_by_user_id),
            status: Set(InviteStatus::Pending.as_str().to_string()),
            created_at: Set(now),
            expires_at: Set(expires_at),
            decided_at: Set(None),
        };
        Ok(active.insert(db).await?)
    }

    /// IdP-side accept: marks the invite `Accepted`, persists the
    /// resolved `remote_identity_id`, and inserts the `team_members`
    /// row with the remote sibling FK set.
    pub async fn accept(
        db: &DatabaseConnection,
        invite_id: Uuid,
        remote_identity_id: Uuid,
    ) -> Result<team_member::Model, AppError> {
        let invite = fediverse_pending_invite::Entity::find_by_id(invite_id)
            .one(db)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("Invite {invite_id} not found")))?;

        if invite.status != InviteStatus::Pending.as_str() {
            return Err(AppError::Validation(format!(
                "Cannot accept: invite is in state {}",
                invite.status
            )));
        }

        let team_id = invite.team_id;
        let role = invite.role.clone();

        let mut active: fediverse_pending_invite::ActiveModel = invite.into();
        active.status = Set(InviteStatus::Accepted.as_str().to_string());
        active.invited_remote_identity_id = Set(Some(remote_identity_id));
        active.decided_at = Set(Some(Utc::now()));
        active.update(db).await?;

        let member = team_member::ActiveModel {
            id: Set(Uuid::new_v4()),
            team_id: Set(team_id),
            // RFC 0018: PK relaxed; remote rows leave `user_id` NULL.
            // The CHECK XOR guarantees exactly one principal column is
            // populated; the unique partial index on
            // (team_id, remote_identity_id) prevents duplicates.
            user_id: Set(None),
            role: Set(role),
            joined_at: Set(Utc::now()),
            remote_identity_id: Set(Some(remote_identity_id)),
        };
        Ok(member.insert(db).await?)
    }

    /// IdP-side reject.
    pub async fn reject(
        db: &DatabaseConnection,
        invite_id: Uuid,
    ) -> Result<fediverse_pending_invite::Model, AppError> {
        Self::transition_terminal(
            db,
            invite_id,
            InviteStatus::Rejected,
            &[InviteStatus::Pending],
        )
        .await
    }

    /// Inviter-side revoke. Allowed both before and after acceptance
    /// — when revoking an accepted invite, the membership row stays
    /// in place and is removed by the lifecycle signal handler in
    /// RFC 0010 (`Revoke{Membership}`).
    pub async fn revoke(
        db: &DatabaseConnection,
        invite_id: Uuid,
    ) -> Result<fediverse_pending_invite::Model, AppError> {
        Self::transition_terminal(
            db,
            invite_id,
            InviteStatus::Revoked,
            &[InviteStatus::Pending, InviteStatus::Accepted],
        )
        .await
    }

    /// List currently-pending invites for a team.
    pub async fn list_pending_for_team(
        db: &DatabaseConnection,
        team_id: Uuid,
    ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> {
        Ok(fediverse_pending_invite::Entity::find()
            .filter(fediverse_pending_invite::Column::TeamId.eq(team_id))
            .filter(fediverse_pending_invite::Column::Status.eq(InviteStatus::Pending.as_str()))
            .all(db)
            .await?)
    }

    /// List currently-pending invites addressed to a specific handle
    /// (`@user@domain`). Used by the IdP-side endpoint
    /// `GET /api/v1/fediverse/invites`. In `debug_local_federation`
    /// mode both sides share the same DB; in production a real IdP
    /// keeps a mirror table populated by its own inbox handler.
    pub async fn list_pending_for_handle(
        db: &DatabaseConnection,
        handle: &str,
    ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> {
        Ok(fediverse_pending_invite::Entity::find()
            .filter(fediverse_pending_invite::Column::InvitedHandle.eq(handle))
            .filter(fediverse_pending_invite::Column::Status.eq(InviteStatus::Pending.as_str()))
            .all(db)
            .await?)
    }

    async fn transition_terminal(
        db: &DatabaseConnection,
        invite_id: Uuid,
        target: InviteStatus,
        from: &[InviteStatus],
    ) -> Result<fediverse_pending_invite::Model, AppError> {
        let invite = fediverse_pending_invite::Entity::find_by_id(invite_id)
            .one(db)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("Invite {invite_id} not found")))?;
        let current = InviteStatus::parse(&invite.status);
        if !from.contains(&current) {
            return Err(AppError::Validation(format!(
                "Cannot transition: invite is in state {}",
                invite.status
            )));
        }
        let mut active: fediverse_pending_invite::ActiveModel = invite.into();
        active.status = Set(target.as_str().to_string());
        active.decided_at = Set(Some(Utc::now()));
        Ok(active.update(db).await?)
    }
}

/// Build the AP `Invite{TeamMembership}` JSON-LD payload.
pub fn build_invite_payload(
    config: &FediverseConfig,
    invite: &fediverse_pending_invite::Model,
    team: &team::Model,
) -> serde_json::Value {
    serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://schema.rustwarden.org/team-membership/v1"
        ],
        "type": "Invite",
        "id": invite.activity_id,
        "actor": format!("{}", config.base_url()),
        "object": {
            "type": "TeamMembership",
            "team": {
                "id": format!("{}teams/{}", config.base_url(), team.id),
                "name": team.name,
            },
            "role": invite.role,
        },
        "target": invite.invited_handle,
        "expiresAt": invite.expires_at.to_rfc3339(),
    })
}

/// Build the AP `Accept{Invite}` activity sent back from the IdP.
pub fn build_accept_invite_payload(
    config: &FediverseConfig,
    invite_activity_id: &str,
) -> serde_json::Value {
    serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Accept",
        "actor": format!("{}", config.base_url()),
        "object": invite_activity_id,
    })
}

/// Build the AP `Reject{Invite}` activity sent back from the IdP.
pub fn build_reject_invite_payload(
    config: &FediverseConfig,
    invite_activity_id: &str,
) -> serde_json::Value {
    serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Reject",
        "actor": format!("{}", config.base_url()),
        "object": invite_activity_id,
    })
}

/// Build the AP `Revoke{Membership}` activity for either side.
pub fn build_revoke_membership_payload(
    config: &FediverseConfig,
    membership_id: Uuid,
    team_id: Uuid,
) -> serde_json::Value {
    serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Revoke",
        "actor": format!("{}", config.base_url()),
        "object": {
            "type": "TeamMembership",
            "id": membership_id,
            "team": format!("{}teams/{}", config.base_url(), team_id),
        },
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};

    fn fresh_pending(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model {
        let now = Utc::now();
        fediverse_pending_invite::Model {
            id,
            activity_id: format!("https://b.tld/invites/{id}"),
            team_id,
            invited_handle: "alice@a.tld".into(),
            invited_remote_identity_id: None,
            role: "member".into(),
            invited_by_user_id: Uuid::new_v4(),
            status: "pending".into(),
            created_at: now,
            expires_at: now + ChronoDuration::days(7),
            decided_at: None,
        }
    }

    fn test_fediverse_config() -> FediverseConfig {
        FediverseConfig::for_tests("test.local")
    }

    fn test_team(id: Uuid) -> team::Model {
        team::Model {
            id,
            slug: "engineering".into(),
            name: "Engineering".into(),
            owner_id: Uuid::new_v4(),
            created_at: Utc::now(),
        }
    }

    #[test]
    fn invite_status_round_trips_through_string() {
        for s in [
            InviteStatus::Pending,
            InviteStatus::Accepted,
            InviteStatus::Rejected,
            InviteStatus::Revoked,
            InviteStatus::Expired,
        ] {
            assert_eq!(InviteStatus::parse(s.as_str()), s);
        }
        assert_eq!(InviteStatus::parse("garbage"), InviteStatus::Pending);
    }

    #[test]
    fn build_invite_payload_includes_team_and_handle() {
        let config = test_fediverse_config();
        let team_id = Uuid::new_v4();
        let invite = fresh_pending(Uuid::new_v4(), team_id);
        let team = test_team(team_id);
        let payload = build_invite_payload(&config, &invite, &team);
        assert_eq!(payload["type"], serde_json::json!("Invite"));
        assert_eq!(payload["target"], serde_json::json!("alice@a.tld"));
        assert_eq!(payload["object"]["role"], serde_json::json!("member"));
        assert_eq!(
            payload["object"]["team"]["name"],
            serde_json::json!("Engineering")
        );
    }

    #[test]
    fn build_accept_invite_payload_references_invite_id() {
        let config = test_fediverse_config();
        let payload = build_accept_invite_payload(&config, "https://b.tld/invites/x");
        assert_eq!(payload["type"], serde_json::json!("Accept"));
        assert_eq!(
            payload["object"],
            serde_json::json!("https://b.tld/invites/x")
        );
    }

    #[tokio::test]
    async fn invite_remote_persists_pending_row() {
        let row = fresh_pending(Uuid::new_v4(), Uuid::new_v4());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row.clone()]])
            .into_connection();
        let inserted = FediverseInviteService::invite_remote(
            &db,
            row.team_id,
            "alice@a.tld",
            TeamRole::Member,
            row.invited_by_user_id,
            Duration::from_secs(7 * 86400),
        )
        .await
        .unwrap();
        assert_eq!(inserted.status, "pending");
    }

    #[tokio::test]
    async fn invite_remote_uses_provided_role() {
        let mut row = fresh_pending(Uuid::new_v4(), Uuid::new_v4());
        row.role = "admin".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row.clone()]])
            .into_connection();
        let inserted = FediverseInviteService::invite_remote(
            &db,
            row.team_id,
            "alice@a.tld",
            TeamRole::Admin,
            row.invited_by_user_id,
            Duration::from_secs(86400),
        )
        .await
        .unwrap();
        assert_eq!(inserted.role, "admin");
    }

    #[tokio::test]
    async fn accept_marks_invite_accepted_and_inserts_team_member() {
        let invite_id = Uuid::new_v4();
        let team_id = Uuid::new_v4();
        let remote_id = Uuid::new_v4();
        let invite = fresh_pending(invite_id, team_id);

        let mut after_invite = invite.clone();
        after_invite.status = "accepted".into();
        after_invite.invited_remote_identity_id = Some(remote_id);
        after_invite.decided_at = Some(Utc::now());

        let now = Utc::now();
        let new_member = team_member::Model {
            id: Uuid::new_v4(),
            team_id,
            user_id: None,
            role: "member".into(),
            joined_at: now,
            remote_identity_id: Some(remote_id),
        };

        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            // 1. SELECT invite
            .append_query_results([vec![invite]])
            // 2. SELECT issued by .update() on invite
            .append_query_results([vec![after_invite]])
            // 3. INSERT into team_members → returned row
            .append_query_results([vec![new_member.clone()]])
            .append_exec_results([
                MockExecResult {
                    last_insert_id: 0,
                    rows_affected: 1,
                },
                MockExecResult {
                    last_insert_id: 0,
                    rows_affected: 1,
                },
            ])
            .into_connection();

        let member = FediverseInviteService::accept(&db, invite_id, remote_id)
            .await
            .unwrap();
        assert_eq!(member.remote_identity_id, Some(remote_id));
        assert_eq!(member.team_id, team_id);
    }

    #[tokio::test]
    async fn accept_refuses_non_pending_invite() {
        let invite_id = Uuid::new_v4();
        let mut invite = fresh_pending(invite_id, Uuid::new_v4());
        invite.status = "rejected".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![invite]])
            .into_connection();
        let err = FediverseInviteService::accept(&db, invite_id, Uuid::new_v4())
            .await
            .expect_err("must refuse non-pending");
        assert!(matches!(err, AppError::Validation(_)));
    }

    #[tokio::test]
    async fn reject_marks_rejected() {
        let invite_id = Uuid::new_v4();
        let invite = fresh_pending(invite_id, Uuid::new_v4());
        let mut after = invite.clone();
        after.status = "rejected".into();
        after.decided_at = Some(Utc::now());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![invite]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let updated = FediverseInviteService::reject(&db, invite_id)
            .await
            .unwrap();
        assert_eq!(updated.status, "rejected");
    }

    #[tokio::test]
    async fn revoke_marks_revoked_when_pending() {
        let invite_id = Uuid::new_v4();
        let invite = fresh_pending(invite_id, Uuid::new_v4());
        let mut after = invite.clone();
        after.status = "revoked".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![invite]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let updated = FediverseInviteService::revoke(&db, invite_id)
            .await
            .unwrap();
        assert_eq!(updated.status, "revoked");
    }

    #[tokio::test]
    async fn revoke_marks_revoked_when_already_accepted() {
        let invite_id = Uuid::new_v4();
        let mut invite = fresh_pending(invite_id, Uuid::new_v4());
        invite.status = "accepted".into();
        invite.invited_remote_identity_id = Some(Uuid::new_v4());
        let mut after = invite.clone();
        after.status = "revoked".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![invite]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let updated = FediverseInviteService::revoke(&db, invite_id)
            .await
            .unwrap();
        assert_eq!(updated.status, "revoked");
    }

    #[tokio::test]
    async fn list_pending_for_team_returns_only_pending() {
        let team_id = Uuid::new_v4();
        let p1 = fresh_pending(Uuid::new_v4(), team_id);
        let p2 = fresh_pending(Uuid::new_v4(), team_id);
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![p1.clone(), p2.clone()]])
            .into_connection();
        let result = FediverseInviteService::list_pending_for_team(&db, team_id)
            .await
            .unwrap();
        assert_eq!(result.len(), 2);
        assert!(result.iter().all(|r| r.status == "pending"));
    }
}