use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use did_hosting_common::did_ops::{DidRecord, did_key, owner_key};
use did_hosting_common::server::acl::{AclEntry, Role, store_acl_entry};
use did_hosting_common::server::auth::session::{create_authenticated_session, now_epoch};
use did_hosting_common::server::config::{
AuthConfig, FeaturesConfig, LogConfig, SecretsConfig, ServerConfig, StoreConfig, VtaConfig,
};
use did_hosting_common::server::domain::DomainScope;
use did_hosting_common::server::stats_collector::StatsCollector;
use did_hosting_common::server::store::{
KS_ACL, KS_DIDS, KS_REGISTRY, KS_SESSIONS, KS_STATS, KS_TIMESERIES, Store,
};
use crate::auth::jwt::JwtKeys;
use crate::config::{AppConfig, RegistryConfig};
use crate::server::AppState;
pub struct TestServerOptions {
pub features: Option<FeaturesConfig>,
pub server_did: Option<Option<String>>,
pub vta: VtaConfig,
pub with_jwt: bool,
}
impl Default for TestServerOptions {
fn default() -> Self {
Self {
features: None,
server_did: None,
vta: VtaConfig::default(),
with_jwt: true,
}
}
}
impl TestServerOptions {
pub fn agent_names(mut self, on: bool) -> Self {
let mut f = self.features.take().unwrap_or_default();
f.agent_names = on;
self.features = Some(f);
self
}
}
pub struct TestServer {
pub state: AppState,
_dir: tempfile::TempDir,
}
impl TestServer {
pub async fn start() -> Self {
Self::start_with(TestServerOptions::default()).await
}
pub async fn start_with(opts: TestServerOptions) -> Self {
let dir = tempfile::tempdir().expect("temp dir");
let store_config = StoreConfig {
data_dir: PathBuf::from(dir.path()),
..StoreConfig::default()
};
let store = Store::open(&store_config).await.expect("open store");
let sessions_ks = store.keyspace(KS_SESSIONS).expect("sessions ks");
let acl_ks = store.keyspace(KS_ACL).expect("acl ks");
let registry_ks = store.keyspace(KS_REGISTRY).expect("registry ks");
let dids_ks = store.keyspace(KS_DIDS).expect("dids ks");
let stats_ks = store.keyspace(KS_STATS).expect("stats ks");
let timeseries_ks = store.keyspace(KS_TIMESERIES).expect("timeseries ks");
let server_did = opts
.server_did
.unwrap_or_else(|| Some("did:webvh:test:control.example.com".into()));
let config = AppConfig {
features: opts.features.unwrap_or_default(),
server_did,
mediator_did: None,
public_url: Some("https://control.test".into()),
did_hosting_url: Some("http://control.test".into()),
server: ServerConfig::default(),
log: LogConfig::default(),
store: store_config,
fjall: Default::default(),
auth: AuthConfig::default(),
secrets: SecretsConfig::default(),
vta: opts.vta,
registry: RegistryConfig::default(),
trust_tasks: Default::default(),
hosting: Default::default(),
identity: Default::default(),
config_path: PathBuf::new(),
};
let jwt_keys = opts
.with_jwt
.then(|| Arc::new(JwtKeys::from_ed25519_bytes(&[7u8; 32]).expect("jwt keys")));
let identity = match config.server_did.as_deref() {
Some(did) => Some(
did_hosting_common::server::identity::ServiceIdentity::generated_for(did)
.await
.expect("test identity"),
),
None => None,
};
let state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
registry_ks,
dids_ks,
config: Arc::new(config),
did_resolver: None,
secrets_resolver: None,
identity,
trust_tasks_verifier: None,
jwt_keys,
webauthn: None,
http_client: reqwest::Client::new(),
didcomm_service: Arc::new(OnceLock::new()),
stats_collector: Arc::new(StatsCollector::new()),
stats_ks,
timeseries_ks,
signing_key_bytes: None,
replay_cache: Arc::new(crate::replay::ReplayCache::new()),
path_locks: crate::path_locks::PathLocks::new(),
acl_locks: did_hosting_common::server::path_locks::PathLocks::new(),
pending_challenges: Arc::new(crate::pending_challenges::PendingChallengeTracker::new()),
ip_rate_limiter: Arc::new(crate::rate_limit::IpRateLimiter::new()),
redeem_rate_limiter: Arc::new(crate::rate_limit::SourceRateLimiter::new()),
large_document_budget: Arc::new(
did_hosting_common::server::trust_tasks::size::LargeDocumentBudget::new(),
),
outbox_notify: Arc::new(tokio::sync::Notify::new()),
cache_invalidate: None,
oob: Default::default(),
};
Self { state, _dir: dir }
}
pub fn router(&self) -> axum::Router {
crate::routes::router_without_fallback()
.with_state(self.state.clone())
.layer(axum::extract::connect_info::MockConnectInfo(
std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
))
}
pub async fn add_acl(&self, did: &str, role: Role) {
store_acl_entry(
&self.state.acl_ks,
&AclEntry {
did: did.into(),
role,
label: None,
created_at: now_epoch(),
max_total_size: None,
max_did_count: None,
domains: DomainScope::All,
},
)
.await
.expect("store acl");
}
pub async fn mint_token(&self, did: &str, role: Role) -> String {
let keys = self
.state
.jwt_keys
.as_ref()
.expect("mint_token requires a JWT key; build with `with_jwt: true`");
let auth = AuthConfig::default();
create_authenticated_session(
&self.state.sessions_ks,
keys,
did,
&role,
auth.access_token_expiry,
auth.refresh_token_expiry,
None,
None,
)
.await
.expect("create session")
.access_token
}
pub async fn seed_did(&self, owner_did: &str, mnemonic: &str) -> DidRecord {
let now = now_epoch();
let record = DidRecord {
owner: owner_did.into(),
mnemonic: mnemonic.into(),
created_at: now,
updated_at: now,
version_count: 1,
did_id: Some(format!("did:webvh:abc:{mnemonic}")),
content_size: 42,
disabled: false,
deleted_at: None,
method: "webvh".to_string(),
domain: String::new(),
services: None,
agent_names: Vec::new(),
};
self.put_did(&record).await;
self.state
.dids_ks
.insert_raw(owner_key(owner_did, mnemonic), mnemonic.as_bytes().to_vec())
.await
.expect("seed owner index");
record
}
pub async fn put_did(&self, record: &DidRecord) {
self.state
.dids_ks
.insert(did_key(&record.mnemonic), record)
.await
.expect("put did record");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn defaults_serve_agent_names_and_configure_jwt() {
let ts = TestServer::start().await;
assert!(ts.state.config.features.agent_names);
assert!(ts.state.jwt_keys.is_some());
assert_eq!(
ts.state.config.server_did.as_deref(),
Some("did:webvh:test:control.example.com")
);
}
#[tokio::test]
async fn agent_names_knob_is_isolated() {
let ts = TestServer::start_with(TestServerOptions::default().agent_names(false)).await;
assert!(!ts.state.config.features.agent_names);
assert!(!ts.state.config.features.didcomm);
assert!(!ts.state.config.features.rest_api);
}
#[tokio::test]
async fn seed_did_writes_record_and_owner_index() {
let ts = TestServer::start().await;
let owner = "did:example:owner";
let seeded = ts.seed_did(owner, "slot-one").await;
assert_eq!(seeded.owner, owner);
let stored: DidRecord = ts
.state
.dids_ks
.get(did_key("slot-one"))
.await
.unwrap()
.expect("record present");
assert_eq!(stored.owner, owner);
assert_eq!(
ts.state
.dids_ks
.get_raw(owner_key(owner, "slot-one"))
.await
.unwrap()
.as_deref(),
Some(b"slot-one".as_slice()),
"owner index must point back at the mnemonic"
);
}
#[tokio::test]
async fn minted_token_is_accepted_by_the_router() {
use did_hosting_common::server::acl::Role;
let ts = TestServer::start().await;
let owner = "did:example:owner";
ts.add_acl(owner, Role::Owner).await;
let token = ts.mint_token(owner, Role::Owner).await;
assert!(!token.is_empty());
let _app = ts.router();
}
}