use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tower::ServiceExt;
use affinidi_data_integrity::crypto_suites::CryptoSuite;
use affinidi_data_integrity::{DataIntegrityProof, prepare_sign_input};
use ed25519_dalek::{Signer, SigningKey};
use multibase::Base;
use trust_tasks_rs::{Proof, TrustTask};
use vta_service::test_support::{TestAppContext, build_test_app};
fn did_key(sk: &SigningKey) -> (String, String) {
let pk = sk.verifying_key();
let mut mc = vec![0xed, 0x01];
mc.extend_from_slice(pk.as_bytes());
let mb = multibase::encode(Base::Base58Btc, mc);
(format!("did:key:{mb}"), mb)
}
async fn seed_admin_acl(ctx: &TestAppContext, did: &str) {
let entry = vti_common::acl::AclEntry::new(did, vti_common::acl::Role::Admin, "test")
.with_created_at(1);
vti_common::acl::store_acl_entry(&ctx.acl_ks, &entry)
.await
.expect("seed admin ACL");
}
fn post(uri: &str, body: Vec<u8>) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("x-forwarded-for", "203.0.113.7")
.body(Body::from(body))
.unwrap()
}
async fn send(router: &axum::Router, req: Request<Body>) -> (StatusCode, Value) {
let resp = router.clone().oneshot(req).await.expect("request");
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&bytes)
.unwrap_or_else(|_| json!({"raw": String::from_utf8_lossy(&bytes).to_string()}));
(status, v)
}
fn signed_authenticate_doc(
sk: &SigningKey,
did: &str,
vm: &str,
challenge: &str,
session_id: &str,
) -> TrustTask<Value> {
let doc_json = json!({
"id": "urn:uuid:authn-itest-1",
"type": "https://trusttasks.org/spec/auth/authenticate/0.1",
"issuer": did,
"recipient": "did:key:z6MkTestVTA",
"payload": { "challenge": challenge, "sessionId": session_id },
});
let mut doc: TrustTask<Value> = serde_json::from_value(doc_json).unwrap();
let mut di = DataIntegrityProof::new(
CryptoSuite::EddsaJcs2022,
vm.to_string(),
"authentication".to_string(),
None,
Some("2026-05-31T12:00:00Z".to_string()),
None,
);
let input = prepare_sign_input(&doc, &di, CryptoSuite::EddsaJcs2022).unwrap();
di.proof_value = Some(multibase::encode(
Base::Base58Btc,
sk.sign(&input).to_bytes(),
));
doc.proof = Some(serde_json::from_value::<Proof>(serde_json::to_value(&di).unwrap()).unwrap());
doc
}
async fn obtain_challenge(router: &axum::Router, did: &str) -> (String, String) {
let (status, body) = send(
router,
post(
"/auth/challenge",
json!({ "did": did }).to_string().into_bytes(),
),
)
.await;
assert_eq!(status, StatusCode::OK, "challenge must issue: {body}");
(
body["sessionId"].as_str().unwrap().to_string(),
body["challenge"].as_str().unwrap().to_string(),
)
}
#[tokio::test]
async fn di_signed_authenticate_issues_tokens() {
let (router, ctx) = build_test_app().await;
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
seed_admin_acl(&ctx, &did).await;
let (session_id, challenge) = obtain_challenge(&router, &did).await;
let doc = signed_authenticate_doc(&sk, &did, &vm, &challenge, &session_id);
let (status, body) = send(&router, post("/auth/", serde_json::to_vec(&doc).unwrap())).await;
assert_eq!(
status,
StatusCode::OK,
"DI-signed authenticate must succeed: {body}"
);
assert!(
body["type"]
.as_str()
.is_some_and(|t| t.ends_with("/auth/authenticate/0.1#response")),
"response is a TT #response doc: {body}"
);
assert_eq!(body["payload"]["session"]["subject"], did, "{body}");
assert_eq!(
body["payload"]["session"]["acr"], "aal1",
"first factor is AAL1: {body}"
);
assert!(
body["payload"]["tokens"]["accessToken"]
.as_str()
.is_some_and(|t| !t.is_empty()),
"an access token is issued: {body}"
);
let stored = vti_common::auth::session::get_session(&ctx.sessions_ks, &session_id)
.await
.unwrap()
.unwrap();
assert_eq!(
stored.state,
vti_common::auth::session::SessionState::Authenticated
);
let (replay_status, _) = send(&router, post("/auth/", serde_json::to_vec(&doc).unwrap())).await;
assert_ne!(
replay_status,
StatusCode::OK,
"replaying the authenticate document must not re-authenticate"
);
}
#[tokio::test]
async fn di_signed_authenticate_rejects_tampered_proof() {
let (router, ctx) = build_test_app().await;
let sk = SigningKey::from_bytes(&[8u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
seed_admin_acl(&ctx, &did).await;
let (session_id, challenge) = obtain_challenge(&router, &did).await;
let mut doc = signed_authenticate_doc(&sk, &did, &vm, &challenge, &session_id);
let mut proof = serde_json::to_value(doc.proof.take().unwrap()).unwrap();
let pv = proof["proofValue"].as_str().unwrap();
let mut chars: Vec<char> = pv.chars().collect();
chars[1] = if chars[1] == 'A' { 'B' } else { 'A' };
proof["proofValue"] = Value::String(chars.into_iter().collect());
doc.proof = Some(serde_json::from_value(proof).unwrap());
let (status, body) = send(&router, post("/auth/", serde_json::to_vec(&doc).unwrap())).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"a tampered proof must be rejected: {body}"
);
let stored = vti_common::auth::session::get_session(&ctx.sessions_ks, &session_id)
.await
.unwrap()
.unwrap();
assert_eq!(
stored.state,
vti_common::auth::session::SessionState::ChallengeSent
);
}
#[tokio::test]
async fn tt_challenge_returns_tt_response_doc() {
let (router, ctx) = build_test_app().await;
let sk = SigningKey::from_bytes(&[11u8; 32]);
let (did, _mb) = did_key(&sk);
seed_admin_acl(&ctx, &did).await;
let doc = json!({
"id": "urn:uuid:challenge-itest-1",
"type": "https://trusttasks.org/spec/auth/challenge/0.1",
"issuer": did,
"recipient": "did:key:z6MkTestVTA",
"payload": { "subject": did },
});
let (status, body) = send(
&router,
post("/auth/challenge", serde_json::to_vec(&doc).unwrap()),
)
.await;
assert_eq!(status, StatusCode::OK, "TT challenge must succeed: {body}");
assert!(
body["type"]
.as_str()
.is_some_and(|t| t.ends_with("/auth/challenge/0.1#response")),
"response is a TT #response doc: {body}"
);
assert_eq!(
body["recipient"], did,
"addressed back to the holder: {body}"
);
assert!(
body["payload"]["challenge"]
.as_str()
.is_some_and(|c| !c.is_empty()),
"{body}"
);
assert!(
body["payload"]["sessionId"]
.as_str()
.is_some_and(|s| !s.is_empty()),
"{body}"
);
assert!(body["payload"]["expiresAt"].as_str().is_some(), "{body}");
}