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, Clone, Serialize, Deserialize)]
494pub struct CertRecord {
495 pub hostname: String,
496 pub state: CertState,
497 pub attempt: u32,
499 pub last_updated: DateTime<Utc>,
500}
501
502impl CertRecord {
503 pub fn new(hostname: String) -> Self {
504 Self {
505 hostname,
506 state: CertState::Requested,
507 attempt: 0,
508 last_updated: Utc::now(),
509 }
510 }
511}
512
513#[derive(Debug, Default, Serialize, Deserialize)]
516pub struct StateStore {
517 pub certs: Vec<CertRecord>,
518}
519
520impl StateStore {
521 pub fn load(path: &Path) -> anyhow::Result<Self> {
523 match std::fs::read_to_string(path) {
524 Ok(s) => Ok(serde_json::from_str(&s)?),
525 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
526 Err(e) => Err(e.into()),
527 }
528 }
529
530 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
532 if let Some(parent) = path.parent() {
533 std::fs::create_dir_all(parent)?;
534 }
535 let tmp = path.with_extension("json.tmp");
536 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
537 std::fs::rename(&tmp, path)?;
538 Ok(())
539 }
540}
541
542pub fn next_state(current: CertState) -> Option<CertState> {
545 match current {
546 CertState::Requested => Some(CertState::PolicyValidated),
547 CertState::PolicyValidated => Some(CertState::DnsChallengePrepared),
548 CertState::DnsChallengePrepared => Some(CertState::DnsChallengePropagating),
549 CertState::DnsChallengePropagating => Some(CertState::Issuing),
550 CertState::Issuing => Some(CertState::Issued),
551 CertState::RenewalDue => Some(CertState::Renewing),
552 CertState::Renewing => Some(CertState::Issued),
553 CertState::Issued | CertState::Failed | CertState::Revoked => None,
555 }
556}
557
558pub fn advance_record(record: &mut CertRecord, config: &BackoffConfig) -> Option<StateTransition> {
566 match reconcile_step(record.state) {
567 ReconcileDecision::Done => None,
568 ReconcileDecision::Wait => None,
569 ReconcileDecision::Proceed => {
570 let to = next_state(record.state)?;
571 let from = record.state;
572 record.state = to;
573 record.attempt = 0;
574 record.last_updated = Utc::now();
575 Some(StateTransition {
576 from,
577 to,
578 reason: "reconcile: proceed".to_string(),
579 at: record.last_updated,
580 })
581 }
582 ReconcileDecision::RetryOrAbandon => {
583 let bs = BackoffState {
584 attempt: record.attempt,
585 };
586 if next_backoff_delay(config, &bs).is_some() {
587 record.attempt += 1;
588 record.last_updated = Utc::now();
589 None
591 } else {
592 let from = record.state;
594 record.state = CertState::Failed;
595 record.last_updated = Utc::now();
596 Some(StateTransition {
597 from,
598 to: CertState::Failed,
599 reason: "reconcile: retries exhausted".to_string(),
600 at: record.last_updated,
601 })
602 }
603 }
604 }
605}
606
607fn state_name(state: CertState) -> &'static str {
608 match state {
609 CertState::Requested => "requested",
610 CertState::PolicyValidated => "policy_validated",
611 CertState::DnsChallengePrepared => "dns_challenge_prepared",
612 CertState::DnsChallengePropagating => "dns_challenge_propagating",
613 CertState::Issuing => "issuing",
614 CertState::Issued => "issued",
615 CertState::RenewalDue => "renewal_due",
616 CertState::Renewing => "renewing",
617 CertState::Failed => "failed",
618 CertState::Revoked => "revoked",
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn dry_run_transitions_to_issued() {
628 let req = IssuanceRequest {
629 request_id: "req-1".to_string(),
630 hostname: "app.example.com".to_string(),
631 actor: "test".to_string(),
632 requested_at: Utc::now(),
633 };
634
635 let report = dry_run_issue(&req).expect("dry run should pass");
636 let last = report.transitions.last().expect("has transitions");
637 assert_eq!(last.to, CertState::Issued);
638 }
639
640 #[test]
641 fn invalid_hostname_fails_validation() {
642 let req = IssuanceRequest {
643 request_id: "req-2".to_string(),
644 hostname: "localhost".to_string(),
645 actor: "test".to_string(),
646 requested_at: Utc::now(),
647 };
648
649 let result = validate_request(&req);
650 assert!(result.is_err());
651 }
652
653 #[test]
654 fn hostname_with_invalid_characters_fails_validation() {
655 let req = IssuanceRequest {
656 request_id: "req-3".to_string(),
657 hostname: "bad host.example.com".to_string(),
658 actor: "test".to_string(),
659 requested_at: Utc::now(),
660 };
661
662 let result = validate_request(&req);
663 assert!(matches!(
664 result,
665 Err(RequestValidationError::InvalidHostnameFormat)
666 ));
667 }
668
669 #[test]
670 fn hostname_with_leading_dash_label_fails_validation() {
671 let req = IssuanceRequest {
672 request_id: "req-4".to_string(),
673 hostname: "-bad.example.com".to_string(),
674 actor: "test".to_string(),
675 requested_at: Utc::now(),
676 };
677
678 let result = validate_request(&req);
679 assert!(matches!(
680 result,
681 Err(RequestValidationError::InvalidHostnameFormat)
682 ));
683 }
684
685 #[test]
686 fn hostname_with_valid_dns_labels_passes_validation() {
687 let req = IssuanceRequest {
688 request_id: "req-5".to_string(),
689 hostname: "api-1.example.com".to_string(),
690 actor: "test".to_string(),
691 requested_at: Utc::now(),
692 };
693
694 assert!(validate_request(&req).is_ok());
695 }
696
697 #[test]
698 fn static_config_contains_cloudflare_acme_resolver() {
699 let cfg = TraefikStaticConfigInput {
700 email: "ops@acme.test".to_string(),
701 resolver_name: "cloudflare".to_string(),
702 cert_storage_file: "/var/traefik/certs/acme.json".to_string(),
703 cloudflare_token_env: "CF_DNS_API_TOKEN".to_string(),
704 log_level: "DEBUG".to_string(),
705 };
706 let rendered = render_traefik_static_yaml(&cfg);
707 assert!(rendered.contains("certificatesResolvers:"));
708 assert!(rendered.contains("provider: cloudflare"));
709 assert!(rendered.contains("CF_DNS_API_TOKEN"));
710 }
711
712 #[test]
713 fn generated_labels_include_https_certresolver() {
714 let cfg = TraefikRouterConfig {
715 service_name: "issuer-api".to_string(),
716 hostname: "issuer.example.com".to_string(),
717 service_port: 8080,
718 resolver_name: "cloudflare".to_string(),
719 };
720 let labels = generate_router_labels(&cfg);
721 assert!(labels
722 .iter()
723 .any(|x| x == "traefik.http.routers.issuer-api-https.tls.certresolver=cloudflare"));
724 assert!(labels
725 .iter()
726 .any(|x| { x == "traefik.http.services.issuer-api.loadbalancer.server.port=8080" }));
727 }
728
729 #[test]
732 fn reconcile_requested_returns_proceed() {
733 assert_eq!(
734 reconcile_step(CertState::Requested),
735 ReconcileDecision::Proceed
736 );
737 }
738
739 #[test]
740 fn reconcile_policy_validated_returns_proceed() {
741 assert_eq!(
742 reconcile_step(CertState::PolicyValidated),
743 ReconcileDecision::Proceed
744 );
745 }
746
747 #[test]
748 fn reconcile_dns_challenge_prepared_returns_proceed() {
749 assert_eq!(
750 reconcile_step(CertState::DnsChallengePrepared),
751 ReconcileDecision::Proceed
752 );
753 }
754
755 #[test]
756 fn reconcile_dns_challenge_propagating_returns_wait() {
757 assert_eq!(
758 reconcile_step(CertState::DnsChallengePropagating),
759 ReconcileDecision::Wait
760 );
761 }
762
763 #[test]
764 fn reconcile_issuing_returns_proceed() {
765 assert_eq!(
766 reconcile_step(CertState::Issuing),
767 ReconcileDecision::Proceed
768 );
769 }
770
771 #[test]
772 fn reconcile_issued_returns_done() {
773 assert_eq!(reconcile_step(CertState::Issued), ReconcileDecision::Done);
774 }
775
776 #[test]
777 fn reconcile_renewal_due_returns_proceed() {
778 assert_eq!(
779 reconcile_step(CertState::RenewalDue),
780 ReconcileDecision::Proceed
781 );
782 }
783
784 #[test]
785 fn reconcile_failed_returns_retry_or_abandon() {
786 assert_eq!(
787 reconcile_step(CertState::Failed),
788 ReconcileDecision::RetryOrAbandon
789 );
790 }
791
792 #[test]
793 fn reconcile_revoked_returns_done() {
794 assert_eq!(reconcile_step(CertState::Revoked), ReconcileDecision::Done);
795 }
796
797 #[test]
800 fn backoff_attempt_0_returns_base_delay() {
801 let cfg = BackoffConfig {
802 max_retries: 3,
803 base_delay_ms: 1_000,
804 max_delay_ms: 60_000,
805 };
806 let state = BackoffState { attempt: 0 };
807 assert_eq!(
808 next_backoff_delay(&cfg, &state),
809 Some(Duration::from_millis(1_000))
810 );
811 }
812
813 #[test]
814 fn backoff_attempt_1_doubles_delay() {
815 let cfg = BackoffConfig {
816 max_retries: 3,
817 base_delay_ms: 1_000,
818 max_delay_ms: 60_000,
819 };
820 let state = BackoffState { attempt: 1 };
821 assert_eq!(
822 next_backoff_delay(&cfg, &state),
823 Some(Duration::from_millis(2_000))
824 );
825 }
826
827 #[test]
828 fn backoff_caps_at_max_delay() {
829 let cfg = BackoffConfig {
830 max_retries: 10,
831 base_delay_ms: 1_000,
832 max_delay_ms: 5_000,
833 };
834 let state = BackoffState { attempt: 5 };
835 assert_eq!(
836 next_backoff_delay(&cfg, &state),
837 Some(Duration::from_millis(5_000))
838 );
839 }
840
841 #[test]
842 fn backoff_returns_none_after_max_retries() {
843 let cfg = BackoffConfig {
844 max_retries: 3,
845 base_delay_ms: 1_000,
846 max_delay_ms: 60_000,
847 };
848 let state = BackoffState { attempt: 3 };
849 assert_eq!(next_backoff_delay(&cfg, &state), None);
850 }
851
852 #[test]
855 fn metrics_record_start_increments_in_flight() {
856 let mut m = CertMetrics::default();
857 m.record_start();
858 assert_eq!(m.in_flight, 1);
859 }
860
861 #[test]
862 fn metrics_record_success_increments_issued_and_decrements_in_flight() {
863 let mut m = CertMetrics::default();
864 m.record_start();
865 m.record_success();
866 assert_eq!(m.issued_total, 1);
867 assert_eq!(m.in_flight, 0);
868 }
869
870 #[test]
871 fn metrics_record_failure_increments_failed_and_decrements_in_flight() {
872 let mut m = CertMetrics::default();
873 m.record_start();
874 m.record_failure();
875 assert_eq!(m.failed_total, 1);
876 assert_eq!(m.in_flight, 0);
877 }
878
879 #[test]
880 fn metrics_in_flight_does_not_underflow() {
881 let mut m = CertMetrics::default();
882 m.record_success(); assert_eq!(m.in_flight, 0);
884 }
885
886 #[test]
887 fn metrics_snapshot_serializes_to_json() {
888 let mut m = CertMetrics::default();
889 m.record_start();
890 m.record_success();
891 let json = serde_json::to_string(&m).expect("serialization must succeed");
892 assert!(json.contains("issued_total"));
893 assert!(json.contains("in_flight"));
894 }
895
896 #[test]
897 fn state_store_save_creates_missing_parent_directories() {
898 let unique = format!("tencrypt-test-{}", Uuid::new_v4());
899 let path = std::env::temp_dir()
900 .join(unique)
901 .join("nested")
902 .join("certs.json");
903
904 let store = StateStore {
905 certs: vec![CertRecord::new("app.example.com".to_string())],
906 };
907
908 store
909 .save(&path)
910 .expect("save should create parent directories");
911 let reloaded = StateStore::load(&path).expect("load should succeed");
912 assert_eq!(reloaded.certs.len(), 1);
913
914 std::fs::remove_dir_all(
915 path.parent()
916 .and_then(|p| p.parent())
917 .expect("test directory layout should exist"),
918 )
919 .expect("temporary directory cleanup should succeed");
920 }
921
922 #[test]
925 fn jittered_backoff_zero_jitter_matches_plain() {
926 let cfg = BackoffConfig {
927 max_retries: 5,
928 base_delay_ms: 1_000,
929 max_delay_ms: 60_000,
930 };
931 let state = BackoffState { attempt: 0 };
932 assert_eq!(
933 next_backoff_delay_jittered(&cfg, &state, 0),
934 next_backoff_delay(&cfg, &state)
935 );
936 }
937
938 #[test]
939 fn jittered_backoff_adds_jitter_ms() {
940 let cfg = BackoffConfig {
941 max_retries: 5,
942 base_delay_ms: 1_000,
943 max_delay_ms: 60_000,
944 };
945 let state = BackoffState { attempt: 0 };
946 assert_eq!(
947 next_backoff_delay_jittered(&cfg, &state, 250),
948 Some(Duration::from_millis(1_250))
949 );
950 }
951
952 #[test]
953 fn jittered_backoff_caps_at_max_delay() {
954 let cfg = BackoffConfig {
955 max_retries: 5,
956 base_delay_ms: 1_000,
957 max_delay_ms: 3_000,
958 };
959 let state = BackoffState { attempt: 2 }; assert_eq!(
961 next_backoff_delay_jittered(&cfg, &state, 500),
962 Some(Duration::from_millis(3_000))
963 );
964 }
965
966 #[test]
967 fn jittered_backoff_returns_none_when_exhausted() {
968 let cfg = BackoffConfig::default();
969 let state = BackoffState {
970 attempt: cfg.max_retries,
971 };
972 assert_eq!(next_backoff_delay_jittered(&cfg, &state, 100), None);
973 }
974
975 #[test]
978 fn resolve_backoff_returns_default_when_no_policies() {
979 let default = BackoffConfig::default();
980 let result = resolve_backoff("foo.example.com", &[], &default);
981 assert_eq!(result.max_retries, default.max_retries);
982 }
983
984 #[test]
985 fn resolve_backoff_returns_override_for_matching_hostname() {
986 let default = BackoffConfig::default();
987 let override_cfg = BackoffConfig {
988 max_retries: 2,
989 base_delay_ms: 500,
990 max_delay_ms: 5_000,
991 };
992 let policies = vec![DomainPolicy {
993 hostname_pattern: "strict.example.com".to_string(),
994 backoff: Some(override_cfg.clone()),
995 }];
996 let result = resolve_backoff("strict.example.com", &policies, &default);
997 assert_eq!(result.max_retries, 2);
998 assert_eq!(result.base_delay_ms, 500);
999 }
1000
1001 #[test]
1002 fn resolve_backoff_skips_none_override() {
1003 let default = BackoffConfig::default();
1004 let policies = vec![DomainPolicy {
1005 hostname_pattern: "opt-out.example.com".to_string(),
1006 backoff: None,
1007 }];
1008 let result = resolve_backoff("opt-out.example.com", &policies, &default);
1009 assert_eq!(result.max_retries, default.max_retries);
1010 }
1011
1012 #[test]
1015 fn state_store_load_returns_empty_when_file_missing() {
1016 let store = StateStore::load(Path::new("/tmp/tencrypt-nonexistent-state.json"))
1017 .expect("missing file should return empty store");
1018 assert!(store.certs.is_empty());
1019 }
1020
1021 #[test]
1022 fn state_store_roundtrip() {
1023 let dir = std::env::temp_dir();
1024 let path = dir.join("tencrypt-test-state.json");
1025 let mut store = StateStore::default();
1026 store
1027 .certs
1028 .push(CertRecord::new("rt.example.com".to_string()));
1029 store.save(&path).expect("save should succeed");
1030 let loaded = StateStore::load(&path).expect("load should succeed");
1031 assert_eq!(loaded.certs.len(), 1);
1032 assert_eq!(loaded.certs[0].hostname, "rt.example.com");
1033 drop(loaded);
1034 std::fs::remove_file(&path).ok();
1035 }
1036
1037 #[test]
1038 fn advance_record_proceeds_from_requested_to_policy_validated() {
1039 let mut record = CertRecord::new("adv.example.com".to_string());
1040 let cfg = BackoffConfig::default();
1041 let t = advance_record(&mut record, &cfg).expect("should produce a transition");
1042 assert_eq!(t.from, CertState::Requested);
1043 assert_eq!(t.to, CertState::PolicyValidated);
1044 assert_eq!(record.state, CertState::PolicyValidated);
1045 assert_eq!(record.attempt, 0);
1046 }
1047
1048 #[test]
1049 fn advance_record_returns_none_for_issued() {
1050 let mut record = CertRecord {
1051 hostname: "done.example.com".to_string(),
1052 state: CertState::Issued,
1053 attempt: 0,
1054 last_updated: Utc::now(),
1055 };
1056 let cfg = BackoffConfig::default();
1057 assert!(advance_record(&mut record, &cfg).is_none());
1058 assert_eq!(record.state, CertState::Issued);
1059 }
1060
1061 #[test]
1062 fn advance_record_returns_none_for_wait() {
1063 let mut record = CertRecord {
1064 hostname: "wait.example.com".to_string(),
1065 state: CertState::DnsChallengePropagating,
1066 attempt: 0,
1067 last_updated: Utc::now(),
1068 };
1069 let cfg = BackoffConfig::default();
1070 assert!(advance_record(&mut record, &cfg).is_none());
1071 assert_eq!(record.state, CertState::DnsChallengePropagating);
1072 }
1073
1074 #[test]
1075 fn advance_record_increments_attempt_on_retry_or_abandon() {
1076 let mut record = CertRecord {
1077 hostname: "fail.example.com".to_string(),
1078 state: CertState::Failed,
1079 attempt: 0,
1080 last_updated: Utc::now(),
1081 };
1082 let cfg = BackoffConfig {
1083 max_retries: 3,
1084 base_delay_ms: 100,
1085 max_delay_ms: 1_000,
1086 };
1087 let t = advance_record(&mut record, &cfg);
1089 assert!(t.is_none());
1090 assert_eq!(record.attempt, 1);
1091 assert_eq!(record.state, CertState::Failed);
1092 }
1093
1094 #[test]
1095 fn advance_record_transitions_to_failed_when_retries_exhausted() {
1096 let mut record = CertRecord {
1097 hostname: "exhaust.example.com".to_string(),
1098 state: CertState::Failed,
1099 attempt: 3,
1100 last_updated: Utc::now(),
1101 };
1102 let cfg = BackoffConfig {
1103 max_retries: 3,
1104 base_delay_ms: 100,
1105 max_delay_ms: 1_000,
1106 };
1107 let t = advance_record(&mut record, &cfg).expect("should transition to Failed");
1108 assert_eq!(t.from, CertState::Failed);
1109 assert_eq!(t.to, CertState::Failed);
1110 }
1111}