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/purge_tenant.rs

//! ★ UW-11 (5.4, ONE monetize, G1 pull): **the nonpay purge on an attached estate.** Monetize
//! never dials an estate, so the account deletion at the end of retention leaves monetize as a
//! signed `purge_tenant` order the main pulls. The main starts the purge (`Accounts.Purge`'s
//! own job: lock, then unlink) or adopts the one running, and acks monetize-product's
//! `PurgeAnswer` as JSON; monetize asks again, naming the job, until it settles. A machine's
//! account is never purged; a job the box does not know is refused.
//!
//! Driven through `gunnar_repair::exec::execute` on the server's own [`ServerEstate`].
#![cfg(feature = "replication")]

use std::sync::Arc;

use gunnar_core::ssh_key::PrivateKey;
use gunnar_core::{Principal, PrincipalKind};
use gunnar_repair::exec::execute;
use gunnar_repair::{Order, OrderAck};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::repair::{Entitlements, ServerEstate};
use gunnar_server::stores::ServerStores;
use serde_json::Value;

struct Rig {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    plane: Arc<CoreControlPlane>,
}

impl Rig {
    /// A main that serves: bob (a person who stopped paying) and ci (a machine).
    fn serving() -> Rig {
        let dir = tempfile::tempdir().expect("tempdir");
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        stores
            .auth()
            .add_principal(Principal {
                id: "bob".into(),
                name: "bob".into(),
                kind: PrincipalKind::User,
                created: 0,
            })
            .expect("bob");
        stores
            .auth()
            .add_principal(Principal {
                id: "ci".into(),
                name: "ci".into(),
                kind: PrincipalKind::Machine,
                created: 0,
            })
            .expect("ci");
        for ns in ["bob", "ci"] {
            stores.catalog().create_namespace(ns).expect("namespace");
        }
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        Rig {
            _dir: dir,
            stores,
            plane,
        }
    }

    fn estate(&self) -> ServerEstate {
        let pem = gunnar_core::mint_ed25519().expect("mint");
        let key = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
        let entitlements = Entitlements::of(Arc::clone(&self.stores))
            .with_control(Arc::clone(&self.plane) as Arc<dyn gunnar_grpc::ControlPlane>);
        ServerEstate {
            key,
            own: "127.0.0.1:1".into(),
            entitlements,
        }
    }

    async fn run(&self, seq: u64, tenant: &str, job: &str) -> OrderAck {
        let order = Order::PurgeTenant {
            tenant: tenant.into(),
            reason: format!("nonpay: {tenant} lapsed"),
            job: job.into(),
        };
        execute(&self.estate(), seq, &order, &|| false).await
    }
}

fn answer(a: &OrderAck) -> Value {
    assert!(a.ok, "{a:?}");
    serde_json::from_str(&a.detail).expect("the PurgeAnswer as JSON")
}

#[tokio::test]
async fn a_lapsed_account_is_purged_and_its_job_is_answered_until_it_settles() {
    let r = Rig::serving();
    let started = answer(&r.run(1, "bob", "").await);
    let job = started["job"].as_str().expect("the job").to_owned();
    assert!(!job.is_empty(), "{started}");
    assert_eq!(started["failed"], false, "{started}");
    // A purge is minutes of unlinking: asked again, naming the job, until it is no longer
    // running; a running job keeps the account locked.
    let mut seq = 2;
    let settled = loop {
        let a = answer(&r.run(seq, "bob", &job).await);
        assert_eq!(a["job"], job.as_str(), "{a}");
        if a["running"] == false {
            break a;
        }
        assert_eq!(
            a["still_locked"], true,
            "a running purge holds the lock: {a}"
        );
        seq += 1;
        assert!(seq < 400, "the purge never settled: {a}");
        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
    };
    assert_eq!(settled["failed"], false, "{settled}");
    assert!(
        settled["detail"]
            .as_str()
            .is_some_and(|d| d.contains("stores")),
        "{settled}"
    );

    let again = answer(&r.run(seq + 1, "bob", "").await);
    assert!(
        !again["job"].as_str().unwrap_or_default().is_empty(),
        "a start with no job running starts one: {again}"
    );
}

#[tokio::test]
async fn a_machines_account_or_an_unknown_job_is_refused() {
    let r = Rig::serving();
    let a = r.run(1, "ci", "").await;
    assert!(
        !a.ok && a.detail.contains("machine"),
        "a machine's account is never purged: {a:?}"
    );
    let a = r.run(2, "bob", "no-such-job").await;
    assert!(!a.ok && a.detail.contains("no-such-job"), "{a:?}");
}