use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use serde::Serialize;
use serde_json::Value;
use trust_tasks_rs::{
ErrorPayload, FreshnessPolicy, RejectReason, StandardCode, TrustTask, TypeUri,
VerificationError,
};
use crate::identity::BridgeIdentity;
use trust_tasks_git_ns::Payload as GitNsPayload;
pub use trust_tasks_git_ns::specs::git_ns::bridge::event::v0_1 as event_v0_1;
pub use trust_tasks_git_ns::specs::git_ns::bridge::event::v0_2 as event_v0_2;
pub use trust_tasks_git_ns::specs::git_ns::bridge::event::v0_3 as event_v0_3;
pub use trust_tasks_git_ns::specs::git_ns::bridge::event::v0_4 as event;
pub use trust_tasks_git_ns::specs::git_ns::bridge::job::v0_4 as job_v0_4;
pub use trust_tasks_git_ns::specs::git_ns::bridge::job::v0_5 as job;
pub use trust_tasks_git_ns::specs::git_ns::bridge::result::v0_1 as result;
pub const JOB_TYPE: &str = <job::Payload as GitNsPayload>::TYPE_URI;
pub const JOB_TYPE_V0_4: &str = <job_v0_4::Payload as GitNsPayload>::TYPE_URI;
pub const JOB_TYPES: [&str; 2] = [JOB_TYPE, JOB_TYPE_V0_4];
pub const DISCOVERY_TYPE: &str = "https://trusttasks.org/spec/trust-task-discovery/0.2";
pub fn is_job_type(type_uri: &str) -> bool {
JOB_TYPES.contains(&type_uri)
}
pub fn is_other_job_version(type_uri: &str) -> bool {
!is_job_type(type_uri) && type_uri.starts_with("https://trusttasks.org/spec/git-ns/bridge/job/")
}
pub fn job_kind_allowed(type_uri: &str, kind: job::PayloadKind) -> bool {
type_uri == JOB_TYPE || kind != job::PayloadKind::ClosePullRequest
}
pub fn discovery_answer(patterns: &[String]) -> Value {
const SLUG: &str = "git-ns/bridge/job";
let selects = |p: &str| {
p == "*"
|| p == SLUG
|| p.strip_suffix("/*")
.is_some_and(|prefix| SLUG.starts_with(&format!("{prefix}/")))
};
let listed = patterns.is_empty() || patterns.iter().any(|p| selects(p));
let types: Vec<&str> = if listed {
JOB_TYPES.to_vec()
} else {
Vec::new()
};
serde_json::json!({ "supportedTypes": types })
}
pub fn declared_error(code: trust_tasks_git_ns::DeclaredErrorCode) -> ErrorPayload {
let parsed: trust_tasks_rs::TrustTaskCode = code
.code
.parse()
.expect("a generated error code is a valid extended code");
ErrorPayload::new(parsed).with_retryable(code.retryable)
}
pub fn event_type_uri(version: crate::config::EventVersion) -> &'static str {
use crate::config::EventVersion;
match version {
EventVersion::V0_1 => event_v0_1::Payload::TYPE_URI,
EventVersion::V0_2 => event_v0_2::Payload::TYPE_URI,
EventVersion::V0_3 => event_v0_3::Payload::TYPE_URI,
_ => event::Payload::TYPE_URI,
}
}
pub fn is_event_response_type(type_uri: &str) -> bool {
type_uri == event::Response::TYPE_URI
|| type_uri == event_v0_3::Response::TYPE_URI
|| type_uri == event_v0_2::Response::TYPE_URI
|| type_uri == event_v0_1::Response::TYPE_URI
}
pub fn parse_job(payload: &Value) -> std::result::Result<job::Payload, String> {
serde_json::from_value(payload.clone()).map_err(|e| format!("job payload: {e}"))
}
pub const ENVELOPE_TYPE: &str = "https://trusttasks.org/binding/didcomm/0.1/envelope";
#[async_trait]
pub trait ProofCheck: Send + Sync {
async fn verify_raw(&self, doc: &Value) -> Result<(), VerificationError>;
}
#[async_trait]
impl ProofCheck for trust_tasks_proof::affinidi::Verifier {
async fn verify_raw(&self, doc: &Value) -> Result<(), VerificationError> {
trust_tasks_proof::affinidi::Verifier::verify_raw(self, doc).await
}
}
#[async_trait]
pub trait Evict: Send + Sync {
async fn evict(&self, did: &str);
}
#[async_trait]
impl Evict for affinidi_tdk::did_resolver::DIDCacheClient {
async fn evict(&self, did: &str) {
let _ = self.remove(did).await;
}
}
pub struct ReResolving {
inner: Arc<dyn ProofCheck>,
cache: Arc<dyn Evict>,
last: std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
}
pub const RE_RESOLVE_EVERY: std::time::Duration = std::time::Duration::from_secs(30);
impl ReResolving {
pub fn new(inner: Arc<dyn ProofCheck>, cache: Arc<dyn Evict>) -> Self {
ReResolving {
inner,
cache,
last: Default::default(),
}
}
fn may_re_resolve(&self, did: &str) -> bool {
let mut last = self.last.lock().expect("lock");
let now = std::time::Instant::now();
if last
.get(did)
.is_some_and(|t| now.duration_since(*t) < RE_RESOLVE_EVERY)
{
return false;
}
if last.len() > 1024 {
last.retain(|_, t| now.duration_since(*t) < RE_RESOLVE_EVERY);
}
last.insert(did.to_string(), now);
true
}
}
#[async_trait]
impl ProofCheck for ReResolving {
async fn verify_raw(&self, doc: &Value) -> Result<(), VerificationError> {
match self.inner.verify_raw(doc).await {
Ok(()) => Ok(()),
Err(first) => {
let Some(issuer) = doc.get("issuer").and_then(Value::as_str) else {
return Err(first);
};
if issuer.starts_with("did:key:")
|| issuer.starts_with("did:peer:")
|| !self.may_re_resolve(issuer)
{
return Err(first);
}
tracing::info!(%issuer, "a proof failed against the cached DID document; resolving it again");
self.cache.evict(issuer).await;
self.inner.verify_raw(doc).await
}
}
}
}
#[derive(Debug, Clone)]
pub struct VerifiedDoc {
pub doc: TrustTask<Value>,
}
impl VerifiedDoc {
pub fn type_uri(&self) -> String {
self.doc.type_uri.to_string()
}
}
#[derive(Debug)]
pub struct Refusal {
pub payload: ErrorPayload,
pub doc: Option<TrustTask<Value>>,
}
impl Refusal {
fn standard(
code: StandardCode,
message: impl Into<String>,
doc: Option<TrustTask<Value>>,
) -> Box<Self> {
Box::new(Refusal {
payload: ErrorPayload::new(code).with_message(message),
doc,
})
}
}
pub struct DocChecker {
vtc_did: String,
bridge_did: String,
freshness: FreshnessPolicy,
proof: Arc<dyn ProofCheck>,
}
impl std::fmt::Debug for DocChecker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DocChecker")
.field("vtc_did", &self.vtc_did)
.field("bridge_did", &self.bridge_did)
.finish_non_exhaustive()
}
}
impl DocChecker {
pub fn new(
vtc_did: impl Into<String>,
bridge_did: impl Into<String>,
max_age_secs: u64,
proof: Arc<dyn ProofCheck>,
) -> Self {
DocChecker {
vtc_did: vtc_did.into(),
bridge_did: bridge_did.into(),
freshness: FreshnessPolicy::consequential()
.with_max_age(TimeDelta::seconds(max_age_secs as i64)),
proof,
}
}
pub fn vtc_did(&self) -> &str {
&self.vtc_did
}
pub async fn check(
&self,
raw: &Value,
authenticated_sender: Option<&str>,
) -> Result<VerifiedDoc, Box<Refusal>> {
let doc: TrustTask<Value> = serde_json::from_value(raw.clone()).map_err(|e| {
Refusal::standard(
StandardCode::MalformedRequest,
format!("not a Trust Task document: {e}"),
None,
)
})?;
if doc.issuer.as_deref() != Some(self.vtc_did.as_str()) {
return Err(Refusal::standard(
StandardCode::PermissionDenied,
"this bridge serves one VTC, and this document is not from it",
Some(doc),
));
}
if let Some(sender) = authenticated_sender
&& sender != self.vtc_did
{
return Err(Refusal::standard(
StandardCode::IdentityMismatch,
"the transport-authenticated sender is not the document's issuer",
Some(doc),
));
}
if doc.recipient.as_deref() != Some(self.bridge_did.as_str()) {
return Err(Refusal::standard(
StandardCode::WrongRecipient,
"the document is not addressed to this bridge",
Some(doc),
));
}
if let Err(reason) = doc.validate_freshness(Utc::now(), &self.freshness) {
let payload: ErrorPayload = reason.into();
return Err(Box::new(Refusal {
payload,
doc: Some(doc),
}));
}
if raw.get("proof").is_none() {
return Err(Refusal::standard(
StandardCode::ProofRequired,
"a proof is required on every document from the VTC",
Some(doc),
));
}
if raw.pointer("/proof/proofPurpose").and_then(Value::as_str) != Some("authentication") {
return Err(Refusal::standard(
StandardCode::ProofInvalid,
"the proof's purpose must be authentication",
Some(doc),
));
}
if let Err(e) = self.proof.verify_raw(raw).await {
tracing::warn!(error = %e, id = %doc.id, "refusing a document whose proof does not verify");
let payload: ErrorPayload = RejectReason::ProofInvalid {
reason: e.to_string(),
}
.into();
return Err(Box::new(Refusal {
payload,
doc: Some(doc),
}));
}
Ok(VerifiedDoc { doc })
}
}
pub fn new_id() -> String {
let mut b = [0u8; 16];
aws_lc_rs::rand::fill(&mut b).expect("system RNG");
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let h = hex::encode(b);
format!(
"urn:uuid:{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..32]
)
}
pub async fn signed_request(
identity: &BridgeIdentity,
vtc_did: &str,
type_uri: &str,
payload: Value,
) -> Result<(String, Value)> {
let id = new_id();
let type_uri: TypeUri = type_uri
.parse()
.map_err(|e| anyhow::anyhow!("type URI `{type_uri}`: {e}"))?;
let mut doc = TrustTask::new(id.clone(), type_uri, payload);
doc.thread_id = Some(id.clone());
doc.issuer = Some(identity.did().to_string());
doc.recipient = Some(vtc_did.to_string());
doc.issued_at = Some(Utc::now());
let signed = identity.sign(&serde_json::to_value(&doc)?).await?;
Ok((id, signed))
}
pub async fn signed_response<R: Serialize>(
identity: &BridgeIdentity,
request: &TrustTask<Value>,
payload: R,
) -> Result<Value> {
let resp = request.respond_with(new_id(), payload);
identity.sign(&serde_json::to_value(&resp)?).await
}
pub async fn signed_error(
identity: &BridgeIdentity,
request: &TrustTask<Value>,
payload: ErrorPayload,
) -> Result<Value> {
let mut resp = request.reject_with(new_id(), payload);
resp.issuer = Some(identity.did().to_string());
identity.sign(&serde_json::to_value(&resp)?).await
}
pub fn check_kind_members(p: &job::Payload) -> std::result::Result<(), String> {
use job::PayloadKind as K;
let has = |present: bool, name: &str, allowed: bool, required: bool| {
if required && !present {
Err(format!("`{}` requires `{name}`", p.kind))
} else if present && !allowed {
Err(format!("`{}` does not use `{name}`", p.kind))
} else {
Ok(())
}
};
const NO: (bool, bool) = (false, false);
const MAY: (bool, bool) = (true, false);
const MUST: (bool, bool) = (true, true);
let rules: [(bool, bool); 8] = match p.kind {
K::ProjectRoles => [MUST, NO, MUST, NO, NO, NO, NO, NO],
K::CreateRepo => [MUST, MUST, MAY, NO, NO, NO, NO, NO],
K::Bootstrap => [MUST, NO, NO, MAY, NO, NO, NO, NO],
K::Archive => [MUST, NO, NO, NO, NO, NO, NO, NO],
K::Inspect => [MAY, NO, NO, NO, NO, NO, NO, NO],
K::BeginBind => [NO, NO, NO, NO, MUST, NO, NO, NO],
K::BeginAccountLink => [NO, NO, NO, NO, NO, MUST, NO, NO],
K::ClosePullRequest => [MUST, NO, NO, NO, NO, NO, MUST, MUST],
_ => return Err(format!("unknown job kind `{}`", p.kind)),
};
let present = [
p.repo.is_some(),
p.spec.is_some(),
p.desired_roles.is_some(),
p.steps.is_some(),
p.target.is_some(),
p.subject.is_some(),
p.number.is_some(),
p.message.is_some(),
];
let names = [
"repo",
"spec",
"desiredRoles",
"steps",
"target",
"subject",
"number",
"message",
];
for i in 0..names.len() {
has(present[i], names[i], rules[i].0, rules[i].1)?;
}
if let Some(steps) = &p.steps {
let mut seen = std::collections::BTreeSet::new();
if steps.is_empty() || !steps.iter().all(|s| seen.insert(s.to_string())) {
return Err("`steps` must be a non-empty list of distinct names".into());
}
}
if let Some(roles) = &p.desired_roles {
let mut seen = std::collections::BTreeSet::new();
if !roles
.iter()
.all(|r| seen.insert((r.account.forge.to_string(), r.account.id.to_string())))
{
return Err("`desiredRoles` names one account twice".into());
}
}
check_remove_accounts(p)
}
fn check_remove_accounts(p: &job::Payload) -> std::result::Result<(), String> {
let Some(remove) = &p.remove_accounts else {
return Ok(());
};
if p.kind != job::PayloadKind::ProjectRoles {
return Err(format!("`{}` does not use `removeAccounts`", p.kind));
}
if remove.is_empty() {
return Err("`removeAccounts` must name at least one account".into());
}
let key = |a: &job::ForgeAccount| (a.forge.to_string(), a.id.to_string());
let mut seen = std::collections::BTreeSet::new();
if !remove.iter().all(|a| seen.insert(key(a))) {
return Err("`removeAccounts` names one account twice".into());
}
if let Some(overlap) = p
.desired_roles
.iter()
.flatten()
.find(|r| seen.contains(&key(&r.account)) && r.right != job::Right::GitNsAdmin)
{
return Err(format!(
"account {} on `{}` is in both `desiredRoles` and `removeAccounts`",
*overlap.account.id, *overlap.account.forge
));
}
Ok(())
}
pub fn clip(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max.saturating_sub(1);
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end.saturating_sub(2)])
}
pub fn is_type<P: GitNsPayload>(v: &VerifiedDoc) -> bool {
v.doc.type_uri.to_string() == P::TYPE_URI
}
#[cfg(test)]
mod tests {
struct Stale {
evicted: std::sync::atomic::AtomicBool,
checks: std::sync::atomic::AtomicU32,
fresh_passes: bool,
}
#[async_trait]
impl ProofCheck for Stale {
async fn verify_raw(&self, _doc: &Value) -> Result<(), VerificationError> {
use std::sync::atomic::Ordering::SeqCst;
self.checks.fetch_add(1, SeqCst);
if self.evicted.load(SeqCst) && self.fresh_passes {
Ok(())
} else {
Err(VerificationError::UnsupportedCryptosuite("stale".into()))
}
}
}
#[async_trait]
impl Evict for Stale {
async fn evict(&self, _did: &str) {
self.evicted
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
#[tokio::test]
async fn a_failed_proof_is_checked_once_more_against_a_fresh_document() {
use std::sync::atomic::Ordering::SeqCst;
let doc = serde_json::json!({ "issuer": "did:webvh:QmVtc:acme-vtc.example" });
for fresh_passes in [true, false] {
let s = Arc::new(Stale {
evicted: false.into(),
checks: 0.into(),
fresh_passes,
});
let r = ReResolving::new(s.clone(), s.clone());
assert_eq!(r.verify_raw(&doc).await.is_ok(), fresh_passes);
assert_eq!(
s.checks.load(SeqCst),
2,
"once more, never again (fails closed)"
);
assert!(s.evicted.load(SeqCst));
}
let s = Arc::new(Stale {
evicted: false.into(),
checks: 0.into(),
fresh_passes: false,
});
let r = ReResolving::new(s.clone(), s.clone());
assert!(r.verify_raw(&doc).await.is_err());
assert!(r.verify_raw(&doc).await.is_err());
assert_eq!(
s.checks.load(SeqCst),
3,
"the second failure is not re-resolved"
);
let s = Arc::new(Stale {
evicted: false.into(),
checks: 0.into(),
fresh_passes: true,
});
let r = ReResolving::new(s.clone(), s.clone());
assert!(
r.verify_raw(&serde_json::json!({ "issuer": "did:key:z6Mk" }))
.await
.is_err()
);
assert_eq!(s.checks.load(SeqCst), 1);
}
use super::*;
use serde_json::json;
use trust_tasks_proof::affinidi::Verifier;
fn checker(vtc: &BridgeIdentity, bridge: &str) -> DocChecker {
DocChecker::new(vtc.did(), bridge, 300, Arc::new(Verifier::for_did_key()))
}
async fn job_doc(vtc: &BridgeIdentity, bridge: &str, payload: Value) -> Value {
let doc = json!({
"id": new_id(),
"type": JOB_TYPE,
"issuer": vtc.did(),
"recipient": bridge,
"issuedAt": Utc::now().to_rfc3339(),
"payload": payload,
});
vtc.sign(&doc).await.unwrap()
}
#[tokio::test]
async fn a_signed_fresh_job_from_the_vtc_passes() {
let (vtc, _) = BridgeIdentity::generate_did_key().unwrap();
let (bridge, _) = BridgeIdentity::generate_did_key().unwrap();
let raw = job_doc(
&vtc,
bridge.did(),
json!({"jobId": "j1", "namespace": "ns", "kind": "inspect"}),
)
.await;
let v = checker(&vtc, bridge.did())
.check(&raw, Some(vtc.did()))
.await
.unwrap();
assert!(is_job_type(&v.type_uri()));
let p: job::Payload = serde_json::from_value(v.doc.payload).unwrap();
check_kind_members(&p).unwrap();
}
#[tokio::test]
async fn every_step_refuses_what_it_should() {
let (vtc, _) = BridgeIdentity::generate_did_key().unwrap();
let (other, _) = BridgeIdentity::generate_did_key().unwrap();
let (bridge, _) = BridgeIdentity::generate_did_key().unwrap();
let c = checker(&vtc, bridge.did());
let payload = json!({"jobId": "j1", "namespace": "ns", "kind": "inspect"});
let code = |r: Box<Refusal>| r.payload.code.to_string();
let raw = job_doc(&other, bridge.did(), payload.clone()).await;
assert_eq!(
code(c.check(&raw, None).await.unwrap_err()),
"permissionDenied"
);
let raw = job_doc(&vtc, bridge.did(), payload.clone()).await;
assert_eq!(
code(c.check(&raw, Some(other.did())).await.unwrap_err()),
"identityMismatch"
);
let raw = job_doc(&vtc, other.did(), payload.clone()).await;
assert_eq!(
code(c.check(&raw, None).await.unwrap_err()),
"wrongRecipient"
);
let mut raw = job_doc(&vtc, bridge.did(), payload.clone()).await;
raw["payload"]["kind"] = json!("archive");
assert_eq!(code(c.check(&raw, None).await.unwrap_err()), "proofInvalid");
let mut forged = json!({
"id": new_id(), "type": JOB_TYPE, "issuer": other.did(),
"recipient": bridge.did(), "issuedAt": Utc::now().to_rfc3339(), "payload": payload,
});
forged = other.sign(&forged).await.unwrap();
forged["issuer"] = json!(vtc.did());
assert_eq!(
code(c.check(&forged, None).await.unwrap_err()),
"proofInvalid"
);
let mut raw = job_doc(
&vtc,
bridge.did(),
json!({"jobId": "j", "namespace": "n", "kind": "inspect"}),
)
.await;
raw.as_object_mut().unwrap().remove("proof");
assert_eq!(
code(c.check(&raw, None).await.unwrap_err()),
"proofRequired"
);
let assertion = vtc
.sign_with_purpose(
&json!({
"id": new_id(), "type": JOB_TYPE, "issuer": vtc.did(),
"recipient": bridge.did(), "issuedAt": Utc::now().to_rfc3339(),
"payload": {"jobId": "j", "namespace": "n", "kind": "inspect"},
}),
"assertionMethod",
)
.await
.unwrap();
let r = c.check(&assertion, None).await.unwrap_err();
assert_eq!(r.payload.code.to_string(), "proofInvalid");
assert!(
r.payload
.message
.unwrap_or_default()
.contains("authentication")
);
let mut forged_ka = job_doc(&vtc, bridge.did(), payload.clone()).await;
forged_ka["proof"]["proofPurpose"] = json!("keyAgreement");
assert_eq!(
code(c.check(&forged_ka, None).await.unwrap_err()),
"proofInvalid"
);
let old = json!({
"id": new_id(), "type": JOB_TYPE, "issuer": vtc.did(),
"recipient": bridge.did(), "issuedAt": "2020-01-01T00:00:00Z",
"payload": {"jobId": "j", "namespace": "n", "kind": "inspect"},
});
let old = vtc.sign(&old).await.unwrap();
assert_eq!(code(c.check(&old, None).await.unwrap_err()), "expired");
}
struct AssertionMethodOnly(std::collections::BTreeMap<String, Vec<u8>>);
#[async_trait]
impl trust_tasks_proof::affinidi::ProofPurposeResolver for AssertionMethodOnly {
async fn resolve_vm_for_purpose(
&self,
vm: &str,
purpose: trust_tasks_proof::affinidi::ProofPurpose,
) -> std::result::Result<
affinidi_data_integrity::did_vm::ResolvedKey,
affinidi_data_integrity::DataIntegrityError,
> {
if purpose != trust_tasks_proof::affinidi::ProofPurpose::AssertionMethod {
return Err(affinidi_data_integrity::DataIntegrityError::Resolver(
format!("{vm} is not listed under {purpose} in its DID document"),
));
}
self.0
.get(vm)
.map(|k| {
affinidi_data_integrity::did_vm::ResolvedKey::new(
affinidi_tdk::affinidi_crypto::KeyType::Ed25519,
k.clone(),
)
})
.ok_or_else(|| {
affinidi_data_integrity::DataIntegrityError::Resolver(format!(
"{vm} is not published"
))
})
}
}
#[tokio::test]
async fn an_authentication_proof_whose_key_is_listed_only_under_assertion_method_is_refused() {
let (vtc, _) = BridgeIdentity::generate_did_key().unwrap();
let (bridge, _) = BridgeIdentity::generate_did_key().unwrap();
let k = vtc.git_signing_key().unwrap();
let map = [(
k.verification_method.clone(),
k.key.verifying_key().to_bytes().to_vec(),
)]
.into_iter()
.collect();
let verifier = trust_tasks_proof::affinidi::Verifier::with_resolver(Arc::new(
AssertionMethodOnly(map),
));
let c = DocChecker::new(vtc.did(), bridge.did(), 300, Arc::new(verifier));
let raw = job_doc(
&vtc,
bridge.did(),
json!({"jobId": "j", "namespace": "n", "kind": "inspect"}),
)
.await;
assert_eq!(raw["proof"]["proofPurpose"], "authentication");
let r = c.check(&raw, None).await.unwrap_err();
assert_eq!(r.payload.code.to_string(), "proofInvalid");
}
#[test]
fn kinds_carry_exactly_their_members() {
let parse = |v: Value| serde_json::from_value::<job::Payload>(v).unwrap();
let ok = [
json!({"jobId":"j","namespace":"n","kind":"archive","repo":"github.com/a/b"}),
json!({"jobId":"j","namespace":"n","kind":"inspect"}),
json!({"jobId":"j","namespace":"n","kind":"projectRoles","repo":"github.com/a/b","desiredRoles":[]}),
json!({"jobId":"j","namespace":"n","kind":"beginBind","target":{"forge":"github.com","owner":"acme"}}),
json!({"jobId":"j","namespace":"n","kind":"projectRoles","repo":"github.com/a/b",
"desiredRoles":[{"subject":"did:key:z6MkAdmin","account":{"forge":"github.com","id":"7","login":"a"},"right":"git.ns.admin"}],
"removeAccounts":[{"forge":"github.com","id":"7","login":"a"}]}),
];
for v in ok {
check_kind_members(&parse(v.clone())).unwrap_or_else(|e| panic!("{v}: {e}"));
}
let bad = [
json!({"jobId":"j","namespace":"n","kind":"archive"}),
json!({"jobId":"j","namespace":"n","kind":"inspect","subject":"did:key:z6Mk"}),
json!({"jobId":"j","namespace":"n","kind":"createRepo","repo":"github.com/a/b"}),
json!({"jobId":"j","namespace":"n","kind":"beginAccountLink"}),
json!({"jobId":"j","namespace":"n","kind":"projectRoles","desiredRoles":[]}),
json!({"jobId":"j","namespace":"n","kind":"projectRoles","repo":"github.com/a/b","desiredRoles":[
{"subject":"did:key:z6MkOne","account":{"forge":"github.com","id":"7","login":"a"},"right":"git.repo.own"},
{"subject":"did:key:z6MkTwo","account":{"forge":"github.com","id":"7","login":"b"},"right":"git.ns.admin"}]}),
json!({"jobId":"j","namespace":"n","kind":"projectRoles","repo":"github.com/a/b",
"desiredRoles":[{"subject":"did:key:z6MkOwner","account":{"forge":"github.com","id":"7","login":"a"},"right":"git.repo.own"}],
"removeAccounts":[{"forge":"github.com","id":"7","login":"a"}]}),
];
for v in bad {
assert!(check_kind_members(&parse(v.clone())).is_err(), "{v}");
}
assert!(
serde_json::from_value::<job::Payload>(
json!({"jobId":"j","namespace":"n","kind":"archive","repo":"github.com/a/b","token":"ghs_x"})
)
.is_err()
);
}
#[test]
fn job_0_4_and_0_5_are_taken() {
assert!(is_job_type(JOB_TYPE));
assert!(is_job_type(JOB_TYPE_V0_4));
assert!(JOB_TYPE.ends_with("/git-ns/bridge/job/0.5"));
assert!(JOB_TYPE_V0_4.ends_with("/git-ns/bridge/job/0.4"));
for v in ["0.1", "0.2", "0.3", "0.6"] {
let t = format!("https://trusttasks.org/spec/git-ns/bridge/job/{v}");
assert!(!is_job_type(&t));
assert!(is_other_job_version(&t));
}
assert!(!is_other_job_version(JOB_TYPE));
assert!(!is_other_job_version(JOB_TYPE_V0_4));
use job::PayloadKind as K;
assert!(job_kind_allowed(JOB_TYPE, K::ClosePullRequest));
assert!(!job_kind_allowed(JOB_TYPE_V0_4, K::ClosePullRequest));
assert!(job_kind_allowed(JOB_TYPE_V0_4, K::ProjectRoles));
assert!(job_kind_allowed(JOB_TYPE, K::ProjectRoles));
}
#[test]
fn discovery_lists_job_0_5_and_0_4_for_a_pattern_that_selects_them() {
for p in [
vec![],
vec!["*".to_string()],
vec!["git-ns/*".into()],
vec!["git-ns/bridge/job".into()],
] {
assert_eq!(
discovery_answer(&p)["supportedTypes"],
json!([
"https://trusttasks.org/spec/git-ns/bridge/job/0.5",
"https://trusttasks.org/spec/git-ns/bridge/job/0.4"
]),
"{p:?}"
);
}
for p in [
vec!["acl/*".to_string()],
vec!["git-ns/bridge".into()],
vec!["git-ns/bridge/job/0.4".into()],
] {
assert_eq!(discovery_answer(&p)["supportedTypes"], json!([]), "{p:?}");
}
}
#[test]
fn close_pull_request_carries_repo_number_and_message() {
let parse = |v: Value| serde_json::from_value::<job::Payload>(v).unwrap();
let base = || {
json!({"jobId":"j","namespace":"n","kind":"closePullRequest",
"repo":"github.com/a/b","number":42,"message":"Not open to you."})
};
check_kind_members(&parse(base())).unwrap();
for missing in ["repo", "number", "message"] {
let mut v = base();
v.as_object_mut().unwrap().remove(missing);
assert!(check_kind_members(&parse(v)).is_err(), "without {missing}");
}
let mut v = base();
v["steps"] = json!(["workflow"]);
assert!(check_kind_members(&parse(v)).is_err());
let v = json!({"jobId":"j","namespace":"n","kind":"archive","repo":"github.com/a/b","number":1});
assert!(check_kind_members(&parse(v)).is_err());
let v = json!({"jobId":"j","namespace":"n","kind":"inspect","message":"x"});
assert!(check_kind_members(&parse(v)).is_err());
for bad in [
json!({"jobId":"j","namespace":"n","kind":"closePullRequest",
"repo":"github.com/a/b","number":0,"message":"m"}),
json!({"jobId":"j","namespace":"n","kind":"closePullRequest",
"repo":"github.com/a/b","number":1,"message":"x".repeat(16_385)}),
] {
assert!(serde_json::from_value::<job::Payload>(bad).is_err());
}
}
#[test]
fn clip_keeps_char_boundaries() {
let s = "é".repeat(1000);
let c = clip(&s, 1024);
assert!(c.len() <= 1024);
assert_eq!(clip("short", 1024), "short");
}
}