#![cfg(feature = "pebble-tests")]
use std::sync::Arc;
use postrust_proxy::saas::handlers::{saas_router, SaasState};
use postrust_proxy::tls::{AcmeIssuer, CertificateStore};
use sqlx::{Executor, PgPool};
const MIGRATIONS: [&str; 4] = [
include_str!("../migrations/20240115000001_saas_domains.sql"),
include_str!("../migrations/20260901000001_proxy_config.sql"),
include_str!("../migrations/20260901000002_proxy_acme.sql"),
include_str!("../migrations/20261030000001_proxy_control_plane.sql"),
];
const MIGRATION_LOCK: i64 = 0x706f7374_72757374;
fn env(name: &str) -> String {
std::env::var(name)
.unwrap_or_else(|_| panic!("{name} must be set; run this through scripts/acme/run.sh"))
}
async fn migrated_pool() -> PgPool {
let pool = PgPool::connect(&env("DATABASE_URL"))
.await
.expect("could not connect to DATABASE_URL");
let mut tx = pool.begin().await.expect("begin");
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(MIGRATION_LOCK)
.execute(&mut *tx)
.await
.expect("migration lock");
for migration in MIGRATIONS {
(&mut *tx)
.execute(migration)
.await
.expect("could not apply a migration");
}
tx.commit().await.expect("commit");
pool
}
#[tokio::test]
#[ignore = "requires Pebble; run scripts/acme/run.sh"]
async fn a_certificate_is_issued_end_to_end() {
let pool = migrated_pool().await;
let domain = env("ACME_TEST_DOMAIN");
let challenge_port: u16 = env("ACME_CHALLENGE_PORT").parse().expect("port");
let cache = tempdir();
let state = SaasState::new(pool.clone(), None, &cache)
.await
.expect("saas state");
let listener = tokio::net::TcpListener::bind(("0.0.0.0", challenge_port))
.await
.expect("could not bind the challenge port");
let server = tokio::spawn(async move {
axum::serve(listener, saas_router(state)).await.ok();
});
let cert_store = Arc::new(
CertificateStore::new(pool.clone(), &cache)
.await
.expect("cert store"),
);
let issuer = AcmeIssuer::new(
env("ACME_DIRECTORY"),
Some("acme-test@postrust.invalid".to_string()),
pool.clone(),
cert_store.clone(),
)
.with_root_certificate(env("ACME_ROOT_PEM"));
let certificate = issuer
.issue(&domain)
.await
.expect("issuance should succeed against Pebble");
let cert_pem = String::from_utf8(certificate.cert_pem.clone()).expect("cert is utf-8");
assert!(
cert_pem.contains("BEGIN CERTIFICATE"),
"no PEM certificate in the chain: {cert_pem:.200}"
);
let key_pem = String::from_utf8(certificate.key_pem.clone()).expect("key is utf-8");
assert!(
key_pem.contains("PRIVATE KEY"),
"no PEM private key came back"
);
let facts =
postrust_proxy::tls::facts(&certificate.cert_pem).expect("the issued chain should parse");
assert_eq!(
certificate.expires_at,
Some(facts.expires_at),
"the stored expiry is not the one in the chain, so the fallback was used"
);
let (_, pem) = x509_parser::pem::parse_x509_pem(certificate.cert_pem.as_slice())
.expect("chain parses as PEM");
let (_, parsed) =
x509_parser::parse_x509_certificate(&pem.contents).expect("certificate parses");
let names: Vec<String> = parsed
.subject_alternative_name()
.ok()
.flatten()
.map(|san| {
san.value
.general_names
.iter()
.map(|n| n.to_string())
.collect()
})
.unwrap_or_default();
assert!(
names.iter().any(|n| n.contains(&domain)),
"issued certificate does not cover {domain}; SANs were {names:?}"
);
let stored = cert_store
.get(&domain)
.await
.expect("the certificate should be in the store");
assert_eq!(stored.cert_pem, certificate.cert_pem);
let in_db: i64 =
sqlx::query_scalar("SELECT count(*) FROM proxy_certificates WHERE domain = $1")
.bind(&domain)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(in_db, 1, "certificate did not reach proxy_certificates");
let accounts: i64 = sqlx::query_scalar("SELECT count(*) FROM proxy_acme_accounts")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(accounts, 1, "the ACME account was not persisted");
let leftover: i64 =
sqlx::query_scalar("SELECT count(*) FROM proxy_acme_challenges WHERE domain = $1")
.bind(&domain)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(leftover, 0, "challenge rows outlived the order");
server.abort();
}
#[tokio::test]
#[ignore = "requires Pebble; run scripts/acme/run.sh"]
async fn a_domain_that_does_not_resolve_here_fails_with_a_useful_message() {
let pool = migrated_pool().await;
let cache = tempdir();
let cert_store = Arc::new(
CertificateStore::new(pool.clone(), &cache)
.await
.expect("cert store"),
);
let issuer = AcmeIssuer::new(
env("ACME_DIRECTORY"),
None,
pool.clone(),
cert_store.clone(),
)
.with_root_certificate(env("ACME_ROOT_PEM"));
let error = issuer
.issue("unreachable.postrust.invalid")
.await
.expect_err("issuance should fail when the challenge cannot be fetched");
let message = error.to_string();
assert!(
message.contains("does not resolve to this proxy")
|| message.contains("did not become ready"),
"the failure should explain what to check, but said: {message}"
);
let leftover: i64 = sqlx::query_scalar(
"SELECT count(*) FROM proxy_acme_challenges WHERE domain = 'unreachable.postrust.invalid'",
)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(leftover, 0, "a failed order left its challenge rows behind");
}
fn tempdir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("postrust-acme-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}