1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CertState {
12 Requested,
13 PolicyValidated,
14 DnsChallengePrepared,
15 DnsChallengePropagating,
16 Issuing,
17 Issued,
18 RenewalDue,
19 Renewing,
20 Failed,
21 Revoked,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct IssuanceRequest {
26 pub request_id: String,
27 pub hostname: String,
28 pub actor: String,
29 pub requested_at: DateTime<Utc>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct StateTransition {
34 pub from: CertState,
35 pub to: CertState,
36 pub reason: String,
37 pub at: DateTime<Utc>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct IssuanceReport {
42 pub request_id: String,
43 pub hostname: String,
44 pub actor: String,
45 pub status: String,
46 pub started_at: DateTime<Utc>,
47 pub completed_at: DateTime<Utc>,
48 pub transitions: Vec<StateTransition>,
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum RequestValidationError {
53 #[error("hostname must contain at least one dot")]
54 InvalidHostname,
55 #[error("hostname must be a valid RFC 1123-style DNS name")]
56 InvalidHostnameFormat,
57}
58
59pub fn new_request(hostname: String, actor: String) -> IssuanceRequest {
60 IssuanceRequest {
61 request_id: Uuid::new_v4().to_string(),
62 hostname,
63 actor,
64 requested_at: Utc::now(),
65 }
66}
67
68pub fn validate_hostname(hostname: &str) -> Result<(), RequestValidationError> {
69 if !hostname.contains('.') {
70 return Err(RequestValidationError::InvalidHostname);
71 }
72
73 if hostname.is_empty() || hostname.len() > 253 {
74 return Err(RequestValidationError::InvalidHostnameFormat);
75 }
76
77 for label in hostname.split('.') {
78 if label.is_empty() || label.len() > 63 {
79 return Err(RequestValidationError::InvalidHostnameFormat);
80 }
81
82 let bytes = label.as_bytes();
83 if bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') {
84 return Err(RequestValidationError::InvalidHostnameFormat);
85 }
86
87 if !bytes
88 .iter()
89 .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
90 {
91 return Err(RequestValidationError::InvalidHostnameFormat);
92 }
93 }
94
95 Ok(())
96}
97
98pub fn validate_request(req: &IssuanceRequest) -> Result<(), RequestValidationError> {
99 validate_hostname(&req.hostname)
100}
101
102pub fn dry_run_issue(req: &IssuanceRequest) -> Result<IssuanceReport, RequestValidationError> {
103 validate_request(req)?;
104
105 let started = Utc::now();
106 let transitions = vec![
107 StateTransition {
108 from: CertState::Requested,
109 to: CertState::PolicyValidated,
110 reason: "policy checks passed".to_string(),
111 at: Utc::now(),
112 },
113 StateTransition {
114 from: CertState::PolicyValidated,
115 to: CertState::DnsChallengePrepared,
116 reason: "acme dns challenge prepared".to_string(),
117 at: Utc::now(),
118 },
119 StateTransition {
120 from: CertState::DnsChallengePrepared,
121 to: CertState::DnsChallengePropagating,
122 reason: "waiting for dns propagation".to_string(),
123 at: Utc::now(),
124 },
125 StateTransition {
126 from: CertState::DnsChallengePropagating,
127 to: CertState::Issuing,
128 reason: "acme order started".to_string(),
129 at: Utc::now(),
130 },
131 StateTransition {
132 from: CertState::Issuing,
133 to: CertState::Issued,
134 reason: "certificate issued (dry run)".to_string(),
135 at: Utc::now(),
136 },
137 ];
138
139 Ok(IssuanceReport {
140 request_id: req.request_id.clone(),
141 hostname: req.hostname.clone(),
142 actor: req.actor.clone(),
143 status: "success".to_string(),
144 started_at: started,
145 completed_at: Utc::now(),
146 transitions,
147 })
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AuditRecord {
152 pub id: String,
153 pub timestamp: DateTime<Utc>,
154 pub actor: String,
155 pub operation: String,
156 pub resource: String,
157 pub status: String,
158 pub message: String,
159}
160
161pub fn write_audit_jsonl(path: &Path, report: &IssuanceReport) -> anyhow::Result<()> {
162 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
163 let rec = AuditRecord {
164 id: Uuid::new_v4().to_string(),
165 timestamp: Utc::now(),
166 actor: report.actor.clone(),
167 operation: "dry_run_issue".to_string(),
168 resource: report.hostname.clone(),
169 status: report.status.clone(),
170 message: format!("request_id={}", report.request_id),
171 };
172 let line = serde_json::to_string(&rec)?;
173 writeln!(file, "{line}")?;
174 Ok(())
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CloudEvent {
179 pub specversion: String,
180 pub id: String,
181 pub source: String,
182 #[serde(rename = "type")]
183 pub event_type: String,
184 pub time: DateTime<Utc>,
185 pub subject: String,
186 pub datacontenttype: String,
187 pub data: serde_json::Value,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct TraefikStaticConfigInput {
192 pub email: String,
193 pub resolver_name: String,
194 pub cert_storage_file: String,
195 pub cloudflare_token_env: String,
196 pub log_level: String,
197}
198
199impl Default for TraefikStaticConfigInput {
200 fn default() -> Self {
201 Self {
202 email: "ops@example.com".to_string(),
203 resolver_name: "cloudflare".to_string(),
204 cert_storage_file: "/var/traefik/certs/acme.json".to_string(),
205 cloudflare_token_env: "CF_DNS_API_TOKEN".to_string(),
206 log_level: "INFO".to_string(),
207 }
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct TraefikRouterConfig {
213 pub service_name: String,
214 pub hostname: String,
215 pub service_port: u16,
216 pub resolver_name: String,
217}
218
219impl TraefikRouterConfig {
220 pub fn router_base_name(&self) -> String {
221 self.service_name.replace('_', "-")
222 }
223}
224
225pub fn render_traefik_static_yaml(input: &TraefikStaticConfigInput) -> String {
226 format!(
227 "global:\n checkNewVersion: false\n sendAnonymousUsage: false\n\nlog:\n level: {log_level}\n\napi:\n dashboard: true\n\nentryPoints:\n web:\n address: \":80\"\n http:\n redirections:\n entryPoint:\n to: websecure\n scheme: https\n permanent: true\n websecure:\n address: \":443\"\n\nproviders:\n docker:\n endpoint: \"unix:///var/run/docker.sock\"\n exposedByDefault: false\n\ncertificatesResolvers:\n {resolver_name}:\n acme:\n email: \"{email}\"\n storage: \"{storage}\"\n dnsChallenge:\n provider: cloudflare\n resolvers:\n - \"1.1.1.1:53\"\n - \"8.8.8.8:53\"\n\n# Required environment variable at runtime:\n# {token_env}=<cloudflare_dns_api_token>\n",
228 log_level = input.log_level,
229 resolver_name = input.resolver_name,
230 email = input.email,
231 storage = input.cert_storage_file,
232 token_env = input.cloudflare_token_env,
233 )
234}
235
236pub fn generate_router_labels(config: &TraefikRouterConfig) -> Vec<String> {
237 let base = config.router_base_name();
238 vec![
239 "traefik.enable=true".to_string(),
240 format!(
241 "traefik.http.routers.{base}-http.rule=Host(`{host}`)",
242 host = config.hostname
243 ),
244 format!("traefik.http.routers.{base}-http.entrypoints=web"),
245 format!(
246 "traefik.http.routers.{base}-https.rule=Host(`{host}`)",
247 host = config.hostname
248 ),
249 format!("traefik.http.routers.{base}-https.entrypoints=websecure"),
250 format!("traefik.http.routers.{base}-https.tls=true"),
251 format!(
252 "traefik.http.routers.{base}-https.tls.certresolver={resolver}",
253 resolver = config.resolver_name
254 ),
255 format!(
256 "traefik.http.services.{base}.loadbalancer.server.port={port}",
257 port = config.service_port
258 ),
259 ]
260}
261
262pub fn cert_state_names() -> Vec<&'static str> {
263 vec![
264 "requested",
265 "policy_validated",
266 "dns_challenge_prepared",
267 "dns_challenge_propagating",
268 "issuing",
269 "issued",
270 "renewal_due",
271 "renewing",
272 "failed",
273 "revoked",
274 ]
275}
276
277pub fn events_from_report(report: &IssuanceReport) -> Vec<CloudEvent> {
278 report
279 .transitions
280 .iter()
281 .map(|t| CloudEvent {
282 specversion: "1.0".to_string(),
283 id: Uuid::new_v4().to_string(),
284 source: "com.tencrypt.issuer".to_string(),
285 event_type: format!(
286 "com.tencrypt.certificate.{}.v1",
287 state_name(t.to).replace('_', "-")
288 ),
289 time: t.at,
290 subject: report.hostname.clone(),
291 datacontenttype: "application/json".to_string(),
292 data: serde_json::json!({
293 "request_id": report.request_id,
294 "hostname": report.hostname,
295 "actor": report.actor,
296 "from": state_name(t.from),
297 "to": state_name(t.to),
298 "reason": t.reason,
299 }),
300 })
301 .collect()
302}
303
304pub fn write_events_jsonl(path: &Path, report: &IssuanceReport) -> anyhow::Result<()> {
305 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
306 for event in events_from_report(report) {
307 let line = serde_json::to_string(&event)?;
308 writeln!(file, "{line}")?;
309 }
310 Ok(())
311}
312
313pub fn cert_state_from_str(s: &str) -> Option<CertState> {
315 match s {
316 "requested" => Some(CertState::Requested),
317 "policy_validated" => Some(CertState::PolicyValidated),
318 "dns_challenge_prepared" => Some(CertState::DnsChallengePrepared),
319 "dns_challenge_propagating" => Some(CertState::DnsChallengePropagating),
320 "issuing" => Some(CertState::Issuing),
321 "issued" => Some(CertState::Issued),
322 "renewal_due" => Some(CertState::RenewalDue),
323 "renewing" => Some(CertState::Renewing),
324 "failed" => Some(CertState::Failed),
325 "revoked" => Some(CertState::Revoked),
326 _ => None,
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum ReconcileDecision {
337 Proceed,
339 Wait,
341 Done,
343 RetryOrAbandon,
345}
346
347pub fn reconcile_step(state: CertState) -> ReconcileDecision {
350 match state {
351 CertState::Requested => ReconcileDecision::Proceed,
352 CertState::PolicyValidated => ReconcileDecision::Proceed,
353 CertState::DnsChallengePrepared => ReconcileDecision::Proceed,
354 CertState::DnsChallengePropagating => ReconcileDecision::Wait,
355 CertState::Issuing => ReconcileDecision::Proceed,
356 CertState::Issued => ReconcileDecision::Done,
357 CertState::RenewalDue => ReconcileDecision::Proceed,
358 CertState::Renewing => ReconcileDecision::Proceed,
359 CertState::Failed => ReconcileDecision::RetryOrAbandon,
360 CertState::Revoked => ReconcileDecision::Done,
361 }
362}
363
364use std::time::Duration;
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct BackoffConfig {
371 pub max_retries: u32,
373 pub base_delay_ms: u64,
375 pub max_delay_ms: u64,
377}
378
379impl Default for BackoffConfig {
380 fn default() -> Self {
381 Self {
382 max_retries: 5,
383 base_delay_ms: 1_000,
384 max_delay_ms: 60_000,
385 }
386 }
387}
388
389#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391pub struct BackoffState {
392 pub attempt: u32,
394}
395
396pub fn next_backoff_delay(config: &BackoffConfig, state: &BackoffState) -> Option<Duration> {
399 if state.attempt >= config.max_retries {
400 return None;
401 }
402 let multiplier = 1u64.checked_shl(state.attempt).unwrap_or(u64::MAX);
403 let delay_ms = config
404 .base_delay_ms
405 .saturating_mul(multiplier)
406 .min(config.max_delay_ms);
407 Some(Duration::from_millis(delay_ms))
408}
409
410pub fn next_backoff_delay_jittered(
417 config: &BackoffConfig,
418 state: &BackoffState,
419 jitter_ms: u64,
420) -> Option<Duration> {
421 if state.attempt >= config.max_retries {
422 return None;
423 }
424 let multiplier = 1u64.checked_shl(state.attempt).unwrap_or(u64::MAX);
425 let base_ms = config.base_delay_ms.saturating_mul(multiplier);
426 let delay_ms = base_ms.saturating_add(jitter_ms).min(config.max_delay_ms);
427 Some(Duration::from_millis(delay_ms))
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct DomainPolicy {
437 pub hostname_pattern: String,
438 pub backoff: Option<BackoffConfig>,
439}
440
441pub fn resolve_backoff<'a>(
446 hostname: &str,
447 policies: &'a [DomainPolicy],
448 default: &'a BackoffConfig,
449) -> &'a BackoffConfig {
450 policies
451 .iter()
452 .find(|p| p.hostname_pattern == hostname)
453 .and_then(|p| p.backoff.as_ref())
454 .unwrap_or(default)
455}
456
457#[derive(Debug, Default, Clone, Serialize, Deserialize)]
462pub struct CertMetrics {
463 pub issued_total: u64,
465 pub failed_total: u64,
467 pub in_flight: u64,
469}
470
471impl CertMetrics {
472 pub fn record_start(&mut self) {
474 self.in_flight += 1;
475 }
476
477 pub fn record_success(&mut self) {
479 self.issued_total += 1;
480 self.in_flight = self.in_flight.saturating_sub(1);
481 }
482
483 pub fn record_failure(&mut self) {
485 self.failed_total += 1;
486 self.in_flight = self.in_flight.saturating_sub(1);
487 }
488}
489
490#[derive(Debug, Deserialize)]
495struct AcmeFile {
496 #[serde(flatten)]
497 resolvers: std::collections::HashMap<String, AcmeResolver>,
498}
499
500#[derive(Debug, Deserialize)]
501struct AcmeResolver {
502 #[serde(rename = "Certificates")]
503 certificates: Option<Vec<AcmeCert>>,
504}
505
506#[derive(Debug, Deserialize)]
507struct AcmeCert {
508 domain: AcmeDomain,
509}
510
511#[derive(Debug, Deserialize)]
512struct AcmeDomain {
513 main: String,
514 #[serde(default)]
515 sans: Vec<String>,
516}
517
518pub fn acme_cert_present(path: &Path, resolver: &str, hostname: &str) -> anyhow::Result<bool> {
528 let raw = match std::fs::read_to_string(path) {
529 Ok(s) => s,
530 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
531 Err(e) => return Err(e.into()),
532 };
533 if raw.trim().is_empty() {
534 return Ok(false);
535 }
536 let acme: AcmeFile = serde_json::from_str(&raw)?;
537 let Some(res) = acme.resolvers.get(resolver) else {
538 return Ok(false);
539 };
540 let certs = res.certificates.as_deref().unwrap_or(&[]);
541 Ok(certs
542 .iter()
543 .any(|c| c.domain.main == hostname || c.domain.sans.iter().any(|s| s == hostname)))
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct CertRecord {
551 pub hostname: String,
552 pub state: CertState,
553 pub attempt: u32,
555 pub last_updated: DateTime<Utc>,
556}
557
558impl CertRecord {
559 pub fn new(hostname: String) -> Self {
560 Self {
561 hostname,
562 state: CertState::Requested,
563 attempt: 0,
564 last_updated: Utc::now(),
565 }
566 }
567}
568
569#[derive(Debug, Default, Serialize, Deserialize)]
572pub struct StateStore {
573 pub certs: Vec<CertRecord>,
574}
575
576impl StateStore {
577 pub fn load(path: &Path) -> anyhow::Result<Self> {
579 match std::fs::read_to_string(path) {
580 Ok(s) => Ok(serde_json::from_str(&s)?),
581 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
582 Err(e) => Err(e.into()),
583 }
584 }
585
586 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
588 if let Some(parent) = path.parent() {
589 std::fs::create_dir_all(parent)?;
590 }
591 let tmp = path.with_extension("json.tmp");
592 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
593 std::fs::rename(&tmp, path)?;
594 Ok(())
595 }
596}
597
598pub fn next_state(current: CertState) -> Option<CertState> {
601 match current {
602 CertState::Requested => Some(CertState::PolicyValidated),
603 CertState::PolicyValidated => Some(CertState::DnsChallengePrepared),
604 CertState::DnsChallengePrepared => Some(CertState::DnsChallengePropagating),
605 CertState::DnsChallengePropagating => Some(CertState::Issuing),
606 CertState::Issuing => Some(CertState::Issued),
607 CertState::RenewalDue => Some(CertState::Renewing),
608 CertState::Renewing => Some(CertState::Issued),
609 CertState::Issued | CertState::Failed | CertState::Revoked => None,
611 }
612}
613
614pub fn advance_record(record: &mut CertRecord, config: &BackoffConfig) -> Option<StateTransition> {
622 match reconcile_step(record.state) {
623 ReconcileDecision::Done => None,
624 ReconcileDecision::Wait => None,
625 ReconcileDecision::Proceed => {
626 let to = next_state(record.state)?;
627 let from = record.state;
628 record.state = to;
629 record.attempt = 0;
630 record.last_updated = Utc::now();
631 Some(StateTransition {
632 from,
633 to,
634 reason: "reconcile: proceed".to_string(),
635 at: record.last_updated,
636 })
637 }
638 ReconcileDecision::RetryOrAbandon => {
639 let bs = BackoffState {
640 attempt: record.attempt,
641 };
642 if next_backoff_delay(config, &bs).is_some() {
643 record.attempt += 1;
644 record.last_updated = Utc::now();
645 None
647 } else {
648 let from = record.state;
650 record.state = CertState::Failed;
651 record.last_updated = Utc::now();
652 Some(StateTransition {
653 from,
654 to: CertState::Failed,
655 reason: "reconcile: retries exhausted".to_string(),
656 at: record.last_updated,
657 })
658 }
659 }
660 }
661}
662
663fn state_name(state: CertState) -> &'static str {
664 match state {
665 CertState::Requested => "requested",
666 CertState::PolicyValidated => "policy_validated",
667 CertState::DnsChallengePrepared => "dns_challenge_prepared",
668 CertState::DnsChallengePropagating => "dns_challenge_propagating",
669 CertState::Issuing => "issuing",
670 CertState::Issued => "issued",
671 CertState::RenewalDue => "renewal_due",
672 CertState::Renewing => "renewing",
673 CertState::Failed => "failed",
674 CertState::Revoked => "revoked",
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn dry_run_transitions_to_issued() {
684 let req = IssuanceRequest {
685 request_id: "req-1".to_string(),
686 hostname: "app.example.com".to_string(),
687 actor: "test".to_string(),
688 requested_at: Utc::now(),
689 };
690
691 let report = dry_run_issue(&req).expect("dry run should pass");
692 let last = report.transitions.last().expect("has transitions");
693 assert_eq!(last.to, CertState::Issued);
694 }
695
696 #[test]
697 fn invalid_hostname_fails_validation() {
698 let req = IssuanceRequest {
699 request_id: "req-2".to_string(),
700 hostname: "localhost".to_string(),
701 actor: "test".to_string(),
702 requested_at: Utc::now(),
703 };
704
705 let result = validate_request(&req);
706 assert!(result.is_err());
707 }
708
709 #[test]
710 fn hostname_with_invalid_characters_fails_validation() {
711 let req = IssuanceRequest {
712 request_id: "req-3".to_string(),
713 hostname: "bad host.example.com".to_string(),
714 actor: "test".to_string(),
715 requested_at: Utc::now(),
716 };
717
718 let result = validate_request(&req);
719 assert!(matches!(
720 result,
721 Err(RequestValidationError::InvalidHostnameFormat)
722 ));
723 }
724
725 #[test]
726 fn hostname_with_leading_dash_label_fails_validation() {
727 let req = IssuanceRequest {
728 request_id: "req-4".to_string(),
729 hostname: "-bad.example.com".to_string(),
730 actor: "test".to_string(),
731 requested_at: Utc::now(),
732 };
733
734 let result = validate_request(&req);
735 assert!(matches!(
736 result,
737 Err(RequestValidationError::InvalidHostnameFormat)
738 ));
739 }
740
741 #[test]
742 fn hostname_with_valid_dns_labels_passes_validation() {
743 let req = IssuanceRequest {
744 request_id: "req-5".to_string(),
745 hostname: "api-1.example.com".to_string(),
746 actor: "test".to_string(),
747 requested_at: Utc::now(),
748 };
749
750 assert!(validate_request(&req).is_ok());
751 }
752
753 #[test]
754 fn static_config_contains_cloudflare_acme_resolver() {
755 let cfg = TraefikStaticConfigInput {
756 email: "ops@acme.test".to_string(),
757 resolver_name: "cloudflare".to_string(),
758 cert_storage_file: "/var/traefik/certs/acme.json".to_string(),
759 cloudflare_token_env: "CF_DNS_API_TOKEN".to_string(),
760 log_level: "DEBUG".to_string(),
761 };
762 let rendered = render_traefik_static_yaml(&cfg);
763 assert!(rendered.contains("certificatesResolvers:"));
764 assert!(rendered.contains("provider: cloudflare"));
765 assert!(rendered.contains("CF_DNS_API_TOKEN"));
766 }
767
768 #[test]
769 fn generated_labels_include_https_certresolver() {
770 let cfg = TraefikRouterConfig {
771 service_name: "issuer-api".to_string(),
772 hostname: "issuer.example.com".to_string(),
773 service_port: 8080,
774 resolver_name: "cloudflare".to_string(),
775 };
776 let labels = generate_router_labels(&cfg);
777 assert!(labels
778 .iter()
779 .any(|x| x == "traefik.http.routers.issuer-api-https.tls.certresolver=cloudflare"));
780 assert!(labels
781 .iter()
782 .any(|x| { x == "traefik.http.services.issuer-api.loadbalancer.server.port=8080" }));
783 }
784
785 #[test]
788 fn reconcile_requested_returns_proceed() {
789 assert_eq!(
790 reconcile_step(CertState::Requested),
791 ReconcileDecision::Proceed
792 );
793 }
794
795 #[test]
796 fn reconcile_policy_validated_returns_proceed() {
797 assert_eq!(
798 reconcile_step(CertState::PolicyValidated),
799 ReconcileDecision::Proceed
800 );
801 }
802
803 #[test]
804 fn reconcile_dns_challenge_prepared_returns_proceed() {
805 assert_eq!(
806 reconcile_step(CertState::DnsChallengePrepared),
807 ReconcileDecision::Proceed
808 );
809 }
810
811 #[test]
812 fn reconcile_dns_challenge_propagating_returns_wait() {
813 assert_eq!(
814 reconcile_step(CertState::DnsChallengePropagating),
815 ReconcileDecision::Wait
816 );
817 }
818
819 #[test]
820 fn reconcile_issuing_returns_proceed() {
821 assert_eq!(
822 reconcile_step(CertState::Issuing),
823 ReconcileDecision::Proceed
824 );
825 }
826
827 #[test]
828 fn reconcile_issued_returns_done() {
829 assert_eq!(reconcile_step(CertState::Issued), ReconcileDecision::Done);
830 }
831
832 #[test]
833 fn reconcile_renewal_due_returns_proceed() {
834 assert_eq!(
835 reconcile_step(CertState::RenewalDue),
836 ReconcileDecision::Proceed
837 );
838 }
839
840 #[test]
841 fn reconcile_failed_returns_retry_or_abandon() {
842 assert_eq!(
843 reconcile_step(CertState::Failed),
844 ReconcileDecision::RetryOrAbandon
845 );
846 }
847
848 #[test]
849 fn reconcile_revoked_returns_done() {
850 assert_eq!(reconcile_step(CertState::Revoked), ReconcileDecision::Done);
851 }
852
853 #[test]
856 fn backoff_attempt_0_returns_base_delay() {
857 let cfg = BackoffConfig {
858 max_retries: 3,
859 base_delay_ms: 1_000,
860 max_delay_ms: 60_000,
861 };
862 let state = BackoffState { attempt: 0 };
863 assert_eq!(
864 next_backoff_delay(&cfg, &state),
865 Some(Duration::from_millis(1_000))
866 );
867 }
868
869 #[test]
870 fn backoff_attempt_1_doubles_delay() {
871 let cfg = BackoffConfig {
872 max_retries: 3,
873 base_delay_ms: 1_000,
874 max_delay_ms: 60_000,
875 };
876 let state = BackoffState { attempt: 1 };
877 assert_eq!(
878 next_backoff_delay(&cfg, &state),
879 Some(Duration::from_millis(2_000))
880 );
881 }
882
883 #[test]
884 fn backoff_caps_at_max_delay() {
885 let cfg = BackoffConfig {
886 max_retries: 10,
887 base_delay_ms: 1_000,
888 max_delay_ms: 5_000,
889 };
890 let state = BackoffState { attempt: 5 };
891 assert_eq!(
892 next_backoff_delay(&cfg, &state),
893 Some(Duration::from_millis(5_000))
894 );
895 }
896
897 #[test]
898 fn backoff_returns_none_after_max_retries() {
899 let cfg = BackoffConfig {
900 max_retries: 3,
901 base_delay_ms: 1_000,
902 max_delay_ms: 60_000,
903 };
904 let state = BackoffState { attempt: 3 };
905 assert_eq!(next_backoff_delay(&cfg, &state), None);
906 }
907
908 #[test]
911 fn metrics_record_start_increments_in_flight() {
912 let mut m = CertMetrics::default();
913 m.record_start();
914 assert_eq!(m.in_flight, 1);
915 }
916
917 #[test]
918 fn metrics_record_success_increments_issued_and_decrements_in_flight() {
919 let mut m = CertMetrics::default();
920 m.record_start();
921 m.record_success();
922 assert_eq!(m.issued_total, 1);
923 assert_eq!(m.in_flight, 0);
924 }
925
926 #[test]
927 fn metrics_record_failure_increments_failed_and_decrements_in_flight() {
928 let mut m = CertMetrics::default();
929 m.record_start();
930 m.record_failure();
931 assert_eq!(m.failed_total, 1);
932 assert_eq!(m.in_flight, 0);
933 }
934
935 #[test]
936 fn metrics_in_flight_does_not_underflow() {
937 let mut m = CertMetrics::default();
938 m.record_success(); assert_eq!(m.in_flight, 0);
940 }
941
942 #[test]
943 fn metrics_snapshot_serializes_to_json() {
944 let mut m = CertMetrics::default();
945 m.record_start();
946 m.record_success();
947 let json = serde_json::to_string(&m).expect("serialization must succeed");
948 assert!(json.contains("issued_total"));
949 assert!(json.contains("in_flight"));
950 }
951
952 #[test]
953 fn state_store_save_creates_missing_parent_directories() {
954 let unique = format!("tencrypt-test-{}", Uuid::new_v4());
955 let path = std::env::temp_dir()
956 .join(unique)
957 .join("nested")
958 .join("certs.json");
959
960 let store = StateStore {
961 certs: vec![CertRecord::new("app.example.com".to_string())],
962 };
963
964 store
965 .save(&path)
966 .expect("save should create parent directories");
967 let reloaded = StateStore::load(&path).expect("load should succeed");
968 assert_eq!(reloaded.certs.len(), 1);
969
970 std::fs::remove_dir_all(
971 path.parent()
972 .and_then(|p| p.parent())
973 .expect("test directory layout should exist"),
974 )
975 .expect("temporary directory cleanup should succeed");
976 }
977
978 #[test]
981 fn jittered_backoff_zero_jitter_matches_plain() {
982 let cfg = BackoffConfig {
983 max_retries: 5,
984 base_delay_ms: 1_000,
985 max_delay_ms: 60_000,
986 };
987 let state = BackoffState { attempt: 0 };
988 assert_eq!(
989 next_backoff_delay_jittered(&cfg, &state, 0),
990 next_backoff_delay(&cfg, &state)
991 );
992 }
993
994 #[test]
995 fn jittered_backoff_adds_jitter_ms() {
996 let cfg = BackoffConfig {
997 max_retries: 5,
998 base_delay_ms: 1_000,
999 max_delay_ms: 60_000,
1000 };
1001 let state = BackoffState { attempt: 0 };
1002 assert_eq!(
1003 next_backoff_delay_jittered(&cfg, &state, 250),
1004 Some(Duration::from_millis(1_250))
1005 );
1006 }
1007
1008 #[test]
1009 fn jittered_backoff_caps_at_max_delay() {
1010 let cfg = BackoffConfig {
1011 max_retries: 5,
1012 base_delay_ms: 1_000,
1013 max_delay_ms: 3_000,
1014 };
1015 let state = BackoffState { attempt: 2 }; assert_eq!(
1017 next_backoff_delay_jittered(&cfg, &state, 500),
1018 Some(Duration::from_millis(3_000))
1019 );
1020 }
1021
1022 #[test]
1023 fn jittered_backoff_returns_none_when_exhausted() {
1024 let cfg = BackoffConfig::default();
1025 let state = BackoffState {
1026 attempt: cfg.max_retries,
1027 };
1028 assert_eq!(next_backoff_delay_jittered(&cfg, &state, 100), None);
1029 }
1030
1031 #[test]
1034 fn resolve_backoff_returns_default_when_no_policies() {
1035 let default = BackoffConfig::default();
1036 let result = resolve_backoff("foo.example.com", &[], &default);
1037 assert_eq!(result.max_retries, default.max_retries);
1038 }
1039
1040 #[test]
1041 fn resolve_backoff_returns_override_for_matching_hostname() {
1042 let default = BackoffConfig::default();
1043 let override_cfg = BackoffConfig {
1044 max_retries: 2,
1045 base_delay_ms: 500,
1046 max_delay_ms: 5_000,
1047 };
1048 let policies = vec![DomainPolicy {
1049 hostname_pattern: "strict.example.com".to_string(),
1050 backoff: Some(override_cfg.clone()),
1051 }];
1052 let result = resolve_backoff("strict.example.com", &policies, &default);
1053 assert_eq!(result.max_retries, 2);
1054 assert_eq!(result.base_delay_ms, 500);
1055 }
1056
1057 #[test]
1058 fn resolve_backoff_skips_none_override() {
1059 let default = BackoffConfig::default();
1060 let policies = vec![DomainPolicy {
1061 hostname_pattern: "opt-out.example.com".to_string(),
1062 backoff: None,
1063 }];
1064 let result = resolve_backoff("opt-out.example.com", &policies, &default);
1065 assert_eq!(result.max_retries, default.max_retries);
1066 }
1067
1068 #[test]
1071 fn state_store_load_returns_empty_when_file_missing() {
1072 let store = StateStore::load(Path::new("/tmp/tencrypt-nonexistent-state.json"))
1073 .expect("missing file should return empty store");
1074 assert!(store.certs.is_empty());
1075 }
1076
1077 #[test]
1078 fn state_store_roundtrip() {
1079 let dir = std::env::temp_dir();
1080 let path = dir.join("tencrypt-test-state.json");
1081 let mut store = StateStore::default();
1082 store
1083 .certs
1084 .push(CertRecord::new("rt.example.com".to_string()));
1085 store.save(&path).expect("save should succeed");
1086 let loaded = StateStore::load(&path).expect("load should succeed");
1087 assert_eq!(loaded.certs.len(), 1);
1088 assert_eq!(loaded.certs[0].hostname, "rt.example.com");
1089 drop(loaded);
1090 std::fs::remove_file(&path).ok();
1091 }
1092
1093 #[test]
1094 fn advance_record_proceeds_from_requested_to_policy_validated() {
1095 let mut record = CertRecord::new("adv.example.com".to_string());
1096 let cfg = BackoffConfig::default();
1097 let t = advance_record(&mut record, &cfg).expect("should produce a transition");
1098 assert_eq!(t.from, CertState::Requested);
1099 assert_eq!(t.to, CertState::PolicyValidated);
1100 assert_eq!(record.state, CertState::PolicyValidated);
1101 assert_eq!(record.attempt, 0);
1102 }
1103
1104 #[test]
1105 fn advance_record_returns_none_for_issued() {
1106 let mut record = CertRecord {
1107 hostname: "done.example.com".to_string(),
1108 state: CertState::Issued,
1109 attempt: 0,
1110 last_updated: Utc::now(),
1111 };
1112 let cfg = BackoffConfig::default();
1113 assert!(advance_record(&mut record, &cfg).is_none());
1114 assert_eq!(record.state, CertState::Issued);
1115 }
1116
1117 #[test]
1118 fn advance_record_returns_none_for_wait() {
1119 let mut record = CertRecord {
1120 hostname: "wait.example.com".to_string(),
1121 state: CertState::DnsChallengePropagating,
1122 attempt: 0,
1123 last_updated: Utc::now(),
1124 };
1125 let cfg = BackoffConfig::default();
1126 assert!(advance_record(&mut record, &cfg).is_none());
1127 assert_eq!(record.state, CertState::DnsChallengePropagating);
1128 }
1129
1130 #[test]
1131 fn advance_record_increments_attempt_on_retry_or_abandon() {
1132 let mut record = CertRecord {
1133 hostname: "fail.example.com".to_string(),
1134 state: CertState::Failed,
1135 attempt: 0,
1136 last_updated: Utc::now(),
1137 };
1138 let cfg = BackoffConfig {
1139 max_retries: 3,
1140 base_delay_ms: 100,
1141 max_delay_ms: 1_000,
1142 };
1143 let t = advance_record(&mut record, &cfg);
1145 assert!(t.is_none());
1146 assert_eq!(record.attempt, 1);
1147 assert_eq!(record.state, CertState::Failed);
1148 }
1149
1150 #[test]
1151 fn advance_record_transitions_to_failed_when_retries_exhausted() {
1152 let mut record = CertRecord {
1153 hostname: "exhaust.example.com".to_string(),
1154 state: CertState::Failed,
1155 attempt: 3,
1156 last_updated: Utc::now(),
1157 };
1158 let cfg = BackoffConfig {
1159 max_retries: 3,
1160 base_delay_ms: 100,
1161 max_delay_ms: 1_000,
1162 };
1163 let t = advance_record(&mut record, &cfg).expect("should transition to Failed");
1164 assert_eq!(t.from, CertState::Failed);
1165 assert_eq!(t.to, CertState::Failed);
1166 }
1167}