invite_routes.rs 588 lignes · 20935 octets
//! HTTP routes for cross-instance team invitations (RFC 0014).
//!
//! Six thin handlers around
//! [`crate::services::fediverse_invite_service::FediverseInviteService`].
//!
//! Inviter-side (`b.tld`) :
//! - `POST /api/v1/teams/{team_id}/members/invite-fediverse`
//! - `GET  /api/v1/teams/{team_id}/invites`
//! - `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke`
//!
//! IdP-side (`a.tld`) :
//! - `GET  /api/v1/fediverse/invites?handle=@user@domain`
//! - `POST /api/v1/fediverse/invites/{invite_id}/accept`
//! - `POST /api/v1/fediverse/invites/{invite_id}/reject`
//!

use std::time::Duration;

use axum::{
    Extension, Json, Router,
    extract::{Path, Query},
    http::StatusCode,
    routing::{get, post},
};
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::error::FediverseError;
use crate::services::fediverse_invite_service::FediverseInviteService;
use rustwarden_core::auth::jwt::Claims;
use rustwarden_core::error::AppError;
use rustwarden_core::services::team_service::TeamRole;

/// Per-router state injected into the invite handlers.
#[derive(Clone)]
pub struct InviteRoutesState {
    /// TTL applied to every freshly-issued invite. Defaults to 7 days
    /// when the consumer does not override (set by
    /// [`crate::routes::router_with_auth`]).
    pub default_ttl: Duration,
}

impl std::fmt::Debug for InviteRoutesState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("InviteRoutesState")
            .field("default_ttl", &self.default_ttl)
            .finish_non_exhaustive()
    }
}

// ─── Bodies / responses ──────────────────────────────────────────────

/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` body.
#[derive(Debug, Deserialize)]
pub struct InviteBody {
    /// `@user@domain` to invite.
    pub handle: String,
    /// `"member"` or `"admin"`.
    pub role: String,
}

/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` 201 response.
#[derive(Debug, Serialize)]
pub struct InviteResponse {
    /// Stable invite identifier.
    pub invite_id: Uuid,
    /// Canonical AP activity id (used by `b.tld` when sending the
    /// `Invite{TeamMembership}` activity to the remote inbox).
    pub activity_id: String,
    /// RFC3339 expiry timestamp.
    pub expires_at: String,
}

/// One row in a `GET /…/invites` listing.
#[derive(Debug, Serialize)]
pub struct InviteRow {
    /// Stable invite identifier.
    pub id: Uuid,
    /// `@user@domain` invited.
    pub handle: String,
    /// `"member"` or `"admin"`.
    pub role: String,
    /// Lifecycle state.
    pub status: String,
    /// RFC3339 creation timestamp.
    pub created_at: String,
    /// RFC3339 expiry timestamp.
    pub expires_at: String,
}

/// Query params for the IdP listing endpoint.
#[derive(Debug, Deserialize)]
pub struct IdpListQuery {
    /// Handle whose pending invites should be returned.
    pub handle: String,
}

// ─── Handlers ────────────────────────────────────────────────────────

async fn invite(
    Extension(state): Extension<InviteRoutesState>,
    Extension(db): Extension<DatabaseConnection>,
    Extension(claims): Extension<Claims>,
    Path(team_id): Path<Uuid>,
    Json(body): Json<InviteBody>,
) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> {
    let role = TeamRole::parse(&body.role)
        .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?;
    // ANSSI F-NET-3 — Claims are now mandatory (Axum returns 401 when
    // the JWT middleware did not inject them). The inviter is always
    // the authenticated admin's user id.
    let inviter = claims.sub;
    tracing::info!(
        admin_id = %inviter,
        team_id = %team_id,
        handle = %body.handle,
        role = %body.role,
        "fediverse invite issued",
    );
    let model = FediverseInviteService::invite_remote(
        &db,
        team_id,
        &body.handle,
        role,
        inviter,
        state.default_ttl,
    )
    .await?;
    Ok((
        StatusCode::CREATED,
        Json(InviteResponse {
            invite_id: model.id,
            activity_id: model.activity_id,
            expires_at: model.expires_at.to_rfc3339(),
        }),
    ))
}

async fn list_for_team(
    Extension(db): Extension<DatabaseConnection>,
    Extension(_claims): Extension<Claims>,
    Path(team_id): Path<Uuid>,
) -> Result<Json<Vec<InviteRow>>, FediverseError> {
    // ANSSI F-NET-3 — Claims are mandatory; future RFC will add RBAC
    // membership check before listing.
    let rows = FediverseInviteService::list_pending_for_team(&db, team_id).await?;
    Ok(Json(rows.into_iter().map(into_row).collect()))
}

