sova-scope 0.1.1

Scoped RBAC (tenant/workspace membership, role matrix, ownership hooks) for Sova
Documentation
//! Engine integration tests.

use sova_db::DbHandle;
use sova_scope::{
    Ability, AuthorizeEngine, Condition, InviteStore, MatrixBuilder, MembershipStore,
    OwnerRegistry, ResourceRef, ScopeMigrator, ScopeRef, ROLE_ADMIN, ROLE_EDITOR, ROLE_OWNER,
    ROLE_VIEWER, callback_owner,
};
use sova_testing::{apply_migrations, SqliteTestDb};
use std::collections::HashMap;

fn workspace_matrix() -> HashMap<&'static str, sova_scope::RoleMatrix> {
    let mut b = MatrixBuilder::default();
    b.allow_all("workspace", ROLE_OWNER)
        .allow_all("workspace", ROLE_ADMIN)
        .allow(
            "workspace",
            ROLE_EDITOR,
            Ability::View,
            sova_scope::ResourcePattern::All,
            Condition::Always,
        )
        .allow(
            "workspace",
            ROLE_EDITOR,
            Ability::Update,
            sova_scope::ResourcePattern::Kind("page"),
            Condition::Owner,
        )
        .deny(
            "workspace",
            ROLE_EDITOR,
            Ability::Delete,
            sova_scope::ResourcePattern::All,
            Condition::Always,
        )
        .deny(
            "workspace",
            ROLE_EDITOR,
            Ability::Manage,
            sova_scope::ResourcePattern::All,
            Condition::Always,
        )
        .allow(
            "workspace",
            ROLE_VIEWER,
            Ability::View,
            sova_scope::ResourcePattern::All,
            Condition::Always,
        );
    b.build()
}

fn engine_with_page_owner(owner_id: i64, owned_page_id: i64) -> AuthorizeEngine {
    let mut owners = OwnerRegistry::default();
    owners.register(
        "page",
        callback_owner(move |_db, id| async move {
            Ok(if id == owned_page_id {
                Some(owner_id)
            } else {
                None
            })
        }),
    );
    AuthorizeEngine::new(workspace_matrix(), owners)
}

async fn test_db() -> (SqliteTestDb, DbHandle) {
    let db = SqliteTestDb::create();
    apply_migrations::<ScopeMigrator>(db.url()).await;
    let conn = db.connect().await;
    (db, DbHandle::Conn(conn))
}

#[tokio::test]
async fn viewer_can_read_not_edit() {
    let (_db, conn) = test_db().await;
    let scope = ScopeRef::new("workspace", 1);
    MembershipStore::add(&conn, scope.clone(), 10, ROLE_VIEWER)
        .await
        .unwrap();
    let engine = engine_with_page_owner(99, 5);
    let page = ResourceRef::new("page", 5);
    assert!(engine
        .authorize(&conn, scope.clone(), 10, Ability::View, Some(page))
        .await
        .unwrap()
        .view);
    assert!(engine
        .authorize(&conn, scope, 10, Ability::Update, Some(page))
        .await
        .is_err());
}

#[tokio::test]
async fn editor_edits_own_page_only() {
    let (_db, conn) = test_db().await;
    let scope = ScopeRef::new("workspace", 1);
    MembershipStore::add(&conn, scope.clone(), 20, ROLE_EDITOR)
        .await
        .unwrap();
    let engine = engine_with_page_owner(20, 1);
    let own = ResourceRef::new("page", 1);
    let foreign = ResourceRef::new("page", 2);
    engine
        .authorize(&conn, scope.clone(), 20, Ability::Update, Some(own))
        .await
        .unwrap();
    assert!(engine
        .authorize(&conn, scope, 20, Ability::Update, Some(foreign))
        .await
        .is_err());
}

#[tokio::test]
async fn admin_has_full_access() {
    let (_db, conn) = test_db().await;
    let scope = ScopeRef::new("workspace", 1);
    MembershipStore::add(&conn, scope.clone(), 30, ROLE_ADMIN)
        .await
        .unwrap();
    let engine = engine_with_page_owner(99, 1);
    let page = ResourceRef::new("page", 1);
    let caps = engine
        .authorize(&conn, scope, 30, Ability::Delete, Some(page))
        .await
        .unwrap();
    assert!(caps.delete);
    assert!(caps.manage);
}

#[tokio::test]
async fn accept_pending_invite() {
    let (_db, conn) = test_db().await;
    let scope = ScopeRef::new("workspace", 7);
    InviteStore::create(&conn, scope.clone(), "joiner@example.com", ROLE_VIEWER, 1)
        .await
        .unwrap();
    let n = InviteStore::accept_pending_for_email(&conn, 42, "joiner@example.com")
        .await
        .unwrap();
    assert_eq!(n, 1);
    let m = MembershipStore::find(&conn, scope, 42).await.unwrap().unwrap();
    assert_eq!(m.role, ROLE_VIEWER);
}