#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use std::collections::VecDeque;
use std::sync::Mutex;
use vti_common::audit::{
AuditKeyStore, AuditWriter, MemberAddedData, MemberRemovedData, RoleChangedData,
};
use vti_common::config::StoreConfig;
use vti_common::store::Store;
const VTC: &str = "did:webvh:vtc.example";
const ALICE: &str = "did:webvh:alice.example";
struct TestKs {
audit: KeyspaceHandle,
queue: KeyspaceHandle,
cursor: KeyspaceHandle,
writer: AuditWriter,
_dir: tempfile::TempDir,
}
async fn temp_keyspaces() -> TestKs {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("store");
let audit = store.keyspace("audit").unwrap();
let audit_key = store.keyspace("audit_key").unwrap();
let queue = store.keyspace("hooks_queue").unwrap();
let cursor = store.keyspace("hooks_cursor").unwrap();
let key_store = AuditKeyStore::new(audit_key);
key_store.ensure_initial(&[0xAB; 32]).await.unwrap();
let writer = AuditWriter::new(audit.clone(), key_store);
TestKs {
audit,
queue,
cursor,
writer,
_dir: dir,
}
}
fn config() -> GitTrustHooksConfig {
GitTrustHooksConfig {
grant_on_role: BTreeMap::from([
("maintainer".to_string(), "openvtc".to_string()),
("committer".to_string(), "openvtc/openvtc".to_string()),
]),
revoke_with_membership: true,
}
}
#[derive(Default)]
struct MockWriter {
outcomes: Mutex<VecDeque<Result<WriteOutcome, HookWriteError>>>,
written: Mutex<Vec<HookJob>>,
}
impl MockWriter {
fn script(&self, outcome: Result<WriteOutcome, HookWriteError>) {
self.outcomes.lock().unwrap().push_back(outcome);
}
fn written(&self) -> Vec<HookJob> {
self.written.lock().unwrap().clone()
}
}
#[async_trait]
impl CapabilityWriter for MockWriter {
async fn write(&self, job: &HookJob) -> Result<WriteOutcome, HookWriteError> {
self.written.lock().unwrap().push(job.clone());
self.outcomes
.lock()
.unwrap()
.pop_front()
.unwrap_or(Ok(WriteOutcome::Success))
}
}
fn relay(ks: &TestKs, writer: Arc<MockWriter>) -> HookRelay {
HookRelay::new(
ks.audit.clone(),
ks.queue.clone(),
ks.cursor.clone(),
config(),
writer,
)
}
async fn add_member(ks: &TestKs, role: &str) {
ks.writer
.write(
VTC,
Some(ALICE),
AuditEvent::MemberAdded(MemberAddedData {
role: role.into(),
via_join_request_id: None,
}),
)
.await
.unwrap();
}
#[tokio::test]
async fn member_added_grants_only_mapped_roles() {
let ks = temp_keyspaces().await;
add_member(&ks, "maintainer").await;
let writer = Arc::new(MockWriter::default());
let relay = relay(&ks, writer.clone());
relay.walk_audit_tail().await.unwrap();
let jobs = list_jobs(&ks.queue).await.unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].op, HookOp::Grant);
assert_eq!(jobs[0].subject_did, ALICE);
assert_eq!(jobs[0].resource, "openvtc");
let ks2 = temp_keyspaces().await;
add_member(&ks2, "member").await;
let relay2 = relay_for(&ks2);
relay2.walk_audit_tail().await.unwrap();
assert!(list_jobs(&ks2.queue).await.unwrap().is_empty());
}
fn relay_for(ks: &TestKs) -> HookRelay {
relay(ks, Arc::new(MockWriter::default()))
}
#[tokio::test]
async fn role_change_enqueues_revoke_then_grant_in_order() {
let ks = temp_keyspaces().await;
ks.writer
.write(
VTC,
Some(ALICE),
AuditEvent::RoleChanged(RoleChangedData {
previous_role: "committer".into(),
new_role: "maintainer".into(),
}),
)
.await
.unwrap();
let relay = relay_for(&ks);
relay.walk_audit_tail().await.unwrap();
let jobs = list_jobs(&ks.queue).await.unwrap();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].op, HookOp::Revoke);
assert_eq!(jobs[0].resource, "openvtc/openvtc");
assert_eq!(jobs[1].op, HookOp::Grant);
assert_eq!(jobs[1].resource, "openvtc");
}
#[tokio::test]
async fn member_removed_revokes_every_mapped_resource() {
let ks = temp_keyspaces().await;
ks.writer
.write(
VTC,
Some(ALICE),
AuditEvent::MemberRemoved(MemberRemovedData {
disposition: "tombstone".into(),
reason: String::new(),
prior_role: None,
}),
)
.await
.unwrap();
let relay = relay_for(&ks);
relay.walk_audit_tail().await.unwrap();
let jobs = list_jobs(&ks.queue).await.unwrap();
assert_eq!(jobs.len(), 2, "one revoke per distinct mapped resource");
assert!(jobs.iter().all(|j| j.op == HookOp::Revoke));
}
#[tokio::test]
async fn tail_walk_is_cursor_deduplicated_and_dispatch_drains() {
let ks = temp_keyspaces().await;
add_member(&ks, "maintainer").await;
let writer = Arc::new(MockWriter::default());
let relay = relay(&ks, writer.clone());
relay.walk_audit_tail().await.unwrap();
relay.walk_audit_tail().await.unwrap(); assert_eq!(list_jobs(&ks.queue).await.unwrap().len(), 1);
relay.dispatch_due().await.unwrap();
assert!(list_jobs(&ks.queue).await.unwrap().is_empty());
assert_eq!(writer.written().len(), 1);
}
#[tokio::test]
async fn idempotent_success_completes_the_job() {
let ks = temp_keyspaces().await;
add_member(&ks, "maintainer").await;
let writer = Arc::new(MockWriter::default());
writer.script(Ok(WriteOutcome::IdempotentSuccess));
let relay = relay(&ks, writer.clone());
relay.walk_audit_tail().await.unwrap();
relay.dispatch_due().await.unwrap();
assert!(
list_jobs(&ks.queue).await.unwrap().is_empty(),
"already_granted/not_granted means the end state holds — job done"
);
}
#[tokio::test]
async fn rejection_is_terminal_and_loud() {
let ks = temp_keyspaces().await;
add_member(&ks, "maintainer").await;
let writer = Arc::new(MockWriter::default());
writer.script(Ok(WriteOutcome::Rejected {
code: "unsupportedType".into(),
message: Some("git-trust is not enabled".into()),
}));
let relay = relay(&ks, writer.clone());
relay.walk_audit_tail().await.unwrap();
relay.dispatch_due().await.unwrap();
let jobs = list_jobs(&ks.queue).await.unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].state, HookJobState::Failed);
assert!(jobs[0].last_error.as_deref().unwrap().contains("rejected"));
}
#[tokio::test]
async fn revokes_retry_past_the_grant_budget_grants_do_not() {
let ks = temp_keyspaces().await;
let mut revoke = HookJob::new(
"seq-r".into(),
HookOp::Revoke,
ALICE.into(),
"openvtc".into(),
None,
Utc::now(),
);
revoke.attempts = GRANT_MAX_ATTEMPTS + 5;
store_job(&ks.queue, &revoke).await.unwrap();
let mut grant = HookJob::new(
"seq-g".into(),
HookOp::Grant,
ALICE.into(),
"openvtc".into(),
None,
Utc::now(),
);
grant.attempts = GRANT_MAX_ATTEMPTS - 1;
store_job(&ks.queue, &grant).await.unwrap();
let writer = Arc::new(MockWriter::default());
writer.script(Err(HookWriteError::Unreachable("registry down".into())));
writer.script(Err(HookWriteError::Unreachable("registry down".into())));
let relay = relay(&ks, writer.clone());
relay.dispatch_due().await.unwrap();
let jobs = list_jobs(&ks.queue).await.unwrap();
let revoke_after = jobs.iter().find(|j| j.op == HookOp::Revoke).unwrap();
let grant_after = jobs.iter().find(|j| j.op == HookOp::Grant).unwrap();
assert_eq!(
revoke_after.state,
HookJobState::Pending,
"revocation is delivery-critical: it retries indefinitely"
);
assert!(revoke_after.next_attempt_at > Utc::now());
assert_eq!(
grant_after.state,
HookJobState::Failed,
"grants exhaust their retry budget"
);
}
#[tokio::test]
async fn boot_recovery_flips_in_flight_back_to_pending() {
let ks = temp_keyspaces().await;
let mut job = HookJob::new(
"seq-x".into(),
HookOp::Grant,
ALICE.into(),
"openvtc".into(),
None,
Utc::now(),
);
job.state = HookJobState::InFlight;
store_job(&ks.queue, &job).await.unwrap();
let relay = relay_for(&ks);
relay.recover_in_flight().await.unwrap();
let jobs = list_jobs(&ks.queue).await.unwrap();
assert_eq!(jobs[0].state, HookJobState::Pending);
}
#[tokio::test]
async fn a_resource_inside_a_bound_git_namespace_is_left_to_the_projection() {
use crate::git_ns::model::{Mode, Namespace, NamespaceState};
let ks = temp_keyspaces().await;
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let git_ns_ks = store.keyspace("git_ns").unwrap();
crate::git_ns::store::put_namespace(
&git_ns_ks,
&Namespace {
id: "ns1".into(),
forge: "github.com".into(),
owner: "acme".into(),
mode: Mode::Manual,
state: NamespaceState::Bound,
owner_id: None,
kind: None,
bridge_did: None,
bind_job_id: None,
bound_by: VTC.into(),
requested_at: Utc::now(),
bound_at: Some(Utc::now()),
roles_digest: None,
installation_removed: false,
forge_status: None,
role_map: None,
},
)
.await
.unwrap();
let cfg = GitTrustHooksConfig {
grant_on_role: BTreeMap::from([
(
"maintainer".to_string(),
"github.com/acme/widgets".to_string(),
),
("committer".to_string(), "openvtc/openvtc".to_string()),
]),
revoke_with_membership: true,
};
for (role, resource) in [
("maintainer", "github.com/acme/widgets"),
("committer", "openvtc/openvtc"),
] {
let _ = role;
store_job(
&ks.queue,
&HookJob::new(
format!("seq-{resource}"),
HookOp::Grant,
ALICE.into(),
resource.into(),
None,
Utc::now(),
),
)
.await
.unwrap();
}
let writer = Arc::new(MockWriter::default());
let relay = HookRelay::new(
ks.audit.clone(),
ks.queue.clone(),
ks.cursor.clone(),
cfg,
writer.clone(),
)
.with_git_ns(git_ns_ks);
relay.dispatch_due().await.unwrap();
let written: Vec<String> = writer.written().into_iter().map(|j| j.resource).collect();
assert_eq!(written, vec!["openvtc/openvtc".to_string()]);
assert!(
list_jobs(&ks.queue).await.unwrap().is_empty(),
"the git-ns resource's job is dropped, not left to retry"
);
}