async fn revoke(
    Extension(db): Extension<DatabaseConnection>,
    Extension(claims): Extension<Claims>,
    Path((_team_id, invite_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<InviteRow>, FediverseError> {
    // ANSSI F-NET-3 / FUI-AUTH-1 — audit-trail destructive action.
    tracing::info!(
        admin_id = %claims.sub,
        invite_id = %invite_id,
        "fediverse invite revoked",
    );
    let model = FediverseInviteService::revoke(&db, invite_id).await?;
    Ok(Json(into_row(model)))
}

async fn idp_list(
    Extension(db): Extension<DatabaseConnection>,
    Extension(_claims): Extension<Claims>,
    Query(query): Query<IdpListQuery>,
) -> Result<Json<Vec<InviteRow>>, FediverseError> {
    // ANSSI F-NET-3 — IdP-side listing is now authenticated; future RFC
    // will narrow `handle` to the caller's own bound identity.
    let rows = FediverseInviteService::list_pending_for_handle(&db, &query.handle).await?;
    Ok(Json(rows.into_iter().map(into_row).collect()))
}

#[derive(Debug, Deserialize)]
struct AcceptBody {
    /// FK of the resolved `remote_identities.id` that the IdP user maps
    /// to. In `debug_local_federation` mode the IdP UI / API picks this
    /// from the same DB; in production a follow-up RFC will let the
    /// IdP-side mirror the activity and resolve the row automatically.
    remote_identity_id: Uuid,
}

async fn idp_accept(
    Extension(db): Extension<DatabaseConnection>,
    Extension(claims): Extension<Claims>,
    Path(invite_id): Path<Uuid>,
    Json(body): Json<AcceptBody>,
) -> Result<StatusCode, FediverseError> {
    // ANSSI F-NET-3 — authenticated user accepts the invite under
    // their own JWT identity (audit trail).
    tracing::info!(
        user_id = %claims.sub,
        invite_id = %invite_id,
        remote_identity_id = %body.remote_identity_id,
        "fediverse invite accepted (idp-side)",
    );
    FediverseInviteService::accept(&db, invite_id, body.remote_identity_id).await?;
    Ok(StatusCode::OK)
}

async fn idp_reject(
    Extension(db): Extension<DatabaseConnection>,
    Extension(claims): Extension<Claims>,
    Path(invite_id): Path<Uuid>,
) -> Result<StatusCode, FediverseError> {
    // ANSSI F-NET-3 — authenticated user rejects the invite.
    tracing::info!(
        user_id = %claims.sub,
        invite_id = %invite_id,
        "fediverse invite rejected (idp-side)",
    );
    FediverseInviteService::reject(&db, invite_id).await?;
    Ok(StatusCode::OK)
}

fn into_row(m: crate::models::fediverse_pending_invite::Model) -> InviteRow {
    InviteRow {
        id: m.id,
        handle: m.invited_handle,
        role: m.role,
        status: m.status,
        created_at: m.created_at.to_rfc3339(),
        expires_at: m.expires_at.to_rfc3339(),
    }
}

/// Build the invite-routes router.
pub fn router(state: InviteRoutesState) -> Router<DatabaseConnection> {
    Router::new()
        .route(
            "/api/v1/teams/{team_id}/members/invite-fediverse",
            post(invite),
        )
        .route("/api/v1/teams/{team_id}/invites", get(list_for_team))
        .route(
            "/api/v1/teams/{team_id}/invites/{invite_id}/revoke",
            post(revoke),
        )
        .route("/api/v1/fediverse/invites", get(idp_list))
        .route(
            "/api/v1/fediverse/invites/{invite_id}/accept",
            post(idp_accept),
        )
        .route(
            "/api/v1/fediverse/invites/{invite_id}/reject",
            post(idp_reject),
        )
        .layer(Extension(state))
}

// `From<AppError>` for `FediverseError` is provided via the `App`
// variant; nothing extra to wire here.
#[allow(dead_code)]
fn _ensure_app_error_conversion(err: AppError) -> FediverseError {
    err.into()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::fediverse_pending_invite;
    use axum::body::Body;
    use axum::http::Request;
    use chrono::{Duration as ChronoDuration, Utc};
    use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
    use tower::ServiceExt;

    fn fresh_invite(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_claims() -> rustwarden_core::auth::jwt::Claims {
        rustwarden_core::auth::jwt::Claims {
            sub: Uuid::new_v4(),
            jti: "test-jti".into(),
            aud: "test".into(),
            username: "admin".into(),
            roles: vec!["admin".into()],
            permissions: vec![],
            exp: i64::MAX / 2,
            iat: 0,
            iss: "rustwarden-test".into(),
            identity_kind: rustwarden_core::auth::jwt::IdentityKind::Local,
            remote_id: None,
        }
    }

    fn build_router(db: DatabaseConnection) -> Router {
        let state = InviteRoutesState {
            default_ttl: Duration::from_secs(7 * 86400),
        };
        router(state)
            .layer(Extension(db))
            .layer(Extension(test_claims()))
            .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection())
    }

    fn req_post(uri: &str, body: &str) -> Request<Body> {
        Request::builder()
            .uri(uri)
            .method("POST")
            .header("content-type", "application/json")
            .body(Body::from(body.to_string()))
            .unwrap()
    }

    fn req_get(uri: &str) -> Request<Body> {
        Request::builder()
            .uri(uri)
            .method("GET")
            .body(Body::empty())
            .unwrap()
    }

    // ─── Inviter side ───────────────────────────────────────────────

    #[tokio::test]
    async fn invite_returns_201_with_invite_id() {
        let team_id = Uuid::new_v4();
        let row = fresh_invite(Uuid::new_v4(), team_id);
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let body = serde_json::json!({"handle":"alice@a.tld","role":"member"}).to_string();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/teams/{team_id}/members/invite-fediverse"),
                &body,
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::CREATED);
    }

    #[tokio::test]
    async fn invite_returns_400_for_invalid_role() {
        let team_id = Uuid::new_v4();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let body = serde_json::json!({"handle":"alice@a.tld","role":"superuser"}).to_string();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/teams/{team_id}/members/invite-fediverse"),
                &body,
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn list_pending_returns_200_with_array() {
        let team_id = Uuid::new_v4();
        let r1 = fresh_invite(Uuid::new_v4(), team_id);
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![r1]])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_get(&format!("/api/v1/teams/{team_id}/invites")))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn revoke_returns_200_for_pending_invite() {
        let team_id = Uuid::new_v4();
        let invite_id = Uuid::new_v4();
        let row = fresh_invite(invite_id, team_id);
        let mut after = row.clone();
        after.status = "revoked".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"),
                "",
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn revoke_returns_404_for_unknown_invite() {
        let team_id = Uuid::new_v4();
        let invite_id = Uuid::new_v4();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<fediverse_pending_invite::Model>::new()])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"),
                "",
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    }

    // ─── IdP side ──────────────────────────────────────────────────

    #[tokio::test]
    async fn idp_list_returns_200_with_invites_for_handle() {
        let team_id = Uuid::new_v4();
        let r1 = fresh_invite(Uuid::new_v4(), team_id);
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![r1]])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_get("/api/v1/fediverse/invites?handle=alice@a.tld"))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn idp_list_returns_empty_array_when_no_invites() {
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<fediverse_pending_invite::Model>::new()])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_get(
                "/api/v1/fediverse/invites?handle=ghost@nowhere.tld",
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn idp_accept_returns_200_for_pending() {
        let team_id = Uuid::new_v4();
        let invite_id = Uuid::new_v4();
        let remote_id = Uuid::new_v4();
        let row = fresh_invite(invite_id, team_id);
        let mut after_invite = row.clone();
        after_invite.status = "accepted".into();
        after_invite.invited_remote_identity_id = Some(remote_id);
        let now = Utc::now();
        let new_member = rustwarden_core::models::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)
            .append_query_results([vec![row]])
            .append_query_results([vec![after_invite]])
            .append_query_results([vec![new_member]])
            .append_exec_results([
                MockExecResult {
                    last_insert_id: 0,
                    rows_affected: 1,
                },
                MockExecResult {
                    last_insert_id: 0,
                    rows_affected: 1,
                },
            ])
            .into_connection();
        let body = serde_json::json!({"remote_identity_id":remote_id}).to_string();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/fediverse/invites/{invite_id}/accept"),
                &body,
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn idp_accept_returns_400_for_already_accepted() {
        let team_id = Uuid::new_v4();
        let invite_id = Uuid::new_v4();
        let mut row = fresh_invite(invite_id, team_id);
        row.status = "accepted".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let body = serde_json::json!({"remote_identity_id":Uuid::new_v4()}).to_string();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/fediverse/invites/{invite_id}/accept"),
                &body,
            ))
            .await
            .unwrap();
        assert!(
            resp.status() == StatusCode::BAD_REQUEST
                || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
            "expected 400/422, got {}",
            resp.status()
        );
    }

    #[tokio::test]
    async fn idp_reject_returns_200_for_pending() {
        let team_id = Uuid::new_v4();
        let invite_id = Uuid::new_v4();
        let row = fresh_invite(invite_id, team_id);
        let mut after = row.clone();
        after.status = "rejected".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/fediverse/invites/{invite_id}/reject"),
                "",
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn idp_reject_returns_400_for_already_decided() {
        let team_id = Uuid::new_v4();
        let invite_id = Uuid::new_v4();
        let mut row = fresh_invite(invite_id, team_id);
        row.status = "rejected".into();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/fediverse/invites/{invite_id}/reject"),
                "",
            ))
            .await
            .unwrap();
        assert!(
            resp.status() == StatusCode::BAD_REQUEST
                || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
        );
    }

    #[tokio::test]
    async fn invite_uses_default_ttl_from_state() {
        // The `default_ttl` flows through the service; the mock
        // doesn't reflect it back to the response, so we only
        // assert status 201 and that the row was inserted.
        let team_id = Uuid::new_v4();
        let row = fresh_invite(Uuid::new_v4(), team_id);
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let body = serde_json::json!({"handle":"alice@a.tld","role":"admin"}).to_string();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/teams/{team_id}/members/invite-fediverse"),
                &body,
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::CREATED);
    }
}