use acme_proxy_core::audit::ClientContext;
pub fn identifiers(pairs: &[(&str, &str)]) -> Vec<acme_proxy_core::identifier::Identifier> {
pairs
.iter()
.map(|(typ, value)| acme_proxy_core::identifier::Identifier::new(*typ, *value))
.collect()
}
pub fn dns_identifiers(values: &[&str]) -> Vec<acme_proxy_core::identifier::Identifier> {
values
.iter()
.map(|value| acme_proxy_core::identifier::Identifier::dns(*value))
.collect()
}
pub async fn account_id(database: &std::sync::Arc<crate::db::Database>) -> uuid::Uuid {
let (account, _) = crate::account::Account::find_or_create(
"default",
&[1u8, 2, 3],
vec![],
&ClientContext::default(),
database,
)
.await
.expect("an in-memory database always accepts an account");
account.id
}
pub fn client_context(ip: Option<&str>, ptr: Option<&str>) -> ClientContext {
ClientContext {
ip: ip.map(str::to_string),
ptr: ptr.map(str::to_string),
..ClientContext::default()
}
}
pub async fn account_seen_from(
pubkey: &[u8],
client: &ClientContext,
database: &std::sync::Arc<crate::db::Database>,
) -> crate::account::Account {
crate::account::Account::find_or_create(
"default",
pubkey,
vec!["mailto:a@example.com".to_string()],
client,
database,
)
.await
.expect("an in-memory database always accepts an account")
.0
}
pub fn order_fixture(
account_id: uuid::Uuid,
status: crate::status::OrderStatus,
) -> crate::order::Order {
let mut order = crate::order::Order::new(
"default",
account_id,
vec![acme_proxy_core::identifier::Identifier::dns("example.com")],
0,
None,
None,
);
order.status = status;
order
}
pub async fn issued_order(
database: &crate::db::Database,
profile: &str,
account: uuid::Uuid,
names: &[&str],
not_after_days: i64,
) -> crate::order::Order {
use crate::order::Order;
use acme_proxy_core::identifier::Identifier;
const DAY: i64 = 24 * 60 * 60;
let mut order = Order::create(
profile,
account,
names.iter().map(|name| Identifier::dns(*name)).collect(),
crate::nonce::now_secs() + 3600,
None,
None,
database,
)
.await
.unwrap();
let ca_key = rcgen::KeyPair::generate().unwrap();
let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let issuer = rcgen::Issuer::new(ca_params, ca_key);
let leaf_key = rcgen::KeyPair::generate().unwrap();
let mut params =
rcgen::CertificateParams::new(names.iter().map(|n| (*n).to_string()).collect::<Vec<_>>())
.unwrap();
params.use_authority_key_identifier_extension = true;
let leaf_cert = params.signed_by(&leaf_key, &issuer).unwrap();
let chain = format!("{}{}", leaf_cert.pem(), ca_cert.pem());
let leaf = acme_proxy_core::cert::leaf_der_from_chain(&chain).unwrap();
let (serial, pubkey) = acme_proxy_core::cert::cert_serial_and_spki(&leaf).unwrap();
order
.finalize(
chain,
serial,
pubkey,
Some(crate::nonce::now_secs() + not_after_days * DAY),
database,
)
.await
.unwrap();
order
}
pub async fn certified_order(
database: &crate::db::Database,
account: uuid::Uuid,
not_after: Option<i64>,
) -> crate::order::Order {
let mut order = crate::order::Order::create(
"default",
account,
vec![acme_proxy_core::identifier::Identifier::dns("example.com")],
crate::nonce::now_secs() + 3600,
None,
None,
database,
)
.await
.unwrap();
order
.finalize(
"-----BEGIN CERTIFICATE-----\n...".to_string(),
order.id.simple().to_string(),
vec![1],
not_after,
database,
)
.await
.unwrap();
order
}
pub fn audit_entry() -> crate::audit::AuditEntry {
crate::audit::AuditEntry {
id: 41_812,
created_at: 1_700_000_000,
event: "certificate_issued".to_string(),
outcome: "success".to_string(),
profile: "le".to_string(),
actor_kind: "acme".to_string(),
actor_id: Some("acct-1".to_string()),
account_id: Some("acct-1".to_string()),
order_id: Some("order-1".to_string()),
cert_serial: Some("0a0b".to_string()),
identifiers: vec!["a.example.com".to_string(), "b.example.com".to_string()],
client_ip: Some("203.0.113.7".to_string()),
client_ptr: Some("host.example.com".to_string()),
user_agent: Some("certbot/2.9.0".to_string()),
request_id: Some("req-1".to_string()),
reason: None,
detail: None,
}
}
pub fn job_fixture() -> crate::job::Job {
crate::job::Job {
id: uuid::uuid!("00000000-0000-7000-8000-00000000abcd"),
kind: "signer_relay_issue".to_string(),
dedup_key: "order-1".to_string(),
payload: serde_json::json!({ "order_id": "order-1", "profile": "le" }),
status: "failed".to_string(),
run_at: 1_700_000_000,
attempts: 3,
max_attempts: 5,
deadline: Some(1_700_600_000),
lease_until: Some(1_700_000_300),
lease_owner: Some("runner-1".to_string()),
last_error: Some("upstream said no".to_string()),
created_at: 1_699_990_000,
updated_at: 1_700_000_100,
}
}
pub fn upstream_order_row_fixture() -> crate::upstream_order::UpstreamOrderRow {
crate::upstream_order::UpstreamOrderRow {
order_id: uuid::uuid!("00000000-0000-7000-8000-00000000ee01"),
upstream_order_url: "https://acme.example/order/9".to_string(),
upstream_finalize_url: Some("https://acme.example/order/9/finalize".to_string()),
upstream_certificate_url: Some("https://acme.example/cert/9".to_string()),
status: "invalid".to_string(),
error: Some("urn:ietf:params:acme:error:rejectedIdentifier".to_string()),
created_at: 1_699_990_000,
updated_at: 1_700_000_100,
client_ip: Some("203.0.113.7".to_string()),
client_ptr: Some("host.example.com".to_string()),
user_agent: Some("lego/4".to_string()),
request_id: Some("req-9".to_string()),
profile: "le".to_string(),
account_id: uuid::uuid!("00000000-0000-7000-8000-0000000acc01"),
identifiers: vec![acme_proxy_core::identifier::Identifier::dns(
"a.example.com",
)],
local_status: crate::status::OrderStatus::Processing,
local_expires: 1_700_600_000,
}
}
pub const ADMIN_FIXTURE_ID: uuid::Uuid = uuid::uuid!("11111111-2222-3333-4444-555555555555");
pub fn admin_user_fixture() -> crate::admin_user::AdminUser {
crate::admin_user::AdminUser {
id: ADMIN_FIXTURE_ID,
username: "alice".to_string(),
password_hash: "pbkdf2-sha256$600000$c2FsdA$aGFzaA".to_string(),
status: "active".to_string(),
role: None,
totp_secret: None,
totp_pending_secret: None,
totp_last_step: None,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
last_login_at: None,
contact_email: None,
known_login_ips: Vec::new(),
}
}
pub fn admin_session_fixture() -> crate::admin_session::AdminSession {
crate::admin_session::AdminSession {
token_hash: "0123456789abcdef0123456789abcdef".to_string(),
user_id: ADMIN_FIXTURE_ID,
csrf_token: "the-csrf-token".to_string(),
state: "active".to_string(),
mfa_attempts: 0,
created_at: 1_700_000_000,
expires_at: 1_700_043_200,
last_seen_at: 1_700_000_000,
created_ip: Some("192.0.2.1".to_string()),
user_agent: Some("curl/8".to_string()),
}
}
pub const TEST_POSTGRES_URL: &str = "TEST_POSTGRES_URL";
pub const REQUIRE_POSTGRES: &str = "ACME_PROXY_REQUIRE_POSTGRES";
pub async fn postgres_database() -> Option<crate::db::Database> {
let base = match std::env::var(TEST_POSTGRES_URL) {
Ok(url) if !url.is_empty() => url,
_ => {
assert!(
std::env::var_os(REQUIRE_POSTGRES).is_none(),
"{REQUIRE_POSTGRES} is set, so skipping is a failure: {TEST_POSTGRES_URL} \
is unset or empty. These tests were about to report green without \
running against PostgreSQL at all."
);
eprintln!(
"skipping: {TEST_POSTGRES_URL} is unset; start a PostgreSQL and set it to \
run the dialect tests"
);
return None;
}
};
let schema = format!("acme_test_{}", crate::id::mint().simple());
let admin = crate::db::Database::open(&base)
.await
.expect("TEST_POSTGRES_URL should name a reachable PostgreSQL");
sweep_stale_schemas(&admin).await;
crate::sql::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema};")))
.execute(&admin)
.await
.expect("a test schema should be creatable");
admin.close().await;
let separator = if base.contains('?') { '&' } else { '?' };
let database = crate::db::Database::open(&format!(
"{base}{separator}options=-c%20search_path%3D{schema}"
))
.await
.expect("the schema-scoped pool should open");
database
.migrate()
.await
.expect("the PostgreSQL migration set should apply");
Some(database)
}
async fn sweep_stale_schemas(admin: &crate::db::Database) {
const STALE_AFTER_MS: u64 = 60 * 60 * 1000;
let Ok(rows) = crate::sql::query(
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name LIKE 'acme_test_%';",
)
.fetch_all(admin)
.await
else {
return;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
for row in rows {
let Ok(name) = row.try_get::<String>("schema_name") else {
continue;
};
let Some(minted) = name
.strip_prefix("acme_test_")
.and_then(|hex| uuid::Uuid::try_parse(hex).ok())
.and_then(|id| id.get_timestamp())
.map(|ts| {
let (secs, nanos) = ts.to_unix();
secs * 1000 + u64::from(nanos) / 1_000_000
})
else {
continue;
};
if now.saturating_sub(minted) > STALE_AFTER_MS {
let _ = crate::sql::query(sqlx::AssertSqlSafe(format!(
"DROP SCHEMA IF EXISTS {name} CASCADE;"
)))
.execute(admin)
.await;
}
}
}
pub async fn seed_every_table(db: &std::sync::Arc<crate::db::Database>) {
use crate::admin_recovery_code::AdminRecoveryCode;
use crate::admin_session::{AdminSession, NewSession};
use crate::admin_user::AdminUser;
use crate::audit::AuditEntry;
use crate::authz::{Authorization, Challenge};
use crate::crl::StoredCrl;
use crate::eab::Eab;
use crate::http01_token::Http01Token;
use crate::job::{Job, NewJob};
use crate::nonce::Nonce;
use crate::revocation::Revocation;
use crate::upstream_order::UpstreamOrder;
use acme_proxy_core::audit::{Actor, AuditEvent, AuditRecord};
use acme_proxy_core::identifier::Identifier;
use std::time::Duration;
let account = account_id(db).await;
let order = certified_order(db, account, Some(4_102_444_800)).await;
let authz = Authorization::create(order.id, Identifier::dns("example.com"), order.expires, db)
.await
.expect("an authorization");
Challenge::create(authz.id, "http-01", db)
.await
.expect("a challenge");
UpstreamOrder::create(
&order.id.to_string(),
"https://upstream.example/order/1",
None,
&[1u8, 2, 3],
db,
)
.await
.expect("an upstream order");
let mut agreed = account_seen_from(&[4u8, 5, 6], &ClientContext::default(), db).await;
agreed
.set_terms_agreed(db)
.await
.expect("the agreement is recordable");
Nonce::new().save(db).await.expect("a nonce");
Eab::create(Some("label".to_string()), Some("default".to_string()), db)
.await
.expect("an EAB key");
Job::enqueue(
NewJob {
id: crate::id::mint(),
kind: "seed_kind",
dedup_key: "seed",
payload: &serde_json::json!({"seeded": true}),
run_at: 0,
deadline: None,
max_attempts: 3,
},
db,
)
.await
.expect("a job");
let mut record = AuditRecord::new(
AuditEvent::CertificateIssued,
"default",
Actor::acme(account.to_string()),
);
record.order_id = Some(order.id.to_string());
record.cert_serial = order.cert_serial.clone();
record.identifiers = vec!["example.com".to_string()];
AuditEntry::insert(record, db).await.expect("an audit row");
Revocation {
issuer: "a".repeat(64),
serial: "0a0b".to_string(),
revoked_at: 1,
reason: Some(4),
not_after: Some(4_102_444_800),
}
.insert_if_absent(db)
.await
.expect("a revocation");
StoredCrl {
issuer: "a".repeat(64),
crl_number: 1,
der: vec![9, 9, 9],
this_update: 1,
next_update: 2,
}
.insert_initial(db)
.await
.expect("a CRL");
Http01Token::publish("tok", "tok.thumb", 1, 4_102_444_800, db)
.await
.expect("an http-01 token");
let user = AdminUser::create("alice", "hash", None, db)
.await
.expect("an operator");
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "0123456789abcdef0123456789abcdef",
csrf_token: "the-csrf-token",
created_ip: Some("192.0.2.1".to_string()),
user_agent: Some("curl/8".to_string()),
},
Duration::from_secs(3600),
db,
)
.await
.expect("a session");
AdminRecoveryCode::replace_all(user.id, &["code-hash".to_string()], db)
.await
.expect("a recovery code");
}
pub async fn row_counts(database: &crate::db::Database) -> Vec<(&'static str, u64)> {
let mut counts = Vec::new();
for spec in crate::transfer::TABLES {
let sql = format!("SELECT COUNT(*) FROM \"{}\";", spec.name);
let rows: i64 = crate::sql::query(sqlx::AssertSqlSafe(sql))
.fetch_one(database)
.await
.expect("a count")
.try_get(0usize)
.expect("a count is an integer");
counts.push((
spec.name,
u64::try_from(rows).expect("a count is not negative"),
));
}
counts
}