Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/tests/new_repo_account_admin.rs

//! **Who may press "New repository"** (owner's decision, 2026-10-06): an
//! ACCOUNT ADMIN creates a repository in their own account and in a clan they
//! administer; whoever holds only write or read on a namespace does not, nor
//! does anybody in another account's namespace. gunnar decides — the console's
//! button is a relay of `Repos.Create` signed as the person — by the rules a
//! push-to-create meets: the account's name filter, only the account admin
//! may make it public, and the account's ROOM: a namespace its plan refuses
//! writes to, or already at its cap, gets no new repository either.

use std::sync::Arc;

use gunnar_grpc::{
    Caller, ClansService, ControlPlane, EventBus, ReposService, ServerConfig, ServiceContext,
};
use gunnar_proto::clans_server::Clans as _;
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use tonic::{Code, Request};

fn person(id: &str) -> Caller {
    Caller {
        principal_id: id.to_owned(),
        fingerprint: format!("SHA256:{id}"),
        machine: false,
        authenticated: true,
    }
}

fn authed<T>(caller: &Caller, body: T) -> Request<T> {
    let mut r = Request::new(body);
    r.extensions_mut().insert(caller.clone());
    r
}

/// The estate: `op` the operator (server Admin); `acme` a customer and so the
/// admin of its own account; `wendy` holding WRITE on acme and `vic` holding
/// READ on it; `carol` the admin of the clan `fellows`, which `dave` joined.
struct World {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    ctx: ServiceContext,
}

fn world() -> World {
    let dir = tempfile::tempdir().expect("tempdir");
    let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
    let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
    let control: Arc<dyn ControlPlane> = plane.clone();
    let ctx = ServiceContext::new(
        control,
        Arc::new(EventBus::new(64)),
        ServerConfig::default(),
    );
    plane
        .add_principal("op", "op", PrincipalKind::User)
        .expect("the operator");
    plane
        .grant("op", "", "", GrantLevel::Admin)
        .expect("the operator is a server admin");
    for id in ["acme", "wendy", "vic", "carol", "dave"] {
        plane
            .add_principal(id, id, PrincipalKind::User)
            .expect("an account");
    }
    plane
        .grant("wendy", "acme", "", GrantLevel::Write)
        .expect("wendy writes acme");
    plane
        .grant("vic", "acme", "", GrantLevel::Read)
        .expect("vic reads acme");
    World {
        _dir: dir,
        stores,
        ctx,
    }
}

impl World {
    fn repos(&self) -> ReposService {
        ReposService::new(self.ctx.clone())
    }
    fn clans(&self) -> ClansService {
        ClansService::new(self.ctx.clone())
    }
    async fn create(
        &self,
        who: &str,
        ns: &str,
        repo: &str,
        visibility: Visibility,
    ) -> Result<Repo, tonic::Status> {
        self.repos()
            .create(authed(
                &person(who),
                CreateRepoRequest {
                    path: Some(RepoPath::new(ns, repo)),
                    visibility: visibility as i32,
                    ..Default::default()
                },
            ))
            .await
            .map(|r| r.into_inner().repo.expect("a repo"))
    }
    async fn fellows(&self) {
        self.clans()
            .create(authed(
                &person("carol"),
                CreateClanRequest {
                    name: "fellows".into(),
                    contribution_bytes: 0,
                },
            ))
            .await
            .expect("carol makes a clan");
        self.clans()
            .invite(authed(
                &person("carol"),
                InviteToClanRequest {
                    clan: "fellows".into(),
                    principal_id: "dave".into(),
                },
            ))
            .await
            .expect("carol invites dave");
        self.clans()
            .accept(authed(
                &person("dave"),
                AcceptClanInviteRequest {
                    clan: "fellows".into(),
                    contribution_bytes: 0,
                },
            ))
            .await
            .expect("dave joins");
    }
}

#[tokio::test(flavor = "multi_thread")]
async fn the_account_admin_creates_in_their_own_account_private_and_public() {
    let w = tokio::task::spawn_blocking(world).await.unwrap();
    w.create("acme", "acme", "site", Visibility::Private)
        .await
        .expect("the account admin, private");
    let public = w
        .create("acme", "acme", "www", Visibility::Public)
        .await
        .expect("the account admin, public");
    assert_eq!(public.visibility, Visibility::Public as i32);
}

