use std::io::BufRead;
use std::sync::Arc;
use std::time::Duration;
use crate::admin::prompt::confirm;
use acme_proxy_core::audit::Actor;
use acme_proxy_core::audit::ClientContext;
use acme_proxy_jobs::auditor::Auditor;
use acme_proxy_signer::SignerError;
use acme_proxy_signer::relay::RELAY_JOB_KIND;
use acme_proxy_signer::relay::abandon_relayed_order;
use acme_proxy_store::account::Account;
use acme_proxy_store::audit::AuditEntry;
use acme_proxy_store::audit::AuditQuery;
use acme_proxy_store::authz::Authorization;
use acme_proxy_store::authz::Challenge;
use acme_proxy_store::db::Database;
use acme_proxy_store::eab::BoundAccounts;
use acme_proxy_store::eab::DeletedEab;
use acme_proxy_store::eab::Eab;
use acme_proxy_store::eab::EabDeletion;
use acme_proxy_store::job::Job;
use acme_proxy_store::nonce::Nonce;
use acme_proxy_store::order::GuardedDelete;
use acme_proxy_store::order::Order;
use acme_proxy_store::status::JobStatus;
use acme_proxy_store::upstream_order::UpstreamOrder;
use acme_proxy_store::upstream_order::UpstreamOrderRow;
#[derive(Debug, PartialEq, Eq)]
pub enum DeleteOutcome {
NotFound,
Cancelled,
Deleted(Deleted),
LiveCertificates(u64),
}
#[derive(Debug, PartialEq, Eq)]
pub enum Deletion {
NotFound,
LiveCertificates(u64),
Deleted(Deleted),
}
impl Deletion {
fn from_guarded(outcome: GuardedDelete, cascaded: u64) -> Self {
match outcome {
GuardedDelete::NotFound => Self::NotFound,
GuardedDelete::LiveCertificates(live) => Self::LiveCertificates(live),
GuardedDelete::Deleted => Self::Deleted(Deleted { cascaded }),
}
}
}
impl From<Deletion> for DeleteOutcome {
fn from(deletion: Deletion) -> Self {
match deletion {
Deletion::NotFound => Self::NotFound,
Deletion::LiveCertificates(live) => Self::LiveCertificates(live),
Deletion::Deleted(deleted) => Self::Deleted(deleted),
}
}
}
#[must_use]
pub fn live_certificates_refusal(subject: &str, live: u64) -> String {
format!(
"{subject} holds {live} live certificate(s) (issued, not revoked, not expired); \
deleting it would leave them impossible to revoke — revoke them first, or wait \
for them to expire"
)
}
#[must_use]
pub fn eab_live_certificates_refusal(kid: &str, accounts: u64, certificates: u64) -> String {
format!(
"{accounts} account(s) bound to EAB credential {kid} hold {certificates} live \
certificate(s) (issued, not revoked, not expired); deleting them would leave those \
certificates impossible to revoke — revoke them first, wait for them to expire, or \
deactivate the accounts instead of deleting them"
)
}
#[must_use]
pub fn unmounted_profile_refusal(
mounted: impl Fn(&str) -> bool,
profile: Option<&str>,
hint: &str,
) -> Option<String> {
let name = profile?;
if mounted(name) {
return None;
}
Some(format!(
"no profile named `{name}` is mounted; {hint} for a credential valid at every endpoint"
))
}
#[derive(Debug, PartialEq, Eq)]
pub struct Deleted {
pub cascaded: u64,
}
#[derive(Debug)]
pub struct OrderDetail {
pub order: Order,
pub authorizations: Vec<(Authorization, Vec<Challenge>)>,
}
#[derive(Debug)]
pub enum RevokeOutcome {
NotFound,
NotIssued,
AlreadyRevoked,
Revoked(Box<Order>),
Queued(uuid::Uuid),
}
#[derive(Debug, thiserror::Error)]
pub enum RevokeError {
#[error("database error: {0}")]
Database(sqlx::Error),
#[error("signer error: {}", acme_proxy_protocol::acme::revoke::signer_detail(.0))]
Signer(SignerError),
#[error("internal error: {0}")]
Internal(String),
#[error("unsupported revocation reason code {0}")]
BadReason(u32),
#[error("the revocation failed (job {job}): {reason}")]
Abandoned { job: uuid::Uuid, reason: String },
}
impl From<sqlx::Error> for RevokeError {
fn from(error: sqlx::Error) -> Self {
Self::Database(error)
}
}
impl From<SignerError> for RevokeError {
fn from(error: SignerError) -> Self {
Self::Signer(error)
}
}
pub async fn delete_account(id: &str, database: Arc<Database>) -> Result<Deletion, sqlx::Error> {
let Some(cascaded) = account_cascade(id, database.clone()).await? else {
return Ok(Deletion::NotFound);
};
let outcome = Account::delete(id, &database).await?;
Ok(Deletion::from_guarded(outcome, cascaded))
}
pub async fn confirm_delete_account(
id: &str,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<DeleteOutcome, sqlx::Error> {
let Some(account) = Account::find_any_by_id(id, &database).await? else {
return Ok(DeleteOutcome::NotFound);
};
let live = Account::count_live_certificates(account.id, &database).await?;
if live > 0 {
return Ok(DeleteOutcome::LiveCertificates(live));
}
let order_count = Order::count_by_account(account.id, &database).await?;
let prompt = format!(
"Delete account {id} (status: {}, {order_count} order(s) will cascade)?",
account.status
);
if !confirm(&prompt, assume_yes, reader) {
return Ok(DeleteOutcome::Cancelled);
}
let outcome = Account::delete(id, &database).await?;
Ok(Deletion::from_guarded(outcome, order_count as u64).into())
}
pub async fn delete_order(id: &str, database: Arc<Database>) -> Result<Deletion, sqlx::Error> {
let Some(cascaded) = order_cascade(id, database.clone()).await? else {
return Ok(Deletion::NotFound);
};
let outcome = Order::delete(id, &database).await?;
Ok(Deletion::from_guarded(outcome, cascaded))
}
pub async fn confirm_delete_order(
id: &str,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<DeleteOutcome, sqlx::Error> {
let Some(order) = Order::find_by_id(id, &database).await? else {
return Ok(DeleteOutcome::NotFound);
};
let live = Order::count_live_certificates(order.id, &database).await?;
if live > 0 {
return Ok(DeleteOutcome::LiveCertificates(live));
}
let authz_count = Authorization::count_by_order(order.id, &database).await?;
let prompt = format!(
"Delete order {id} (status: {}, {authz_count} authorization(s) will cascade)?",
order.status
);
if !confirm(&prompt, assume_yes, reader) {
return Ok(DeleteOutcome::Cancelled);
}
let outcome = Order::delete(id, &database).await?;
Ok(Deletion::from_guarded(outcome, authz_count as u64).into())
}
#[derive(Debug)]
pub enum EabDeleteOutcome {
NotFound,
Cancelled,
LiveCertificates {
accounts: u64,
certificates: u64,
},
Deleted(DeletedEab),
}
impl From<EabDeletion> for EabDeleteOutcome {
fn from(deletion: EabDeletion) -> Self {
match deletion {
EabDeletion::NotFound => Self::NotFound,
EabDeletion::LiveCertificates {
accounts,
certificates,
} => Self::LiveCertificates {
accounts,
certificates,
},
EabDeletion::Deleted(deleted) => Self::Deleted(deleted),
}
}
}
pub async fn delete_eab(
kid: &str,
accounts: BoundAccounts,
database: Arc<Database>,
) -> Result<EabDeletion, sqlx::Error> {
Eab::delete(kid, accounts, &database).await
}
pub async fn confirm_delete_eab(
kid: &str,
accounts: BoundAccounts,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<EabDeleteOutcome, sqlx::Error> {
let Some(eab) = Eab::find_any_by_kid(kid, &database).await? else {
return Ok(EabDeleteOutcome::NotFound);
};
let bound = Account::eab_summary(eab.kid, &database).await?;
if accounts == BoundAccounts::Delete && bound.live_certificates > 0 {
return Ok(EabDeleteOutcome::LiveCertificates {
accounts: bound.accounts_with_live_certificates,
certificates: bound.live_certificates,
});
}
let prompt = match accounts {
BoundAccounts::Keep => format!(
"Delete EAB credential {kid} (status: {})? Its {} account(s) are kept, and will \
fail any eab filter check from now on.",
eab.status, bound.accounts
),
BoundAccounts::Deactivate => format!(
"Delete EAB credential {kid} and deactivate its {} account(s)? Their {} order(s) \
are kept.",
bound.accounts, bound.orders
),
BoundAccounts::Delete => format!(
"Delete EAB credential {kid} and its {} account(s) ({} order(s) will cascade)?",
bound.accounts, bound.orders
),
};
if !confirm(&prompt, assume_yes, reader) {
return Ok(EabDeleteOutcome::Cancelled);
}
Ok(Eab::delete(kid, accounts, &database).await?.into())
}
pub async fn cleanup_nonces(ttl: Duration, database: Arc<Database>) -> Result<u64, sqlx::Error> {
Nonce::cleanup(&database, ttl).await
}
pub async fn confirm_cleanup_nonces(
ttl: Duration,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<Option<u64>, sqlx::Error> {
let prompt = format!("Delete all nonces older than {}s?", ttl.as_secs());
if !confirm(&prompt, assume_yes, reader) {
return Ok(None);
}
Ok(Some(cleanup_nonces(ttl, database).await?))
}
async fn account_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
let Some(account) = Account::find_any_by_id(id, &database).await? else {
return Ok(None);
};
Ok(Some(
Order::count_by_account(account.id, &database).await? as u64,
))
}
async fn order_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
let Some(order) = Order::find_by_id(id, &database).await? else {
return Ok(None);
};
Ok(Some(
Authorization::count_by_order(order.id, &database).await? as u64,
))
}
#[derive(Debug, thiserror::Error)]
pub enum ContactError {
#[error("{0}")]
Invalid(String),
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
}
pub async fn update_account_contact(
id: &str,
contact: Vec<String>,
database: Arc<Database>,
) -> Result<Option<Account>, ContactError> {
use acme_proxy_protocol::acme::account::ContactUpdateError;
use acme_proxy_protocol::acme::account::update_contact;
let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
return Ok(None);
};
update_contact(&mut account, contact, &database)
.await
.map_err(|error| match error {
ContactUpdateError::Refused(problem) => ContactError::Invalid(
problem.to_value()["detail"]
.as_str()
.unwrap_or_default()
.to_string(),
),
ContactUpdateError::Database(error) => ContactError::Database(error),
})?;
Ok(Some(account))
}
pub async fn deactivate_account(
id: &str,
database: Arc<Database>,
notifier: impl Fn(&str) -> Option<Arc<acme_proxy_jobs::notify::NotifyDispatcher>>,
client_ip: Option<String>,
) -> Result<Option<Account>, sqlx::Error> {
let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
return Ok(None);
};
let dispatcher = notifier(&account.profile);
acme_proxy_protocol::acme::account::deactivate(
&mut account,
&database,
dispatcher.as_deref(),
client_ip,
)
.await?;
Ok(Some(account))
}
pub async fn revoke_order(
id: &str,
reason: Option<u32>,
actor: Actor,
client: ClientContext,
revocations: acme_proxy_protocol::acme::revoke::Revocations<'_>,
) -> Result<RevokeOutcome, RevokeError> {
use acme_proxy_protocol::acme::revoke::RevokeError as Refusal;
match revocations.revoke_order(id, reason, actor, client).await {
Ok(order) => Ok(RevokeOutcome::Revoked(Box::new(order))),
Err(Refusal::NotFound) => Ok(RevokeOutcome::NotFound),
Err(Refusal::NotIssued) => Ok(RevokeOutcome::NotIssued),
Err(Refusal::AlreadyRevoked) => Ok(RevokeOutcome::AlreadyRevoked),
Err(Refusal::BadReason(code)) => Err(RevokeError::BadReason(code)),
Err(Refusal::Database(error)) => Err(RevokeError::Database(error)),
Err(Refusal::Internal(detail)) => Err(RevokeError::Internal(detail)),
Err(Refusal::Signer(error)) => Err(RevokeError::Signer(error)),
Err(Refusal::Pending { job }) => Ok(RevokeOutcome::Queued(job)),
Err(Refusal::Abandoned { job, reason }) => Err(RevokeError::Abandoned { job, reason }),
Err(Refusal::Refused(problem)) => Err(RevokeError::Internal(problem.detail().to_owned())),
}
}
pub async fn list_audit(
query: &AuditQuery,
database: Arc<Database>,
) -> Result<(Vec<AuditEntry>, i64), sqlx::Error> {
AuditEntry::search(query, &database).await
}
pub async fn find_audit(
id: i64,
database: Arc<Database>,
) -> Result<Option<AuditEntry>, sqlx::Error> {
AuditEntry::find_by_id(id, &database).await
}
pub async fn cleanup_audit(days: u64, database: Arc<Database>) -> Result<u64, sqlx::Error> {
AuditEntry::cleanup(acme_proxy_store::audit::audit_cutoff(days), &database).await
}
pub async fn confirm_cleanup_audit(
days: u64,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<Option<u64>, sqlx::Error> {
let cutoff = acme_proxy_store::audit::audit_cutoff(days);
let doomed = AuditEntry::count_older_than(cutoff, &database).await?;
let prompt =
format!("Delete {doomed} audit row(s) older than {days} day(s)? This cannot be undone.");
if !confirm(&prompt, assume_yes, reader) {
return Ok(None);
}
Ok(Some(AuditEntry::cleanup(cutoff, &database).await?))
}
pub async fn orders_json(
orders: &[Order],
base_url: &str,
database: &Database,
) -> Result<Vec<serde_json::Value>, sqlx::Error> {
let ids: Vec<uuid::Uuid> = orders.iter().map(|order| order.id).collect();
let mut grouped = Authorization::find_ids_by_orders(&ids, database).await?;
Ok(orders
.iter()
.map(|order| {
crate::admin::render_order_json(
order,
base_url,
&grouped.remove(&order.id).unwrap_or_default(),
)
})
.collect())
}
pub async fn load_order_detail(
id: &str,
database: Arc<Database>,
) -> Result<Option<OrderDetail>, sqlx::Error> {
let Some(order) = Order::find_by_id(id, &database).await? else {
return Ok(None);
};
let authzs = Authorization::find_by_order(order.id, &database).await?;
let mut authorizations = Vec::with_capacity(authzs.len());
for authz in authzs {
let challenges = Challenge::find_by_authz(authz.id, &database).await?;
authorizations.push((authz, challenges));
}
Ok(Some(OrderDetail {
order,
authorizations,
}))
}
#[derive(Debug)]
pub struct JobDetail {
pub job: Job,
pub upstream_order: Option<UpstreamOrderRow>,
}
#[derive(Debug)]
pub struct UpstreamOrderDetail {
pub upstream_order: UpstreamOrderRow,
pub job: Option<Job>,
}
const PERIODIC_JOB_KINDS: &[&str] = &[
acme_proxy_jobs::jobs::sweep::RETENTION_JOB_KIND,
acme_proxy_jobs::jobs::sweep::NONCE_SWEEP_KIND,
acme_proxy_jobs::jobs::sweep::AUDIT_SWEEP_KIND,
acme_proxy_jobs::jobs::sweep::ADMIN_SESSION_SWEEP_KIND,
acme_proxy_jobs::jobs::sweep::ORDER_SWEEP_KIND,
acme_proxy_signer::local_ca::sweep::CRL_SWEEP_KIND,
acme_proxy_jobs::notify::expiry::EXPIRY_JOB_KIND,
];
#[must_use]
pub fn is_periodic_job_kind(kind: &str) -> bool {
PERIODIC_JOB_KINDS.contains(&kind)
}
pub async fn load_job_detail(
id: &str,
database: Arc<Database>,
) -> Result<Option<JobDetail>, sqlx::Error> {
let Some(job_id) = acme_proxy_store::id::parse(id) else {
return Ok(None);
};
let Some(job) = Job::find_by_id(job_id, &database).await? else {
return Ok(None);
};
let upstream_order = if job.kind == RELAY_JOB_KIND {
UpstreamOrder::find_row_by_order_id(&job.dedup_key, &database).await?
} else {
None
};
Ok(Some(JobDetail {
job,
upstream_order,
}))
}
pub async fn load_upstream_order_detail(
order_id: &str,
database: Arc<Database>,
) -> Result<Option<UpstreamOrderDetail>, sqlx::Error> {
let Some(upstream_order) = UpstreamOrder::find_row_by_order_id(order_id, &database).await?
else {
return Ok(None);
};
let job = Job::find_latest_by_dedup(
RELAY_JOB_KIND,
&upstream_order.order_id.to_string(),
&database,
)
.await?;
Ok(Some(UpstreamOrderDetail {
upstream_order,
job,
}))
}
#[derive(Debug)]
pub enum CancelJobOutcome {
NotFound,
NotCancellable(String),
Cancelled(Box<Job>),
CancelledAndOrderAbandoned {
job: Box<Job>,
order_id: String,
},
}
#[derive(Debug)]
pub enum RunJobNowOutcome {
NotFound,
Refused(String),
Nudged(Box<Job>),
Revived(Box<Job>),
}
#[derive(Debug, thiserror::Error)]
pub enum CancelJobError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
}
pub async fn cancel_job(
id: &str,
actor: Actor,
client: ClientContext,
audit: &Auditor,
database: Arc<Database>,
) -> Result<CancelJobOutcome, CancelJobError> {
let Some(job_id) = acme_proxy_store::id::parse(id) else {
return Ok(CancelJobOutcome::NotFound);
};
let (job, was_in_flight) = match Job::cancel_row(job_id, JobStatus::Ready, &database).await? {
Some(job) => (job, true),
None => match Job::cancel_row(job_id, JobStatus::Failed, &database).await? {
Some(job) => (job, false),
None => {
return Ok(match Job::find_by_id(job_id, &database).await? {
None => CancelJobOutcome::NotFound,
Some(job) => CancelJobOutcome::NotCancellable(job.status),
});
}
},
};
if job.kind == RELAY_JOB_KIND && was_in_flight {
let order_id = job.dedup_key.clone();
if let Some(mut order) = Order::find_by_id(&order_id, &database).await? {
abandon_relayed_order(
&mut order,
"issuance cancelled by operator",
actor,
client,
audit,
&database,
)
.await?;
return Ok(CancelJobOutcome::CancelledAndOrderAbandoned {
job: Box::new(job),
order_id,
});
}
}
if job.kind == acme_proxy_protocol::acme::issue::SIGNER_ISSUE_KIND && was_in_flight {
let order_id = job.dedup_key.clone();
if let Some(mut order) = Order::find_by_id(&order_id, &database).await?
&& order.status == acme_proxy_store::status::OrderStatus::Processing
{
acme_proxy_signer::issuance::record_issue_failure(
&mut order,
&acme_proxy_core::error::Problem::server_internal("Certificate issuance failed"),
"issuance cancelled by operator",
actor,
client,
audit,
&database,
)
.await?;
return Ok(CancelJobOutcome::CancelledAndOrderAbandoned {
job: Box::new(job),
order_id,
});
}
}
audit
.record(acme_proxy_jobs::auditor::admin::job_cancelled(
actor,
client,
&job.kind,
&job.id.to_string(),
))
.await;
Ok(CancelJobOutcome::Cancelled(Box::new(job)))
}
pub async fn confirm_cancel_job(
id: &str,
assume_yes: bool,
reader: &mut impl BufRead,
actor: Actor,
client: ClientContext,
audit: &Auditor,
database: Arc<Database>,
) -> Result<Option<CancelJobOutcome>, CancelJobError> {
let Some(job_id) = acme_proxy_store::id::parse(id) else {
return Ok(Some(CancelJobOutcome::NotFound));
};
let Some(job) = Job::find_by_id(job_id, &database).await? else {
return Ok(Some(CancelJobOutcome::NotFound));
};
let prompt = if job.kind == RELAY_JOB_KIND && job.status == JobStatus::Ready.as_str() {
format!(
"Cancel relay job {id}? Order {} will be marked invalid.",
job.dedup_key
)
} else if is_periodic_job_kind(&job.kind) {
format!(
"Cancel job {id} ({})? This periodic sweep will not run again until \
the server restarts.",
job.kind
)
} else {
format!("Cancel job {id} ({})?", job.kind)
};
if !confirm(&prompt, assume_yes, reader) {
return Ok(None);
}
Ok(Some(cancel_job(id, actor, client, audit, database).await?))
}
pub async fn run_job_now(
id: &str,
actor: Actor,
client: ClientContext,
audit: &Auditor,
database: Arc<Database>,
) -> Result<RunJobNowOutcome, sqlx::Error> {
let Some(job_id) = acme_proxy_store::id::parse(id) else {
return Ok(RunJobNowOutcome::NotFound);
};
if let Some(job) = Job::advance_row(job_id, &database).await? {
audit
.record(acme_proxy_jobs::auditor::admin::job_advanced(
actor,
client,
&job.id.to_string(),
false,
))
.await;
return Ok(RunJobNowOutcome::Nudged(Box::new(job)));
}
if let Some(job) = Job::revive_row(job_id, &database).await? {
audit
.record(acme_proxy_jobs::auditor::admin::job_advanced(
actor,
client,
&job.id.to_string(),
true,
))
.await;
return Ok(RunJobNowOutcome::Revived(Box::new(job)));
}
Ok(match Job::find_by_id(job_id, &database).await? {
None => RunJobNowOutcome::NotFound,
Some(job) => RunJobNowOutcome::Refused(job.status),
})
}
#[cfg(test)]
mod tests {
use super::*;
use acme_proxy_core::audit::AuditEvent;
use acme_proxy_core::audit::AuditRecord;
use acme_proxy_core::identifier::Identifier;
use acme_proxy_signer::SignerBackend;
use acme_proxy_store::nonce::now_secs;
use acme_proxy_store::testutil::account_id;
async fn db() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
fn cli_actor() -> Actor {
Actor::cli()
}
async fn audit_rows(db: &Arc<Database>) -> Vec<AuditEntry> {
AuditEntry::search(
&AuditQuery {
limit: 50,
..AuditQuery::default()
},
db,
)
.await
.unwrap()
.0
}
#[tokio::test]
async fn cleaning_the_audit_trail_is_confirm_gated_and_bounded_by_age() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
AuditEntry::insert(
AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
&db,
)
.await
.unwrap();
let mut declined: &[u8] = b"n\n";
assert_eq!(
confirm_cleanup_audit(0, false, &mut declined, db.clone())
.await
.unwrap(),
None
);
assert_eq!(audit_rows(&db).await.len(), 1);
let mut reader: &[u8] = &[];
assert_eq!(
confirm_cleanup_audit(7, true, &mut reader, db.clone())
.await
.unwrap(),
Some(0)
);
assert_eq!(audit_rows(&db).await.len(), 1);
assert_eq!(cleanup_audit(1, db.clone()).await.unwrap(), 0);
assert_eq!(
AuditEntry::cleanup(acme_proxy_store::audit::audit_cutoff(0) + 3600, &db)
.await
.unwrap(),
1
);
assert!(audit_rows(&db).await.is_empty());
}
#[tokio::test]
async fn listing_and_finding_audit_rows_pages_and_resolves() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let mut ids = Vec::new();
for _ in 0..3 {
ids.push(
AuditEntry::insert(
AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
&db,
)
.await
.unwrap(),
);
}
let (page, total) = list_audit(
&AuditQuery {
limit: 2,
..AuditQuery::default()
},
db.clone(),
)
.await
.unwrap();
assert_eq!(total, 3);
assert_eq!(page.len(), 2);
assert!(find_audit(ids[0], db.clone()).await.unwrap().is_some());
assert!(find_audit(9_999, db).await.unwrap().is_none());
}
#[tokio::test]
async fn revoking_writes_one_audit_row_naming_the_caller() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca(&db);
let order = finalized_order(db.clone(), &signer).await;
let outcome = revoke_order(
order.id.to_string().as_str(),
Some(1),
Actor::admin("root"),
ClientContext {
ip: Some("203.0.113.7".to_string()),
ptr: Some("desk.example.com".to_string()),
..ClientContext::default()
},
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::Revoked(_)));
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1, "{rows:?}");
let row = &rows[0];
assert_eq!(row.event, "certificate_revoked");
assert_eq!(row.outcome, "success");
assert_eq!(row.actor_kind, "admin");
assert_eq!(row.actor_id.as_deref(), Some("root"));
assert_eq!(row.account_id, Some(order.account_id.to_string()));
assert_eq!(row.order_id, Some(order.id.to_string()));
assert_eq!(row.cert_serial, order.cert_serial);
assert_eq!(row.client_ip.as_deref(), Some("203.0.113.7"));
assert_eq!(row.client_ptr.as_deref(), Some("desk.example.com"));
assert_eq!(row.reason.as_deref(), Some("1"));
let outcome = revoke_order(
order.id.to_string().as_str(),
None,
Actor::admin("root"),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
assert_eq!(audit_rows(&db).await.len(), 1);
}
struct Wanting;
#[async_trait::async_trait]
impl acme_proxy_jobs::notify::NotifyBackend for Wanting {
fn name(&self) -> &'static str {
"custom"
}
async fn send(
&self,
_event: &acme_proxy_jobs::notify::NotifyEvent,
) -> Result<(), acme_proxy_jobs::notify::NotifyError> {
Ok(())
}
}
#[tokio::test]
async fn an_operator_revocation_queues_a_certificate_revoked_notification() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca(&db);
let order = finalized_order(db.clone(), &signer).await;
let dispatcher = acme_proxy_jobs::notify::NotifyDispatcher::new(
"default",
vec![acme_proxy_jobs::notify::BackendSlot::new(
"custom:test",
Arc::new(Wanting),
&["certificate_revoked".to_string()],
)],
acme_proxy_jobs::testutil::idle_job_queue(db.clone()),
);
let outcome = revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &Auditor::offline(db.clone()),
notify: Some(&dispatcher),
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::Revoked(_)));
let queued = Job::count_live(acme_proxy_jobs::notify::NOTIFY_JOB_KIND, &db)
.await
.unwrap();
assert_eq!(queued, 1);
}
#[tokio::test]
async fn a_revocation_with_no_reason_leaves_the_column_absent() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca(&db);
let order = finalized_order(db.clone(), &signer).await;
revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap();
let rows = audit_rows(&db).await;
assert_eq!(rows[0].reason, None);
assert_eq!(rows[0].actor_kind, "cli");
assert_eq!(rows[0].client_ip, None);
assert_eq!(rows[0].client_ptr, None);
}
#[tokio::test]
async fn delete_account_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let mut reader: &[u8] = &[];
let outcome = confirm_delete_account("nope", true, &mut reader, db)
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::NotFound);
}
#[tokio::test]
async fn delete_account_cancelled_leaves_row() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let mut reader = b"n\n".as_slice();
let outcome =
confirm_delete_account(acct.to_string().as_str(), false, &mut reader, db.clone())
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::Cancelled);
assert!(
Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn delete_account_confirmed_deletes_and_cascades() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let mut reader: &[u8] = &[];
let outcome =
confirm_delete_account(acct.to_string().as_str(), true, &mut reader, db.clone())
.await
.unwrap();
assert!(matches!(outcome, DeleteOutcome::Deleted(_)));
assert!(
Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn delete_order_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let mut reader: &[u8] = &[];
let outcome = confirm_delete_order("nope", true, &mut reader, db)
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::NotFound);
}
#[tokio::test]
async fn delete_order_cancelled_leaves_row() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let mut reader = b"no\n".as_slice();
let outcome = confirm_delete_order(
order.id.to_string().as_str(),
false,
&mut reader,
db.clone(),
)
.await
.unwrap();
assert_eq!(outcome, DeleteOutcome::Cancelled);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn delete_order_confirmed_deletes_and_cascades() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = Authorization::create(
order.id,
Identifier::dns("example.com"),
acme_proxy_store::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
let mut reader: &[u8] = &[];
let outcome =
confirm_delete_order(order.id.to_string().as_str(), true, &mut reader, db.clone())
.await
.unwrap();
assert!(matches!(outcome, DeleteOutcome::Deleted(_)));
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
assert!(
Authorization::find_by_id(authz.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn bare_delete_account_reports_not_found_for_an_unknown_id() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert_eq!(
delete_account("nope", db).await.unwrap(),
Deletion::NotFound
);
}
#[tokio::test]
async fn bare_delete_account_deletes_and_counts_the_cascade() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
for _ in 0..2 {
Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
}
assert_eq!(
delete_account(acct.to_string().as_str(), db.clone())
.await
.unwrap(),
Deletion::Deleted(Deleted { cascaded: 2 })
);
assert!(
Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn bare_delete_order_reports_not_found_for_an_unknown_id() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert_eq!(delete_order("nope", db).await.unwrap(), Deletion::NotFound);
}
#[tokio::test]
async fn bare_delete_order_deletes_and_counts_the_cascade() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
Authorization::create(
order.id,
Identifier::dns("example.com"),
acme_proxy_store::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
assert_eq!(
delete_order(order.id.to_string().as_str(), db.clone())
.await
.unwrap(),
Deletion::Deleted(Deleted { cascaded: 1 })
);
assert!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn a_live_certificate_refuses_account_and_order_deletes_before_asking() {
let db = db().await;
let acct = account_id(&db).await;
let order = acme_proxy_store::testutil::certified_order(&db, acct, None).await;
let (acct, order) = (acct.to_string(), order.id.to_string());
let mut reader: &[u8] = &[];
assert_eq!(
confirm_delete_account(&acct, false, &mut reader, db.clone())
.await
.unwrap(),
DeleteOutcome::LiveCertificates(1)
);
assert_eq!(
confirm_delete_order(&order, false, &mut reader, db.clone())
.await
.unwrap(),
DeleteOutcome::LiveCertificates(1)
);
assert_eq!(
delete_account(&acct, db.clone()).await.unwrap(),
Deletion::LiveCertificates(1)
);
assert_eq!(
delete_order(&order, db.clone()).await.unwrap(),
Deletion::LiveCertificates(1)
);
assert!(Order::find_by_id(&order, &db).await.unwrap().is_some());
}
#[test]
fn the_refusals_name_the_subject_the_count_and_the_way_out() {
let message = live_certificates_refusal("account a-1", 2);
assert!(message.starts_with("account a-1 holds 2 live certificate(s)"));
assert!(message.contains("revoke them first"));
let message = eab_live_certificates_refusal("k-1", 1, 3);
assert!(message.starts_with("1 account(s) bound to EAB credential k-1 hold 3 live"));
assert!(message.contains("deactivate the accounts instead"));
}
async fn eab_with_live_account(db: &Arc<Database>) -> (Eab, uuid::Uuid) {
let eab = Eab::create(None, None, db).await.unwrap();
let (mut account, _) =
Account::find_or_create("default", &[42u8], vec![], &ClientContext::default(), db)
.await
.unwrap();
account.set_eab_kid(eab.kid, db).await.unwrap();
acme_proxy_store::testutil::certified_order(db, account.id, None).await;
(eab, account.id)
}
#[tokio::test]
async fn confirm_delete_eab_not_found_cancelled_and_refused() {
let db = db().await;
let mut reader: &[u8] = &[];
assert!(matches!(
confirm_delete_eab("nope", BoundAccounts::Keep, true, &mut reader, db.clone())
.await
.unwrap(),
EabDeleteOutcome::NotFound
));
let (eab, _) = eab_with_live_account(&db).await;
let kid = eab.kid.to_string();
let mut declined: &[u8] = b"n\n";
assert!(matches!(
confirm_delete_eab(&kid, BoundAccounts::Keep, false, &mut declined, db.clone())
.await
.unwrap(),
EabDeleteOutcome::Cancelled
));
assert!(Eab::find_any_by_kid(&kid, &db).await.unwrap().is_some());
let mut reader: &[u8] = &[];
assert!(matches!(
confirm_delete_eab(&kid, BoundAccounts::Delete, false, &mut reader, db.clone())
.await
.unwrap(),
EabDeleteOutcome::LiveCertificates {
accounts: 1,
certificates: 1
}
));
assert!(Eab::find_any_by_kid(&kid, &db).await.unwrap().is_some());
}
#[tokio::test]
async fn confirm_delete_eab_deactivating_keeps_the_live_certificate() {
let db = db().await;
let (eab, account) = eab_with_live_account(&db).await;
let mut reader: &[u8] = &[];
let EabDeleteOutcome::Deleted(deleted) = confirm_delete_eab(
&eab.kid.to_string(),
BoundAccounts::Deactivate,
true,
&mut reader,
db.clone(),
)
.await
.unwrap() else {
panic!("deactivating is never refused");
};
assert_eq!(deleted.deactivated.len(), 1);
assert_eq!(Order::find_by_account(account, &db).await.unwrap().len(), 1);
assert!(
delete_eab(&eab.kid.to_string(), BoundAccounts::Keep, db.clone())
.await
.map(|deletion| matches!(deletion, EabDeletion::NotFound))
.unwrap()
);
}
#[tokio::test]
async fn bare_cleanup_nonces_removes_stale_rows_without_asking() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let stale = Nonce {
value: "stale".to_string(),
created_at: acme_proxy_store::nonce::now_secs() - 10_000,
};
stale.save(&db).await.unwrap();
Nonce::new().save(&db).await.unwrap();
assert_eq!(
cleanup_nonces(Duration::from_secs(300), db.clone())
.await
.unwrap(),
1
);
assert!(
!Nonce::verify("stale", &db, Duration::from_secs(300))
.await
.unwrap()
);
}
fn in_memory_ca(database: &Arc<Database>) -> Arc<dyn SignerBackend> {
Arc::new(
acme_proxy_signer::local_ca::LocalCa::generate_in_memory(
"ecdsa-p256",
90,
database.clone(),
)
.expect("in-memory CA"),
)
}
async fn finalized_order(db: Arc<Database>, signer: &Arc<dyn SignerBackend>) -> Order {
let acct = account_id(&db).await;
let mut order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
let csr = params.serialize_request(&key_pair).unwrap();
let chain = match signer
.issue(
order.id.to_string().as_str(),
csr.der(),
&order.identifiers,
acme_proxy_signer::RequestedValidity::default(),
)
.await
.unwrap()
{
acme_proxy_signer::IssueOutcome::Issued(chain) => chain,
acme_proxy_signer::IssueOutcome::Processing => {
panic!("the in-memory local CA issues synchronously")
}
};
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();
let not_after = acme_proxy_core::cert::cert_validity(&leaf)
.ok()
.map(|(_, na)| na);
order
.finalize(chain, serial, pubkey, not_after, &db)
.await
.unwrap();
order
}
#[tokio::test]
async fn revoke_order_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let outcome = revoke_order(
"nope",
None,
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(
in_memory_ca(&db).as_ref(),
),
},
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::NotFound));
}
#[tokio::test]
async fn revoke_order_without_a_certificate_is_refused() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let outcome = revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(
in_memory_ca(&db).as_ref(),
),
},
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::NotIssued));
}
#[tokio::test]
async fn revoke_order_persists() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca(&db);
let order = finalized_order(db.clone(), &signer).await;
let outcome = revoke_order(
order.id.to_string().as_str(),
Some(1),
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap();
let RevokeOutcome::Revoked(revoked) = outcome else {
panic!("expected Revoked, got {outcome:?}");
};
assert!(revoked.revoked_at.is_some());
assert_eq!(revoked.revocation_reason, Some(1));
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(reloaded.revoked_at.is_some());
use x509_parser::prelude::FromDer;
let der = signer.info().crl_der().await.unwrap().unwrap();
let (_, crl) =
x509_parser::revocation_list::CertificateRevocationList::from_der(&der).unwrap();
assert_eq!(crl.iter_revoked_certificates().count(), 1);
}
#[tokio::test]
async fn revoke_order_already_revoked() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca(&db);
let order = finalized_order(db.clone(), &signer).await;
revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap();
let outcome = revoke_order(
order.id.to_string().as_str(),
None,
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap();
assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
}
#[tokio::test]
async fn revoke_order_bad_reason_is_refused() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = in_memory_ca(&db);
let order = finalized_order(db.clone(), &signer).await;
let error = revoke_order(
order.id.to_string().as_str(),
Some(999),
cli_actor(),
ClientContext::default(),
acme_proxy_protocol::acme::revoke::Revocations {
database: &db,
audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
notify: None,
revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
},
)
.await
.unwrap_err();
assert!(matches!(error, RevokeError::BadReason(999)));
}
#[tokio::test]
async fn cleanup_nonces_cancelled_leaves_nonces() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
Nonce {
value: "stale".to_string(),
created_at: acme_proxy_store::nonce::now_secs() - 600,
}
.save(&db)
.await
.unwrap();
let mut reader = b"n\n".as_slice();
let outcome =
confirm_cleanup_nonces(Duration::from_secs(300), false, &mut reader, db.clone())
.await
.unwrap();
assert_eq!(outcome, None);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
.fetch_one(db.raw_pool())
.await
.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn cleanup_nonces_confirmed_removes_stale_and_reports_count() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
Nonce {
value: "stale".to_string(),
created_at: acme_proxy_store::nonce::now_secs() - 600,
}
.save(&db)
.await
.unwrap();
let mut reader: &[u8] = &[];
let outcome =
confirm_cleanup_nonces(Duration::from_secs(300), true, &mut reader, db.clone())
.await
.unwrap();
assert_eq!(outcome, Some(1));
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
.fetch_one(db.raw_pool())
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn update_account_contact_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(
update_account_contact("nope", vec![], db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn update_account_contact_persists() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let contact = vec!["mailto:a@example.com".to_string()];
let updated =
update_account_contact(acct.to_string().as_str(), contact.clone(), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(updated.contact, contact);
let reloaded = Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.contact, contact);
}
#[tokio::test]
async fn deactivate_account_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(
deactivate_account("nope", db, |_| None, None)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn deactivate_account_persists() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let dispatcher = Arc::new(acme_proxy_jobs::notify::NotifyDispatcher::new(
"default",
vec![acme_proxy_jobs::notify::BackendSlot::new(
"custom:test",
Arc::new(Wanting),
&["account_deactivated".to_string()],
)],
acme_proxy_jobs::testutil::idle_job_queue(db.clone()),
));
let updated = deactivate_account(
acct.to_string().as_str(),
db.clone(),
|profile| (profile == "default").then(|| dispatcher.clone()),
Some("203.0.113.7".to_string()),
)
.await
.unwrap()
.unwrap();
assert_eq!(updated.status, "deactivated");
assert_eq!(
Job::count_live(acme_proxy_jobs::notify::NOTIFY_JOB_KIND, &db)
.await
.unwrap(),
1
);
let reloaded = Account::find_by_id("default", acct.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.status, "deactivated");
}
#[tokio::test]
async fn load_order_detail_not_found() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
assert!(load_order_detail("nope", db).await.unwrap().is_none());
}
#[tokio::test]
async fn load_order_detail_nests_authorizations_and_challenges() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = Authorization::create(
order.id,
Identifier::dns("example.com"),
acme_proxy_store::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
Challenge::create(authz.id, "http-01", &db).await.unwrap();
let detail = load_order_detail(order.id.to_string().as_str(), db)
.await
.unwrap()
.unwrap();
assert_eq!(detail.order.id, order.id);
assert_eq!(detail.authorizations.len(), 1);
assert_eq!(detail.authorizations[0].0.id, authz.id);
assert_eq!(detail.authorizations[0].1.len(), 1);
assert_eq!(detail.authorizations[0].1[0].typ, "http-01");
}
use acme_proxy_store::job::Job;
use acme_proxy_store::job::NewJob;
async fn relay_job(db: &Arc<Database>) -> (Order, Job) {
let acct = account_id(db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
now_secs() + 3600,
None,
None,
db,
)
.await
.unwrap();
UpstreamOrder::create(
order.id.to_string().as_str(),
"https://up.example/o/1",
None,
b"csr",
db,
)
.await
.unwrap();
let id = acme_proxy_store::id::mint();
Job::enqueue(
NewJob {
id,
kind: RELAY_JOB_KIND,
dedup_key: &order.id.to_string(),
payload: &serde_json::json!({ "order_id": order.id.to_string(), "profile": "default" }),
run_at: now_secs(),
deadline: Some(order.expires),
max_attempts: 5,
},
db,
)
.await
.unwrap();
let job = Job::find_by_id(id, db).await.unwrap().unwrap();
(order, job)
}
async fn sweep_job(db: &Arc<Database>) -> Job {
let id = acme_proxy_store::id::mint();
Job::enqueue(
NewJob {
id,
kind: "nonce_sweep",
dedup_key: "nonce_sweep",
payload: &serde_json::json!({}),
run_at: now_secs() + 3600,
deadline: None,
max_attempts: 5,
},
db,
)
.await
.unwrap();
Job::find_by_id(id, db).await.unwrap().unwrap()
}
#[tokio::test]
async fn load_job_detail_attaches_the_upstream_order_only_for_a_relay_job() {
let db = db().await;
let (order, job) = relay_job(&db).await;
let detail = load_job_detail(job.id.to_string().as_str(), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(detail.job.id, job.id);
assert_eq!(detail.upstream_order.as_ref().unwrap().order_id, order.id);
let sweep = sweep_job(&db).await;
let detail = load_job_detail(sweep.id.to_string().as_str(), db.clone())
.await
.unwrap()
.unwrap();
assert!(detail.upstream_order.is_none());
assert!(load_job_detail("nope", db.clone()).await.unwrap().is_none());
assert!(
load_job_detail(acme_proxy_store::id::mint().to_string().as_str(), db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn load_job_detail_resolves_the_cross_link_after_the_job_is_done() {
let db = db().await;
let (_order, job) = relay_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
.bind(job.id)
.execute(db.raw_pool())
.await
.unwrap();
let detail = load_job_detail(job.id.to_string().as_str(), db)
.await
.unwrap()
.unwrap();
assert!(detail.upstream_order.is_some());
}
#[tokio::test]
async fn load_upstream_order_detail_finds_the_latest_job_even_when_terminal() {
let db = db().await;
let (order, job) = relay_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'failed' WHERE id = ?;")
.bind(job.id)
.execute(db.raw_pool())
.await
.unwrap();
let detail = load_upstream_order_detail(order.id.to_string().as_str(), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(detail.upstream_order.order_id, order.id);
assert_eq!(detail.job.as_ref().unwrap().id, job.id);
assert!(
load_upstream_order_detail("nope", db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn cancel_job_on_a_ready_sweep_writes_a_plain_job_cancelled_row_and_no_order_change() {
let db = db().await;
let sweep = sweep_job(&db).await;
let outcome = cancel_job(
sweep.id.to_string().as_str(),
cli_actor(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap();
assert!(matches!(outcome, CancelJobOutcome::Cancelled(_)));
assert_eq!(
Job::find_by_id(sweep.id, &db)
.await
.unwrap()
.unwrap()
.status,
"cancelled"
);
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].event, "job_cancelled");
assert_eq!(rows[0].outcome, "success");
assert_eq!(rows[0].actor_kind, "cli");
assert!(rows[0].order_id.is_none());
assert!(rows[0].account_id.is_none());
}
#[tokio::test]
async fn cancel_job_on_a_ready_relay_job_abandons_the_order_and_audits_the_operator() {
let db = db().await;
let (order, job) = relay_job(&db).await;
let outcome = cancel_job(
job.id.to_string().as_str(),
Actor::admin("root"),
ClientContext {
ip: Some("203.0.113.7".to_string()),
..ClientContext::default()
},
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap();
let CancelJobOutcome::CancelledAndOrderAbandoned { order_id, .. } = outcome else {
panic!("expected CancelledAndOrderAbandoned, got {outcome:?}");
};
assert_eq!(order_id, order.id.to_string());
assert_eq!(
Job::find_by_id(job.id, &db).await.unwrap().unwrap().status,
"cancelled"
);
let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.status.as_str(), "invalid");
let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(mapping.status, "invalid");
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].event, "certificate_issue_failed");
assert_eq!(rows[0].actor_kind, "admin");
assert_eq!(rows[0].actor_id.as_deref(), Some("root"));
assert_eq!(rows[0].client_ip.as_deref(), Some("203.0.113.7"));
}
#[tokio::test]
async fn cancel_job_on_a_failed_relay_job_keeps_the_upstream_error_and_writes_one_row() {
let db = db().await;
let (order, job) = relay_job(&db).await;
acme_proxy_signer::relay::abandon_relayed_order(
&mut Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap(),
"urn:ietf:params:acme:error:rejectedIdentifier from the upstream",
Actor::cli(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
&db,
)
.await
.unwrap();
Job::claim_next(
"runner-a",
&[RELAY_JOB_KIND],
now_secs() + 60,
now_secs(),
&db,
)
.await
.unwrap()
.expect("the relay job is claimable");
assert!(
Job::abandon(job.id, "runner-a", "gave up", &db)
.await
.unwrap()
);
let rows_before = audit_rows(&db).await.len();
let outcome = cancel_job(
job.id.to_string().as_str(),
Actor::admin("root"),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap();
assert!(
matches!(outcome, CancelJobOutcome::Cancelled(_)),
"a failed relay job has nothing left to abandon: {outcome:?}"
);
let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert_eq!(
mapping.error.as_deref(),
Some("urn:ietf:params:acme:error:rejectedIdentifier from the upstream"),
"the upstream's own reason must survive the cancellation"
);
let rows = audit_rows(&db).await;
assert_eq!(
rows.len(),
rows_before + 1,
"one issuance, one certificate_issue_failed row"
);
assert_eq!(rows[0].event, "job_cancelled");
}
#[tokio::test]
async fn cancel_job_on_a_relay_job_with_no_order_says_so_and_still_audits() {
let db = db().await;
let (order, job) = relay_job(&db).await;
Order::delete(order.id.to_string().as_str(), &db)
.await
.unwrap();
let outcome = cancel_job(
job.id.to_string().as_str(),
Actor::admin("root"),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap();
assert!(
matches!(outcome, CancelJobOutcome::Cancelled(_)),
"nothing was abandoned, so nothing may say it was: {outcome:?}"
);
let rows = audit_rows(&db).await;
assert_eq!(rows.len(), 1, "the cancellation is still an admin action");
assert_eq!(rows[0].event, "job_cancelled");
}
#[tokio::test]
async fn cancel_job_refuses_a_running_job_and_leaves_the_order_alone() {
let db = db().await;
let (order, job) = relay_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'running' WHERE id = ?;")
.bind(job.id)
.execute(db.raw_pool())
.await
.unwrap();
let outcome = cancel_job(
job.id.to_string().as_str(),
cli_actor(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap();
assert!(matches!(
outcome,
CancelJobOutcome::NotCancellable(s) if s == "running"
));
assert_ne!(
Order::find_by_id(order.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap()
.status
.as_str(),
"invalid"
);
}
#[tokio::test]
async fn cancel_job_not_found_versus_not_cancellable() {
let db = db().await;
assert!(matches!(
cancel_job(
"nope",
cli_actor(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone()
)
.await
.unwrap(),
CancelJobOutcome::NotFound
));
assert!(matches!(
cancel_job(
acme_proxy_store::id::mint().to_string().as_str(),
cli_actor(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone()
)
.await
.unwrap(),
CancelJobOutcome::NotFound
));
let sweep = sweep_job(&db).await;
sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
.bind(sweep.id)
.execute(db.raw_pool())
.await
.unwrap();
assert!(matches!(
cancel_job(
sweep.id.to_string().as_str(),
cli_actor(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db,
)
.await
.unwrap(),
CancelJobOutcome::NotCancellable(s) if s == "done"
));
}
#[tokio::test]
async fn confirm_cancel_job_declined_leaves_the_job() {
let db = db().await;
let sweep = sweep_job(&db).await;
let mut reader = b"n\n".as_slice();
assert!(
confirm_cancel_job(
sweep.id.to_string().as_str(),
false,
&mut reader,
cli_actor(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap()
.is_none()
);
assert_eq!(
Job::find_by_id(sweep.id, &db)
.await
.unwrap()
.unwrap()
.status,
"ready"
);
}
#[tokio::test]
async fn run_job_now_nudges_ready_revives_failed_and_refuses_the_rest() {
let db = db().await;
let sweep = sweep_job(&db).await; let outcome = run_job_now(
sweep.id.to_string().as_str(),
Actor::cli(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap();
let RunJobNowOutcome::Nudged(job) = outcome else {
panic!("expected Nudged, got {outcome:?}");
};
assert!(job.run_at <= now_secs() + 1);
let (_order, relay) = relay_job(&db).await;
sqlx::query(
"UPDATE jobs SET status = 'failed', attempts = 5, last_error = 'boom' WHERE id = ?;",
)
.bind(relay.id)
.execute(db.raw_pool())
.await
.unwrap();
let outcome = run_job_now(
relay.id.to_string().as_str(),
Actor::cli(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
)
.await
.unwrap();
let RunJobNowOutcome::Revived(job) = outcome else {
panic!("expected Revived, got {outcome:?}");
};
assert_eq!(job.status, "ready");
assert_eq!(job.attempts, 4, "max_attempts - 1");
sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
.bind(sweep.id)
.execute(db.raw_pool())
.await
.unwrap();
assert!(matches!(
run_job_now(
sweep.id.to_string().as_str(),
Actor::cli(),
ClientContext::default(),
&acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
db.clone(),
).await.unwrap(),
RunJobNowOutcome::Refused(s) if s == "done"
));
assert!(matches!(
run_job_now(
"nope",
Actor::cli(),
ClientContext::default(),
&Auditor::offline(db.clone()),
db
)
.await
.unwrap(),
RunJobNowOutcome::NotFound
));
}
#[test]
fn revoke_error_display_formatting() {
let db_err: RevokeError = sqlx::Error::RowNotFound.into();
assert!(format!("{db_err}").contains("database error"));
let signer_internal: RevokeError = SignerError::Internal("test".to_string()).into();
assert!(format!("{signer_internal}").contains("signer error: test"));
let signer_bad_csr: RevokeError = SignerError::BadCsr.into();
assert!(format!("{signer_bad_csr}").contains("unexpected badCsr"));
let internal = RevokeError::Internal("detail".to_string());
assert!(format!("{internal}").contains("internal error: detail"));
let bad_reason = RevokeError::BadReason(7);
assert!(format!("{bad_reason}").contains("unsupported revocation reason code 7"));
}
}