pub mod effects;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::AppError;
use crate::store::{Batch, KeyspaceHandle};
const PENDING_PREFIX: &[u8] = b"pending:";
const GRANT_PREFIX: &[u8] = b"grant:";
const WIRE_INDEX_PREFIX: &[u8] = b"wire:";
const DIGEST_DOMAIN: &[u8] = b"vta/task-consent/v1\0";
pub fn payload_digest(type_uri: &str, payload: &serde_json::Value) -> Result<String, AppError> {
digest_with(type_uri, payload, None)
}
pub fn wire_digest(
type_uri: &str,
payload: &serde_json::Value,
challenge: &str,
) -> Result<String, AppError> {
digest_with(type_uri, payload, Some(challenge))
}
fn digest_with(
type_uri: &str,
payload: &serde_json::Value,
challenge: Option<&str>,
) -> Result<String, AppError> {
domain_digest(DIGEST_DOMAIN, type_uri, payload, challenge)
}
pub fn domain_digest(
domain: &[u8],
type_uri: &str,
payload: &serde_json::Value,
salt: Option<&str>,
) -> Result<String, AppError> {
let canonical = serde_json_canonicalizer::to_string(payload)
.map_err(|e| AppError::Internal(format!("payload JCS canonicalization failed: {e}")))?;
let mut h = Sha256::new();
h.update(domain);
h.update((type_uri.len() as u64).to_be_bytes());
h.update(type_uri.as_bytes());
h.update((canonical.len() as u64).to_be_bytes());
h.update(canonical.as_bytes());
if let Some(c) = salt {
h.update(c.as_bytes());
}
Ok(encode_digest_multibase(&h.finalize()))
}
const MULTIHASH_SHA2_256_32: [u8; 2] = [0x12, 0x20];
fn encode_digest_multibase(digest: &[u8]) -> String {
let mut buf = Vec::with_capacity(MULTIHASH_SHA2_256_32.len() + digest.len());
buf.extend_from_slice(&MULTIHASH_SHA2_256_32);
buf.extend_from_slice(digest);
multibase::encode(multibase::Base::Base58Btc, &buf)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingTaskConsent {
pub digest: String,
pub wire_digest: String,
pub type_uri: String,
pub requester_did: String,
pub approver_set: String,
pub min_approvals: u32,
pub exclude_requester: bool,
pub challenge: String,
#[serde(default)]
pub correlator: String,
pub approvals: Vec<String>,
#[serde(default)]
pub state_pin: Option<effects::StatePin>,
#[serde(default)]
pub guards: crate::guards::Guards,
#[serde(default)]
pub subject_context: Option<String>,
#[serde(default = "default_true")]
pub requester_authorized: bool,
pub created_at: u64,
pub expires_at: u64,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskConsentGrant {
pub digest: String,
pub requester_did: String,
pub type_uri: String,
pub approvers: Vec<String>,
#[serde(default)]
pub state_pin: Option<effects::StatePin>,
#[serde(default)]
pub guards: crate::guards::Guards,
#[serde(default)]
pub delegated_contexts: Vec<String>,
pub granted_at: u64,
pub expires_at: u64,
}
fn pending_key(digest: &str) -> Vec<u8> {
[PENDING_PREFIX, digest.as_bytes()].concat()
}
fn wire_index_key(wire_digest: &str) -> Vec<u8> {
[WIRE_INDEX_PREFIX, wire_digest.as_bytes()].concat()
}
fn grant_key(requester_did: &str, digest: &str) -> Vec<u8> {
[
GRANT_PREFIX,
digest.as_bytes(),
b":",
requester_did.as_bytes(),
]
.concat()
}
fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, AppError> {
serde_json::from_slice(bytes)
.map_err(|e| AppError::Internal(format!("task-consent decode: {e}")))
}
const WRITE_ATTEMPTS: usize = 64;
pub async fn store_pending(ks: &KeyspaceHandle, p: &PendingTaskConsent) -> Result<(), AppError> {
let mut batch = Batch::new();
batch.insert(pending_key(&p.digest), p)?;
batch.insert_raw(wire_index_key(&p.wire_digest), p.digest.as_bytes().to_vec());
ks.apply_batch(batch).await?;
Ok(())
}
pub async fn pending_by_wire_digest(
ks: &KeyspaceHandle,
wire_digest: &str,
now: u64,
) -> Result<Option<PendingTaskConsent>, AppError> {
let Some(bytes) = ks.get_raw(wire_index_key(wire_digest)).await? else {
return Ok(None);
};
let internal = String::from_utf8(bytes)
.map_err(|e| AppError::Internal(format!("wire index value not utf-8: {e}")))?;
get_pending(ks, &internal, now).await
}
pub async fn get_pending(
ks: &KeyspaceHandle,
digest: &str,
now: u64,
) -> Result<Option<PendingTaskConsent>, AppError> {
Ok(read_live_pending(ks, digest, now).await?.map(|(_, p)| p))
}
async fn read_live_pending(
ks: &KeyspaceHandle,
digest: &str,
now: u64,
) -> Result<Option<(Vec<u8>, PendingTaskConsent)>, AppError> {
let key = pending_key(digest);
let Some(b) = ks.get_raw(key.clone()).await? else {
return Ok(None);
};
let p: PendingTaskConsent = decode(&b)?;
if p.expires_at <= now {
let mut batch = Batch::new();
batch
.require_unchanged(key.clone(), b)
.remove(key)
.remove(wire_index_key(&p.wire_digest));
ks.apply_batch(batch).await?;
return Ok(None);
}
Ok(Some((b, p)))
}
pub async fn delete_pending(ks: &KeyspaceHandle, p: &PendingTaskConsent) -> Result<(), AppError> {
let mut batch = Batch::new();
batch
.remove(pending_key(&p.digest))
.remove(wire_index_key(&p.wire_digest));
ks.apply_batch(batch).await?;
Ok(())
}
pub async fn add_approval(
ks: &KeyspaceHandle,
digest: &str,
approver_did: &str,
now: u64,
) -> Result<Option<PendingTaskConsent>, AppError> {
for _ in 0..WRITE_ATTEMPTS {
let Some((raw, mut p)) = read_live_pending(ks, digest, now).await? else {
return Ok(None);
};
if p.approvals.iter().any(|a| a == approver_did) {
return Ok(Some(p));
}
p.approvals.push(approver_did.to_string());
let key = pending_key(digest);
let mut batch = Batch::new();
batch.require_unchanged(key.clone(), raw).insert(key, &p)?;
if ks.apply_batch(batch).await?.is_applied() {
return Ok(Some(p));
}
}
Err(AppError::Conflict(format!(
"task-consent pending {digest} kept changing while an approval was recorded"
)))
}
pub async fn grant_from_pending(
ks: &KeyspaceHandle,
grant: &TaskConsentGrant,
now: u64,
) -> Result<bool, AppError> {
for _ in 0..WRITE_ATTEMPTS {
let Some((raw, p)) = read_live_pending(ks, &grant.digest, now).await? else {
return Ok(false);
};
if p.requester_did != grant.requester_did
|| p.type_uri != grant.type_uri
|| !grant.approvers.iter().all(|a| p.approvals.contains(a))
|| (p.approvals.len() as u32) < p.min_approvals
{
return Ok(false);
}
let key = pending_key(&p.digest);
let mut batch = Batch::new();
batch
.require_unchanged(key.clone(), raw)
.insert(grant_key(&grant.requester_did, &grant.digest), grant)?
.remove(key)
.remove(wire_index_key(&p.wire_digest));
if ks.apply_batch(batch).await?.is_applied() {
return Ok(true);
}
}
Err(AppError::Conflict(format!(
"task-consent pending {} kept changing while its grant was minted",
grant.digest
)))
}
pub async fn sweep_expired(ks: &KeyspaceHandle, now: u64) -> Result<usize, AppError> {
let mut pruned = 0usize;
for (key, value) in ks.prefix_iter_raw("pending:").await? {
match serde_json::from_slice::<PendingTaskConsent>(&value) {
Ok(p) if p.expires_at <= now => {
let mut batch = Batch::new();
batch
.require_unchanged(key.clone(), value)
.remove(key)
.remove(wire_index_key(&p.wire_digest));
if ks.apply_batch(batch).await?.is_applied() {
pruned += 1;
}
}
Ok(_) => {}
Err(e) => tracing::debug!(error = %e, "task-consent sweeper: unreadable pending row"),
}
}
for (key, value) in ks.prefix_iter_raw("grant:").await? {
match serde_json::from_slice::<TaskConsentGrant>(&value) {
Ok(g) if g.expires_at <= now => {
let mut batch = Batch::new();
batch.require_unchanged(key.clone(), value).remove(key);
if ks.apply_batch(batch).await?.is_applied() {
pruned += 1;
}
}
Ok(_) => {}
Err(e) => tracing::debug!(error = %e, "task-consent sweeper: unreadable grant row"),
}
}
Ok(pruned)
}
pub async fn remove_pending_if_present(
ks: &KeyspaceHandle,
p: &PendingTaskConsent,
) -> Result<bool, AppError> {
let mut batch = Batch::new();
batch
.require_present(pending_key(&p.digest))
.remove(pending_key(&p.digest))
.remove(wire_index_key(&p.wire_digest));
Ok(ks.apply_batch(batch).await?.is_applied())
}
pub async fn store_grant(ks: &KeyspaceHandle, g: &TaskConsentGrant) -> Result<(), AppError> {
ks.insert(grant_key(&g.requester_did, &g.digest), g).await
}
pub async fn consume_grant(
ks: &KeyspaceHandle,
requester_did: &str,
type_uri: &str,
digest: &str,
now: u64,
) -> Result<Option<TaskConsentGrant>, AppError> {
let Some(bytes) = ks.take_raw(grant_key(requester_did, digest)).await? else {
return Ok(None);
};
let grant: TaskConsentGrant = decode(&bytes)?;
if grant.expires_at <= now {
return Ok(None);
}
if grant.type_uri != type_uri {
return Err(AppError::Internal(format!(
"task-consent grant type mismatch: granted for '{}', presented for '{type_uri}'",
grant.type_uri
)));
}
Ok(Some(grant))
}
pub async fn get_grant(
ks: &KeyspaceHandle,
requester_did: &str,
digest: &str,
now: u64,
) -> Result<Option<TaskConsentGrant>, AppError> {
let key = grant_key(requester_did, digest);
let Some(bytes) = ks.get_raw(key.clone()).await? else {
return Ok(None);
};
let grant: TaskConsentGrant = decode(&bytes)?;
if grant.expires_at <= now {
let mut batch = Batch::new();
batch.require_unchanged(key.clone(), bytes).remove(key);
ks.apply_batch(batch).await?;
return Ok(None);
}
Ok(Some(grant))
}
pub async fn discard_grant(
ks: &KeyspaceHandle,
requester_did: &str,
digest: &str,
) -> Result<(), AppError> {
ks.remove(grant_key(requester_did, digest)).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
use serde_json::json;
async fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
(store.keyspace("task_consent").unwrap(), dir)
}
const T_UPDATE: &str = "https://trusttasks.org/spec/webvh/dids/update/1.0";
const T_ROTATE: &str = "https://trusttasks.org/spec/webvh/dids/rotate-keys/1.0";
#[test]
fn digest_is_deterministic_and_key_order_independent() {
let a = payload_digest(T_UPDATE, &json!({ "b": 2, "a": 1 })).unwrap();
let b = payload_digest(T_UPDATE, &json!({ "a": 1, "b": 2 })).unwrap();
assert_eq!(a, b, "JCS canonicalization must ignore key order");
assert_ne!(
a,
payload_digest(T_UPDATE, &json!({ "a": 1, "b": 3 })).unwrap()
);
assert!(a.starts_with('z'), "must be multibase base58btc, got {a}");
let (base, bytes) = multibase::decode(&a).expect("valid multibase");
assert_eq!(base, multibase::Base::Base58Btc);
assert_eq!(
&bytes[..2],
&[0x12, 0x20],
"must carry the sha2-256 multihash prefix in-band"
);
assert_eq!(bytes.len(), 34, "2-byte multihash prefix + 32-byte digest");
}
#[test]
fn digest_binds_the_type_uri() {
let payload = json!({ "did": "did:webvh:example.com:abc", "contextId": "default" });
assert_ne!(
payload_digest(T_UPDATE, &payload).unwrap(),
payload_digest(T_ROTATE, &payload).unwrap(),
"same payload under a different task URI must not collide"
);
}
#[test]
fn wire_digest_is_salted_and_distinct_from_the_internal_one() {
let p = json!({ "did": "did:webvh:example.com:acme" });
let internal = payload_digest(T_UPDATE, &p).unwrap();
let a = wire_digest(T_UPDATE, &p, "challenge-aaaa").unwrap();
let b = wire_digest(T_UPDATE, &p, "challenge-bbbb").unwrap();
assert_ne!(
internal, a,
"the wire digest must not equal the storage key"
);
assert_ne!(
a, b,
"a different challenge must yield a different wire digest"
);
assert_eq!(a, wire_digest(T_UPDATE, &p, "challenge-aaaa").unwrap());
}
#[test]
fn wire_digest_binds_the_type_uri() {
let p = json!({ "did": "did:webvh:example.com:acme" });
assert_ne!(
wire_digest(T_UPDATE, &p, "c").unwrap(),
wire_digest(T_ROTATE, &p, "c").unwrap()
);
}
#[tokio::test]
async fn a_decision_resolves_its_pending_by_wire_digest() {
let (ks, _d) = temp_ks().await;
let p = pending("deadbeef", 1);
store_pending(&ks, &p).await.unwrap();
let found = pending_by_wire_digest(&ks, &p.wire_digest, 200)
.await
.unwrap()
.expect("resolved via the wire index");
assert_eq!(found.digest, "deadbeef");
assert!(
pending_by_wire_digest(&ks, "not-a-digest", 200)
.await
.unwrap()
.is_none()
);
delete_pending(&ks, &p).await.unwrap();
assert!(
pending_by_wire_digest(&ks, &p.wire_digest, 200)
.await
.unwrap()
.is_none()
);
}
#[test]
fn digest_uri_payload_boundary_is_unambiguous() {
assert_ne!(
payload_digest("ab", &json!("c")).unwrap(),
payload_digest("a", &json!("bc")).unwrap(),
);
}
#[test]
fn digest_matches_its_pinned_vectors() {
const URI: &str = "https://trusttasks.org/spec/acl/grant/0.1";
let p = json!({ "role": "admin", "did": "did:key:z6MkA" });
assert_eq!(
payload_digest(URI, &p).unwrap(),
"zQmNsLmSgtT4jrcgWEC8nHhjambJmen2phxzMKVrCoDWL4Q"
);
assert_eq!(
wire_digest(URI, &p, "chal").unwrap(),
"zQmYdqiqiHsozX3N5NRnM6CmYRwWmPo4P5Pu1MVopiCfwa2"
);
assert_eq!(
domain_digest(b"vtc/step-up/v1\0", URI, &p, None).unwrap(),
"zQmcC7Puan5QbvK5HZ5HWSzrfr7m9pLk8ajR6JRfvB2BG7k"
);
assert_eq!(
domain_digest(b"vtc/step-up/v1\0", URI, &p, Some("chal")).unwrap(),
"zQmc1qnh2HJTpPzHCsPV81Dred9WPgqRKBooU7WYZdGqJEi"
);
}
fn pending(digest: &str, min: u32) -> PendingTaskConsent {
PendingTaskConsent {
digest: digest.into(),
wire_digest: format!("wire-{digest}"),
correlator: "urn:uuid:test-correlator".into(),
state_pin: None,
guards: Default::default(),
type_uri: "https://…/dids/update/1.0".into(),
requester_did: "did:key:zReq".into(),
approver_set: "operators".into(),
min_approvals: min,
exclude_requester: true,
challenge: "nonce123".into(),
approvals: vec![],
subject_context: None,
requester_authorized: true,
created_at: 100,
expires_at: 1000,
}
}
#[tokio::test]
async fn approvals_accumulate_idempotently() {
let (ks, _d) = temp_ks().await;
store_pending(&ks, &pending("deadbeef", 2)).await.unwrap();
let p = add_approval(&ks, "deadbeef", "did:key:zA", 200)
.await
.unwrap()
.unwrap();
assert_eq!(p.approvals.len(), 1);
let p = add_approval(&ks, "deadbeef", "did:key:zA", 200)
.await
.unwrap()
.unwrap();
assert_eq!(p.approvals.len(), 1);
let p = add_approval(&ks, "deadbeef", "did:key:zB", 200)
.await
.unwrap()
.unwrap();
assert_eq!(p.approvals.len(), 2);
assert!(p.approvals.len() as u32 >= p.min_approvals);
assert!(
add_approval(&ks, "nope", "did:key:zA", 200)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn expired_pending_reads_as_absent_and_cannot_be_approved() {
let (ks, _d) = temp_ks().await;
store_pending(&ks, &pending("deadbeef", 1)).await.unwrap();
assert!(get_pending(&ks, "deadbeef", 999).await.unwrap().is_some());
assert!(get_pending(&ks, "deadbeef", 1000).await.unwrap().is_none());
assert!(
add_approval(&ks, "deadbeef", "did:key:zA", 1001)
.await
.unwrap()
.is_none(),
"an expired pending must not accept approvals"
);
assert!(get_pending(&ks, "deadbeef", 500).await.unwrap().is_none());
}
#[tokio::test]
async fn sweeper_prunes_lapsed_pendings_and_grants() {
let (ks, _d) = temp_ks().await;
store_pending(&ks, &pending("d1", 1)).await.unwrap(); store_grant(&ks, &grant("d2", 500)).await.unwrap();
assert_eq!(
sweep_expired(&ks, 400).await.unwrap(),
0,
"nothing lapsed yet"
);
assert_eq!(sweep_expired(&ks, 2000).await.unwrap(), 2, "both lapsed");
assert_eq!(sweep_expired(&ks, 2000).await.unwrap(), 0, "idempotent");
}
fn grant(digest: &str, expires_at: u64) -> TaskConsentGrant {
TaskConsentGrant {
digest: digest.into(),
state_pin: None,
guards: Default::default(),
requester_did: "did:key:zReq".into(),
type_uri: T_UPDATE.into(),
approvers: vec!["did:key:zA".into()],
delegated_contexts: vec![],
granted_at: 100,
expires_at,
}
}
#[tokio::test]
async fn grant_is_single_use_and_expiry_checked() {
let (ks, _d) = temp_ks().await;
let g = grant("d1", 500);
store_grant(&ks, &g).await.unwrap();
assert!(
consume_grant(&ks, "did:key:zReq", T_UPDATE, "d1", 200)
.await
.unwrap()
.is_some()
);
assert!(
consume_grant(&ks, "did:key:zReq", T_UPDATE, "d1", 200)
.await
.unwrap()
.is_none()
);
store_grant(&ks, &g).await.unwrap();
assert!(
consume_grant(&ks, "did:key:zReq", T_UPDATE, "d1", 999)
.await
.unwrap()
.is_none()
);
assert!(
consume_grant(&ks, "did:key:zReq", T_UPDATE, "d1", 200)
.await
.unwrap()
.is_none()
);
store_grant(&ks, &g).await.unwrap();
assert!(
consume_grant(&ks, "did:key:zOther", T_UPDATE, "d1", 200)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn get_grant_reads_without_spending() {
let (ks, _d) = temp_ks().await;
store_grant(&ks, &grant("d1", 500)).await.unwrap();
assert!(
get_grant(&ks, "did:key:zReq", "d1", 200)
.await
.unwrap()
.is_some()
);
assert!(
get_grant(&ks, "did:key:zReq", "d1", 200)
.await
.unwrap()
.is_some()
);
assert!(
get_grant(&ks, "did:key:zOther", "d1", 200)
.await
.unwrap()
.is_none()
);
assert!(
consume_grant(&ks, "did:key:zReq", T_UPDATE, "d1", 200)
.await
.unwrap()
.is_some(),
"a read must leave the grant for the consume"
);
store_grant(&ks, &grant("d1", 500)).await.unwrap();
assert!(
get_grant(&ks, "did:key:zReq", "d1", 600)
.await
.unwrap()
.is_none()
);
assert!(
get_grant(&ks, "did:key:zReq", "d1", 200)
.await
.unwrap()
.is_none(),
"an expired grant is removed on read"
);
store_grant(&ks, &grant("d1", 500)).await.unwrap();
discard_grant(&ks, "did:key:zReq", "d1").await.unwrap();
assert!(
get_grant(&ks, "did:key:zReq", "d1", 200)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn grant_refuses_a_different_task_uri() {
let (ks, _d) = temp_ks().await;
store_grant(&ks, &grant("d1", 500)).await.unwrap();
let err = consume_grant(&ks, "did:key:zReq", T_ROTATE, "d1", 200)
.await
.unwrap_err();
assert!(
err.to_string().contains("type mismatch"),
"expected a type-mismatch refusal, got: {err}"
);
}
const ROUNDS: usize = 40;
const RACERS: usize = 16;
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_consumes_spend_a_grant_exactly_once() {
let (ks, _d) = temp_ks().await;
for _ in 0..ROUNDS {
store_grant(&ks, &grant("d1", 500)).await.unwrap();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(RACERS));
let mut tasks = Vec::new();
for _ in 0..RACERS {
let ks = ks.clone();
let barrier = barrier.clone();
tasks.push(tokio::spawn(async move {
barrier.wait().await;
consume_grant(&ks, "did:key:zReq", T_UPDATE, "d1", 200)
.await
.unwrap()
.is_some()
}));
}
let mut hits = 0;
for t in tasks {
hits += t.await.unwrap() as usize;
}
assert_eq!(hits, 1, "a grant must be spent exactly once");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_approvals_are_never_lost() {
let (ks, _d) = temp_ks().await;
for round in 0..ROUNDS {
let digest = format!("d{round}");
store_pending(&ks, &pending(&digest, RACERS as u32))
.await
.unwrap();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(RACERS));
let mut tasks = Vec::new();
for i in 0..RACERS {
let (ks, barrier, digest) = (ks.clone(), barrier.clone(), digest.clone());
tasks.push(tokio::spawn(async move {
barrier.wait().await;
add_approval(&ks, &digest, &format!("did:key:z{i}"), 200)
.await
.unwrap()
}));
}
for t in tasks {
t.await.unwrap();
}
let p = get_pending(&ks, &digest, 200).await.unwrap().unwrap();
assert_eq!(p.approvals.len(), RACERS, "an approval was lost");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn an_approval_never_resurrects_a_denied_pending() {
let (ks, _d) = temp_ks().await;
for round in 0..ROUNDS * 4 {
let p = pending(&format!("d{round}"), 2);
store_pending(&ks, &p).await.unwrap();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(RACERS + 1));
let mut tasks = Vec::new();
for i in 0..RACERS {
let (ks, barrier, digest) = (ks.clone(), barrier.clone(), p.digest.clone());
tasks.push(tokio::spawn(async move {
barrier.wait().await;
add_approval(&ks, &digest, &format!("did:key:z{i}"), 200)
.await
.unwrap();
}));
}
let deny = {
let (ks, barrier, p) = (ks.clone(), barrier.clone(), p.clone());
tokio::spawn(async move {
barrier.wait().await;
delete_pending(&ks, &p).await.unwrap();
})
};
for t in tasks {
t.await.unwrap();
}
deny.await.unwrap();
assert!(
get_pending(&ks, &p.digest, 200).await.unwrap().is_none(),
"a denied pending came back"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn a_pending_resolves_to_exactly_one_grant_or_one_denial() {
let (ks, _d) = temp_ks().await;
for round in 0..ROUNDS {
let digest = format!("d{round}");
let mut p = pending(&digest, 1);
p.approvals = vec!["did:key:zA".into()];
store_pending(&ks, &p).await.unwrap();
let mut g = grant(&digest, 5000);
g.type_uri = p.type_uri.clone();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(RACERS + 1));
let mut grants = Vec::new();
for _ in 0..RACERS {
let (ks, barrier, g) = (ks.clone(), barrier.clone(), g.clone());
grants.push(tokio::spawn(async move {
barrier.wait().await;
grant_from_pending(&ks, &g, 200).await.unwrap()
}));
}
let deny = {
let (ks, barrier, p) = (ks.clone(), barrier.clone(), p.clone());
tokio::spawn(async move {
barrier.wait().await;
remove_pending_if_present(&ks, &p).await.unwrap()
})
};
let mut minted = 0;
for t in grants {
minted += t.await.unwrap() as usize;
}
let denied = deny.await.unwrap() as usize;
assert_eq!(minted + denied, 1, "minted {minted}, denied {denied}");
let granted = get_grant(&ks, "did:key:zReq", &digest, 200)
.await
.unwrap()
.is_some();
assert_eq!(granted, minted == 1, "a grant exists iff one was minted");
assert!(get_pending(&ks, &digest, 200).await.unwrap().is_none());
assert!(
pending_by_wire_digest(&ks, &p.wire_digest, 200)
.await
.unwrap()
.is_none(),
"the wire index must go with its pending"
);
}
}
#[tokio::test]
async fn pending_and_its_index_commit_as_one_journal_record() {
let (ks, _d) = temp_ks().await;
let seq = || ks.journal_seqno().unwrap();
let mut p = pending("d1", 1);
p.approvals = vec!["did:key:zA".into()];
let before = seq();
store_pending(&ks, &p).await.unwrap();
assert_eq!(seq() - before, 1, "store_pending");
let mut g = grant("d1", 5000);
g.type_uri = p.type_uri.clone();
let before = seq();
assert!(grant_from_pending(&ks, &g, 200).await.unwrap());
assert_eq!(seq() - before, 1, "grant_from_pending");
}
#[tokio::test]
async fn grant_from_pending_refuses_unrecorded_approvers() {
let (ks, _d) = temp_ks().await;
let mut p = pending("d1", 1);
p.approvals = vec!["did:key:zA".into()];
store_pending(&ks, &p).await.unwrap();
let mut g = grant("d1", 5000);
g.type_uri = p.type_uri.clone();
g.approvers = vec!["did:key:zB".into()];
assert!(!grant_from_pending(&ks, &g, 200).await.unwrap());
assert!(get_pending(&ks, "d1", 200).await.unwrap().is_some());
g.approvers = vec!["did:key:zA".into()];
assert!(grant_from_pending(&ks, &g, 200).await.unwrap());
assert!(get_pending(&ks, "d1", 200).await.unwrap().is_none());
}
}