/// ★ **`Repos.Create{readme}` writes the first commit server-side** (F3, the owner's welcome
/// repository, 2026-10-08): the README on the default branch, in the repository's store, with
/// no git client; an empty `readme` creates an empty repository as before.
#[tokio::test(flavor = "multi_thread")]
async fn a_repository_created_with_a_readme_holds_it_as_its_first_commit() {
    let w = tokio::task::spawn_blocking(world).await.unwrap();
    let readme = "# Welcome\n\nThank you for buying your private Gunnar.\n";
    w.repos()
        .create(authed(
            &person("acme"),
            CreateRepoRequest {
                path: Some(RepoPath::new("acme", "welcome")),
                visibility: Visibility::Private as i32,
                readme: readme.into(),
                ..Default::default()
            },
        ))
        .await
        .expect("the welcome repository");
    let plane = CoreControlPlane::new(Arc::clone(&w.stores));
    let path = RepoPath::new("acme", "welcome");
    let main = plane
        .get_ref(&path, "refs/heads/main", false)
        .expect("the default branch holds a commit");
    let read = plane
        .read_object(&path, &main.oid, 1 << 20)
        .expect("the commit reads back");
    assert!(
        String::from_utf8_lossy(&read.data).contains("author Gunnar <noreply@gunnar.invalid>"),
        "{:?}",
        String::from_utf8_lossy(&read.data)
    );
    let empty = w
        .create("acme", "acme", "empty", Visibility::Private)
        .await
        .expect("no readme");
    assert!(
        plane
            .get_ref(&RepoPath::new("acme", "empty"), "refs/heads/main", false)
            .is_err(),
        "no readme, no commit: {empty:?}"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn a_writer_and_a_reader_of_the_account_create_nothing_in_it() {
    let w = tokio::task::spawn_blocking(world).await.unwrap();
    for who in ["wendy", "vic"] {
        for visibility in [Visibility::Private, Visibility::Public] {
            let err = w
                .create(who, "acme", &format!("by-{who}"), visibility)
                .await
                .expect_err("not the account's admin");
            assert_eq!(
                err.code(),
                Code::PermissionDenied,
                "{who} {visibility:?}: {err:?}"
            );
        }
    }
}

#[tokio::test(flavor = "multi_thread")]
async fn nobody_creates_in_another_accounts_namespace() {
    let w = tokio::task::spawn_blocking(world).await.unwrap();
    for (who, ns) in [("acme", "carol"), ("carol", "acme"), ("wendy", "vic")] {
        let err = w
            .create(who, ns, "intruder", Visibility::Private)
            .await
            .expect_err("another account's namespace");
        assert_eq!(err.code(), Code::PermissionDenied, "{who} in {ns}: {err:?}");
    }
}

#[tokio::test(flavor = "multi_thread")]
async fn the_clan_admin_creates_in_the_clan_and_only_the_admin_makes_it_public() {
    let w = tokio::task::spawn_blocking(world).await.unwrap();
    w.fellows().await;
    w.create("carol", "fellows", "plans", Visibility::Private)
        .await
        .expect("the clan's admin, private");
    w.create("carol", "fellows", "site", Visibility::Public)
        .await
        .expect("the clan's admin, public");
    let err = w
        .create("dave", "fellows", "shout", Visibility::Public)
        .await
        .expect_err("a member does not publish");
    assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
    let err = w
        .create("acme", "fellows", "intruder", Visibility::Private)
        .await
        .expect_err("not in the clan");
    assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
}

#[tokio::test(flavor = "multi_thread")]
async fn a_name_the_account_filter_stops_is_refused_at_create() {
    let w = tokio::task::spawn_blocking(world).await.unwrap();
    let err = w
        .create("acme", "acme", "fuck-this", Visibility::Private)
        .await
        .expect_err("the name filter");
    assert_eq!(err.code(), Code::InvalidArgument, "{err:?}");
}

const SUSPENDED: &str =
    "plan team is suspended: writes are refused until it is paid at https://gunnar.rs/billing/acme";
const FULL: &str =
    "plan team holds 10 GiB of its 10 GiB: no room for more at https://gunnar.rs/billing/carol";

/// The account's plan, as the front doors ask it: `acme` suspended, `carol`
/// at its cap, everybody else free.
struct Plans;

impl gunnar_ssh::PlanGate for Plans {
    fn name(&self) -> &'static str {
        "plans-for-the-test"
    }
    fn verdict(&self, namespace: &str, _: gunnar_ssh::PlanAction) -> gunnar_ssh::PlanVerdict {
        if namespace == "acme" {
            gunnar_ssh::PlanVerdict::Refuse(SUSPENDED.to_owned())
        } else {
            gunnar_ssh::PlanVerdict::Allow
        }
    }
    fn pack_quota(&self, namespace: &str) -> gunnar_ssh::PlanVerdict {
        if namespace == "carol" {
            gunnar_ssh::PlanVerdict::Refuse(FULL.to_owned())
        } else {
            gunnar_ssh::PlanVerdict::Allow
        }
    }
}

/// ★ **The account's room, as a push-to-create meets it**: a namespace its
/// plan refuses writes to, or one already at its cap, gets no new repository —
/// refused with the plan's own line, and nothing is made.
#[tokio::test(flavor = "multi_thread")]
async fn an_account_its_plan_refuses_or_at_its_cap_gets_no_new_repository() {
    let w = tokio::task::spawn_blocking(world).await.unwrap();
    w.stores
        .install_plan_gates(vec![Arc::new(Plans) as Arc<dyn gunnar_ssh::PlanGate>]);
    for (who, words, code) in [
        ("acme", SUSPENDED, Code::FailedPrecondition),
        ("carol", FULL, Code::ResourceExhausted),
    ] {
        let err = w
            .create(who, who, "more", Visibility::Private)
            .await
            .expect_err("the plan says no");
        assert_eq!(err.code(), code, "{who}: {err:?}");
        assert!(
            err.message().contains(words),
            "{who}: the plan's own line: {err:?}"
        );
        let listed = w
            .repos()
            .list(authed(
                &person(who),
                ListReposRequest {
                    namespace: who.into(),
                    ..Default::default()
                },
            ))
            .await
            .expect("a listing")
            .into_inner()
            .repos;
        assert!(listed.is_empty(), "{who}: a refused create left {listed:?}");
    }
    w.create("dave", "dave", "fine", Visibility::Private)
        .await
        .expect("an account the plan allows");
}