use std::collections::BTreeMap;
use super::super::bridge::JobKind;
use super::super::projection::{ProjectionView, Projector};
use super::*;
use crate::registry::TrustRegistryClient;
const ACTION: &str = "act-suspend-carol";
async fn suspension_fixture() -> (Fixture, String) {
let f = fixture().await;
let ns = bind_bridge(&f).await;
adopt_with_forge_id(&f, RES, "100").await;
link_account(&f, &ns, &f.bob, "9120045", "bob-builds").await;
link_account(&f, &ns, &f.carol, "5550001", "carol-c").await;
ok(&grant(&f, &f.bob, &f.carol.did, "git.repo.maintain", RES).await);
(f, ns)
}
fn projector(f: &Fixture, registry: &MockRegistryClient) -> Projector {
let client: Arc<dyn TrustRegistryClient> = Arc::new(registry.clone());
Projector::new(
f.vtc.state.clone(),
Some((client, TEST_VTC_DID.to_string())),
Duration::from_secs(1),
)
}
async fn suspend(f: &Fixture, who: &Party) {
crate::acl::storage::put_suspension(
&f.vtc.state.acl_ks,
&who.did,
&crate::acl::Suspension {
action_id: ACTION.into(),
lands_at: 4_102_444_800,
requester: f.admin.did.clone(),
},
)
.await
.unwrap();
}
async fn lift(f: &Fixture, who: &Party) {
crate::acl::storage::remove_suspension(&f.vtc.state.acl_ks, &who.did)
.await
.unwrap();
}
fn records_of(records: &BTreeMap<String, Value>, did: &str) -> BTreeMap<String, Value> {
records
.iter()
.filter(|(k, _)| k.starts_with(&format!("{did}|")))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn last_sent_roles(f: &Fixture) -> Vec<Value> {
f.bridge
.jobs
.lock()
.unwrap()
.iter()
.rev()
.find(|(_, p)| p["kind"] == "projectRoles" && p["repo"] == RES)
.map(|(_, p)| p["desiredRoles"].as_array().cloned().unwrap_or_default())
.expect("a projectRoles job was sent for widgets")
}
fn sent_jobs(f: &Fixture) -> usize {
f.bridge
.jobs
.lock()
.unwrap()
.iter()
.filter(|(_, p)| p["kind"] == "projectRoles" && p["repo"] == RES)
.count()
}
fn names_account(roles: &[Value], id: &str) -> bool {
roles
.iter()
.any(|r| r.pointer("/account/id").and_then(Value::as_str) == Some(id))
}
async fn stored_rows_of(f: &Fixture, did: &str) -> Vec<super::super::model::RightRow> {
Snapshot::load(&f.vtc.state.git_ns)
.await
.unwrap()
.rights
.values()
.flat_map(|s| s.rows.iter())
.filter(|r| r.subject == did)
.cloned()
.collect()
}
async fn grants_of(f: &Fixture, did: &str) -> Vec<crate::acl::resource_grant::ResourceGrant> {
crate::acl::get_acl_entry(&f.vtc.state.acl_ks, did)
.await
.unwrap()
.expect("the entry is kept")
.resource_grants
}
async fn audited(f: &Fixture, action: &str) -> Vec<Value> {
let mut out = Vec::new();
for (_, v) in f
.vtc
.state
.audit_ks
.prefix_iter_raw(Vec::new())
.await
.unwrap()
{
let Ok(env) = serde_json::from_slice::<vti_common::audit::AuditEnvelope>(&v) else {
continue;
};
if let vti_common::audit::AuditEvent::GitNsOperation(d) = env.event
&& d.action == action
{
let detail = d
.detail
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
out.push(detail);
}
}
out
}
#[tokio::test]
async fn suspension_withdraws_projected_git_rights_without_deleting_them() {
let (f, _ns) = suspension_fixture().await;
let registry = MockRegistryClient::new();
projector(&f, ®istry).run_once().await;
let carol_before = records_of(®istry.trust_records().await, &f.carol.did);
assert!(carol_before.contains_key(&projection::tuple_key(
&f.carol.did,
"git.repo.maintain",
RES
)));
let rows_before = stored_rows_of(&f, &f.carol.did).await;
let grants_before = grants_of(&f, &f.carol.did).await;
assert!(!rows_before.is_empty());
suspend(&f, &f.carol).await;
projector(&f, ®istry).run_once().await;
let records = registry.trust_records().await;
assert!(
records_of(&records, &f.carol.did).is_empty(),
"nothing of Carol's is published: {records:?}"
);
assert!(records.contains_key(&projection::tuple_key(&f.bob.did, "git.repo.own", RES)));
assert_eq!(stored_rows_of(&f, &f.carol.did).await, rows_before);
assert_eq!(grants_of(&f, &f.carol.did).await, grants_before);
let roles = last_sent_roles(&f);
assert!(!names_account(&roles, "5550001"), "{roles:?}");
assert!(names_account(&roles, "9120045"), "{roles:?}");
let rows = audited(&f, "gitNs.projection.withheld").await;
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0]["actionId"], ACTION);
assert_eq!(rows[0]["rights"], 1);
projector(&f, ®istry).run_once().await;
assert_eq!(audited(&f, "gitNs.projection.withheld").await.len(), 1);
}
#[tokio::test]
async fn suspended_subjects_commit_signing_right_is_not_published() {
let (f, _ns) = suspension_fixture().await;
ok(&grant(
&f,
&f.admin,
&f.carol.did,
"git.commit.sign",
"github.com/acme",
)
.await);
let registry = MockRegistryClient::new();
projector(&f, ®istry).run_once().await;
let records = registry.trust_records().await;
let commit_on = |r: &str| projection::tuple_key(&f.carol.did, "git.commit.sign", r);
assert!(records.contains_key(&commit_on(RES)), "implied by maintain");
assert!(
records.contains_key(&commit_on("github.com/acme")),
"explicit"
);
suspend(&f, &f.carol).await;
projector(&f, ®istry).run_once().await;
let records = registry.trust_records().await;
assert!(!records.contains_key(&commit_on(RES)));
assert!(!records.contains_key(&commit_on("github.com/acme")));
assert!(records.contains_key(&projection::tuple_key(&f.bob.did, "git.commit.sign", RES)));
let view = ProjectionView::load(&f.vtc.state).await.unwrap();
assert!(view.withheld().contains_key(&f.carol.did));
let want = projection::desired_all(&f.vtc.state, &view, super::super::ops::now())
.await
.unwrap();
assert!(want.keys().all(|k| !k.starts_with(&f.carol.did)));
}
#[tokio::test]
async fn cancelled_cooling_off_republishes_exactly_what_was_withdrawn() {
let (f, _ns) = suspension_fixture().await;
let registry = MockRegistryClient::new();
projector(&f, ®istry).run_once().await;
let before = registry.trust_records().await;
let roles_before = last_sent_roles(&f);
suspend(&f, &f.carol).await;
projector(&f, ®istry).run_once().await;
assert!(records_of(®istry.trust_records().await, &f.carol.did).is_empty());
lift(&f, &f.carol).await;
projector(&f, ®istry).run_once().await;
assert_eq!(registry.trust_records().await, before);
assert_eq!(last_sent_roles(&f), roles_before);
let rows = audited(&f, "gitNs.projection.restored").await;
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0]["actionId"], ACTION);
assert!(audited(&f, "gitNs.projection.released").await.is_empty());
}
#[tokio::test]
async fn bridge_receives_role_withdrawal_on_suspension_and_reprojection_on_cancel() {
let (f, _ns) = suspension_fixture().await;
super::super::bridge::project_roles(&f.vtc.state, false)
.await
.unwrap();
super::super::bridge::dispatch_due(&f.vtc.state)
.await
.unwrap();
let carol_role = last_sent_roles(&f)
.into_iter()
.find(|r| r.pointer("/account/id").and_then(Value::as_str) == Some("5550001"))
.expect("Carol's account is projected");
assert_eq!(carol_role["right"], "git.repo.maintain");
let sent = sent_jobs(&f);
let before: std::collections::HashSet<String> =
super::super::bridge::list_jobs(&f.vtc.state.git_ns.jobs_ks)
.await
.unwrap()
.into_iter()
.map(|j| j.job_id)
.collect();
suspend(&f, &f.carol).await;
super::super::bridge::project_roles(&f.vtc.state, false)
.await
.unwrap();
let new_jobs: Vec<_> = super::super::bridge::list_jobs(&f.vtc.state.git_ns.jobs_ks)
.await
.unwrap()
.into_iter()
.filter(|j| {
j.kind == JobKind::ProjectRoles
&& j.payload["repo"] == RES
&& !before.contains(&j.job_id)
})
.collect();
assert_eq!(
new_jobs.len(),
1,
"one role job for the suspension: {new_jobs:?}"
);
let queued = &new_jobs[0];
assert!(!names_account(
queued.payload["desiredRoles"].as_array().unwrap(),
"5550001"
));
super::super::bridge::dispatch_due(&f.vtc.state)
.await
.unwrap();
assert_eq!(sent_jobs(&f), sent + 1, "the withdrawal job went out");
let withdrawn = last_sent_roles(&f);
assert!(!names_account(&withdrawn, "5550001"), "{withdrawn:?}");
assert!(names_account(&withdrawn, "9120045"));
lift(&f, &f.carol).await;
super::super::bridge::project_roles(&f.vtc.state, false)
.await
.unwrap();
super::super::bridge::dispatch_due(&f.vtc.state)
.await
.unwrap();
assert_eq!(sent_jobs(&f), sent + 2, "the re-projection job went out");
let back = last_sent_roles(&f);
assert!(back.contains(&carol_role), "{back:?}");
}
#[tokio::test]
async fn landing_after_suspension_removes_rights_once() {
let (f, _ns) = suspension_fixture().await;
let registry = MockRegistryClient::new();
let mut backoff = Backoff::default();
projection::reconcile(&f.vtc.state, ®istry, TEST_VTC_DID, &mut backoff)
.await
.unwrap();
suspend(&f, &f.carol).await;
projector(&f, ®istry).run_once().await;
assert!(records_of(®istry.trust_records().await, &f.carol.did).is_empty());
let jobs = sent_jobs(&f);
crate::acl::delete_acl_entry(&f.vtc.state.acl_ks, &f.carol.did)
.await
.unwrap();
let mid = projection::reconcile(&f.vtc.state, ®istry, TEST_VTC_DID, &mut backoff)
.await
.unwrap();
assert_eq!((mid.put, mid.deleted, mid.failed), (0, 0, 0), "{mid:?}");
lift(&f, &f.carol).await;
let after = projection::reconcile(&f.vtc.state, ®istry, TEST_VTC_DID, &mut backoff)
.await
.unwrap();
assert_eq!(
(after.put, after.deleted, after.failed),
(0, 0, 0),
"nothing withdrawn twice, nothing republished: {after:?}"
);
projector(&f, ®istry).run_once().await;
assert!(records_of(®istry.trust_records().await, &f.carol.did).is_empty());
assert_eq!(sent_jobs(&f), jobs, "her role was already withdrawn");
assert!(stored_rows_of(&f, &f.carol.did).await.is_empty());
assert_eq!(audited(&f, "gitNs.projection.released").await.len(), 1);
assert!(audited(&f, "gitNs.projection.restored").await.is_empty());
}
#[tokio::test]
async fn reconcile_converges_projection_after_crash_between_marker_and_withdraw() {
let (f, _ns) = suspension_fixture().await;
let registry = MockRegistryClient::new();
projector(&f, ®istry).run_once().await;
let before = registry.trust_records().await;
suspend(&f, &f.carol).await;
registry
.fail_next_trust_record(crate::registry::RegistryError::Transient(
"registry down".into(),
))
.await;
let mut restarted = projector(&f, ®istry);
restarted.run_once().await;
let mut again = projector(&f, ®istry);
again.run_once().await;
assert!(records_of(®istry.trust_records().await, &f.carol.did).is_empty());
assert!(!names_account(&last_sent_roles(&f), "5550001"));
lift(&f, &f.carol).await;
projector(&f, ®istry).run_once().await;
assert_eq!(registry.trust_records().await, before);
assert!(names_account(&last_sent_roles(&f), "5550001"));
}
#[tokio::test]
async fn write_paths_never_persist_the_projection_view() {
let (f, _ns) = suspension_fixture().await;
let rows_before = stored_rows_of(&f, &f.carol.did).await;
let grants_before = grants_of(&f, &f.carol.did).await;
suspend(&f, &f.carol).await;
let registry = MockRegistryClient::new();
projector(&f, ®istry).run_once().await;
ok(&grant(&f, &f.bob, &f.admin.did, "git.commit.sign", RES).await);
super::super::lifecycle::sweep(&f.vtc.state).await.unwrap();
super::super::bridge::project_roles(&f.vtc.state, true)
.await
.unwrap();
projector(&f, ®istry).run_once().await;
assert_eq!(stored_rows_of(&f, &f.carol.did).await, rows_before);
assert_eq!(grants_of(&f, &f.carol.did).await, grants_before);
let view = ProjectionView::load(&f.vtc.state).await.unwrap();
let repo_id = Snapshot::load(&f.vtc.state.git_ns)
.await
.unwrap()
.repo_at(RES)
.unwrap()
.id
.clone();
assert!(
view.rows(&Scope::Repo(repo_id))
.iter()
.all(|r| r.subject != f.carol.did)
);
}