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:?}");
}