diff --git a/README.md b/README.md index f365119..2154d82 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,11 @@ bucket and ships a standalone Rust/Axum responder on port `2560`. CA. - Each CA snapshot is cached for one second with a single-flight refresh, so concurrent requests cause at most one S3 `GetObject` per CA per second. -- If S3 is unavailable, a stale snapshot is served within the configured grace - period; otherwise the responder returns `tryLater`. +- The publisher refreshes unchanged snapshots every 30 seconds by default. The + responder rejects snapshots older than its configured grace period even when + S3 is readable, and returns `tryLater` when no fresh snapshot is available. +- OCSP `thisUpdate` reflects the snapshot generation time and `nextUpdate` never + extends beyond the snapshot's hard expiry. - Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same `release/*` tags as the server image. The responder image is a statically linked musl executable running `FROM scratch` with only the CA bundle. diff --git a/docker-compose.yml b/docker-compose.yml index 3256327..bac423b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,6 +113,7 @@ services: KCA_OCSP_S3_ENDPOINT: ${KCA_OCSP_S3_ENDPOINT:-} KCA_OCSP_S3_FORCE_PATH_STYLE: ${KCA_OCSP_S3_FORCE_PATH_STYLE:-false} KCA_OCSP_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1} + KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-} diff --git a/ocsp-responder/README.md b/ocsp-responder/README.md index 7859c48..a2ccdde 100644 --- a/ocsp-responder/README.md +++ b/ocsp-responder/README.md @@ -27,8 +27,9 @@ vaults. The responder validates both this ID and the CA certificate fingerprint against every downloaded snapshot. KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON -contains all certificates issued by that CA, both valid and revoked. The -responder never infers status from a partial revoked-only list. +contains all certificates issued by that CA, both valid and revoked, and a +required `generated_at` timestamp. The responder never infers status from a +partial revoked-only list. ## Upgrading from fingerprint-named snapshots @@ -97,7 +98,19 @@ build a trust store containing the CA or validate with your normal PKI tooling. - Per-CA cache TTL: `s3.cache_ttl_ms` (default 1000 ms). - Concurrent requests for the same CA share one S3 `GetObject`. -- If S3 fails, a previously cached snapshot is served for - `s3.stale_grace_seconds`; otherwise the OCSP response is `tryLater`. +- `s3.stale_grace_seconds` is also the hard maximum snapshot age, measured from + `generated_at`. Expired or future-dated snapshots are rejected even when S3 + remains readable; if no valid cached snapshot remains, OCSP returns + `tryLater`. +- If a refresh fails, a cached snapshot is served only while both its time since + the last successful fetch and its `generated_at` age remain within + `s3.stale_grace_seconds`. +- KCA republishes unchanged snapshots every + `KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds). Keep that interval + comfortably below the responder's `s3.stale_grace_seconds` (default 60). For + a rolling upgrade, enable publisher heartbeats before the freshness-enforcing + responder so existing snapshots remain within the age limit. +- OCSP `thisUpdate` is the snapshot's `generated_at`. `nextUpdate` is capped at + the snapshot's hard expiry, while `producedAt` is the response signing time. - With multiple CAs, the TTL is per CA. If you need a global one-request-per- second limit across all CAs, that requires a shared rate limiter. diff --git a/ocsp-responder/responder.example.yaml b/ocsp-responder/responder.example.yaml index 46b337c..ecded02 100644 --- a/ocsp-responder/responder.example.yaml +++ b/ocsp-responder/responder.example.yaml @@ -11,6 +11,7 @@ s3: # endpoint: https://s3.example.com # force_path_style: true cache_ttl_ms: 1000 + # Hard maximum snapshot age. Keep comfortably above KCA's heartbeat interval. stale_grace_seconds: 60 issuers: diff --git a/ocsp-responder/src/cache.rs b/ocsp-responder/src/cache.rs index b80b808..66a2bc7 100644 --- a/ocsp-responder/src/cache.rs +++ b/ocsp-responder/src/cache.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; +use chrono::Utc; use tokio::sync::{Mutex, RwLock}; use crate::snapshot::Snapshot; @@ -27,7 +28,8 @@ impl SnapshotCache { /// Return a snapshot, refreshing from S3 at most once per `ttl`. /// - /// On refresh failure, data up to `ttl + stale_grace` old is still used. + /// Snapshots older than `stale_grace` are never used. On refresh failure, + /// cached data is also limited to `stale_grace` since its last fetch. pub async fn get( &self, store: &S, @@ -38,7 +40,8 @@ impl SnapshotCache { stale_grace: Duration, ) -> Result, CacheError> { if let Some(value) = self.cached.read().await.as_ref() { - if value.fetched_at.elapsed() < ttl { + if value.fetched_at.elapsed() < ttl && snapshot_age_valid(&value.snapshot, stale_grace) + { return Ok(value.snapshot.clone()); } } @@ -49,7 +52,9 @@ impl SnapshotCache { let stale = { let cache = self.cached.read().await; if let Some(value) = cache.as_ref() { - if value.fetched_at.elapsed() < ttl { + if value.fetched_at.elapsed() < ttl + && snapshot_age_valid(&value.snapshot, stale_grace) + { return Ok(value.snapshot.clone()); } Some(value.clone()) @@ -60,14 +65,17 @@ impl SnapshotCache { match store.get(key).await { Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) { - Ok(snapshot) => { - let snapshot = Arc::new(snapshot); - *self.cached.write().await = Some(CachedSnapshot { - snapshot: snapshot.clone(), - fetched_at: Instant::now(), - }); - Ok(snapshot) - } + Ok(snapshot) => match validate_snapshot_age(&snapshot, stale_grace) { + Ok(()) => { + let snapshot = Arc::new(snapshot); + *self.cached.write().await = Some(CachedSnapshot { + snapshot: snapshot.clone(), + fetched_at: Instant::now(), + }); + Ok(snapshot) + } + Err(error) => self.fallback(stale, error, stale_grace), + }, Err(error) => self.fallback(stale, error.to_string(), stale_grace), }, Err(error) => self.fallback(stale, error.to_string(), stale_grace), @@ -81,7 +89,9 @@ impl SnapshotCache { stale_grace: Duration, ) -> Result, CacheError> { if let Some(value) = stale { - if value.fetched_at.elapsed() <= stale_grace { + if value.fetched_at.elapsed() <= stale_grace + && snapshot_age_valid(&value.snapshot, stale_grace) + { tracing::warn!(error = %error, "serving stale OCSP snapshot"); return Ok(value.snapshot); } @@ -90,6 +100,29 @@ impl SnapshotCache { } } +fn snapshot_age_valid(snapshot: &Snapshot, stale_grace: Duration) -> bool { + validate_snapshot_age(snapshot, stale_grace).is_ok() +} + +fn validate_snapshot_age(snapshot: &Snapshot, stale_grace: Duration) -> Result<(), String> { + let age = Utc::now() + .signed_duration_since(snapshot.generated_at) + .to_std() + .map_err(|_| { + format!( + "snapshot generation time {} is in the future", + snapshot.generated_at + ) + })?; + if age > stale_grace { + return Err(format!( + "snapshot generated at {} exceeds maximum age {stale_grace:?}", + snapshot.generated_at + )); + } + Ok(()) +} + #[derive(Debug, thiserror::Error)] pub enum CacheError { #[error("OCSP snapshot unavailable: {0}")] @@ -134,16 +167,17 @@ mod tests { } } - fn body(sha: &str) -> String { + fn body(sha: &str, generated_at: chrono::DateTime) -> String { + let generated_at = generated_at.to_rfc3339(); format!( - r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"# + r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"generated_at":"{generated_at}","entries":[{{"serial_hex":"01","status":"valid"}}]}}"# ) } #[tokio::test] async fn single_flight_refreshes_once() { let sha = sha256_hex(b"ca"); - let store = FakeStore::new(body(&sha)); + let store = FakeStore::new(body(&sha, Utc::now())); let cache = SnapshotCache::new(); let (a, b) = tokio::join!( cache.get( @@ -171,7 +205,7 @@ mod tests { #[tokio::test] async fn refreshes_after_ttl_and_serves_stale_on_error() { let sha = sha256_hex(b"ca"); - let store = FakeStore::new(body(&sha)); + let store = FakeStore::new(body(&sha, Utc::now())); let cache = SnapshotCache::new(); cache .get( @@ -200,4 +234,46 @@ mod tests { assert_eq!(stale.entries.len(), 1); assert_eq!(store.calls.load(Ordering::SeqCst), 2); } + + #[tokio::test] + async fn rejects_expired_snapshot_from_healthy_store() { + let sha = sha256_hex(b"ca"); + let generated_at = Utc::now() - chrono::Duration::seconds(30); + let store = FakeStore::new(body(&sha, generated_at)); + let cache = SnapshotCache::new(); + let result = cache + .get( + &store, + "key", + &sha, + "ca", + Duration::from_millis(1), + Duration::from_secs(1), + ) + .await; + assert!(matches!(result, Err(CacheError::Unavailable(_)))); + assert_eq!(store.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn successful_fetches_do_not_extend_snapshot_lifetime() { + let sha = sha256_hex(b"ca"); + let store = FakeStore::new(body(&sha, Utc::now())); + let cache = SnapshotCache::new(); + let ttl = Duration::from_secs(1); + let stale_grace = Duration::from_millis(100); + cache + .get(&store, "key", &sha, "ca", ttl, stale_grace) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(120)).await; + let expired = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await; + assert!(matches!(expired, Err(CacheError::Unavailable(_)))); + + store.fail.store(true, Ordering::SeqCst); + let unavailable = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await; + assert!(matches!(unavailable, Err(CacheError::Unavailable(_)))); + assert_eq!(store.calls.load(Ordering::SeqCst), 3); + } } diff --git a/ocsp-responder/src/ocsp.rs b/ocsp-responder/src/ocsp.rs index b6e1394..0a08620 100644 --- a/ocsp-responder/src/ocsp.rs +++ b/ocsp-responder/src/ocsp.rs @@ -8,6 +8,7 @@ use axum::extract::{Path, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use base64::Engine; +use chrono::{DateTime, Utc}; use der::{Decode, Encode}; use x509_cert::ext::pkix::CrlReason; use x509_ocsp::builder::OcspResponseBuilder; @@ -17,7 +18,7 @@ use x509_ocsp::{ use crate::config::Config; use crate::issuer::Issuer; -use crate::signer::{now_time, system_time}; +use crate::signer::system_time; use crate::store::StatusStore; #[derive(Clone)] @@ -74,19 +75,28 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response { } }; - let now = match now_time() { + let now = SystemTime::now(); + let produced_at = match OcspGeneralizedTime::try_from(now) { Ok(now) => now, Err(error) => { tracing::error!(error = %error, "could not create OCSP time"); return ocsp_response(OcspResponse::internal_error()); } }; - let next_update = now_time() - .ok() - .and_then(|_| { - SystemTime::now().checked_add(Duration::from_secs(state.config.next_update_seconds)) - }) - .and_then(|value| OcspGeneralizedTime::try_from(value).ok()); + let (this_update, next_update) = response_update_times( + snapshot.generated_at, + state.config.next_update_seconds, + state.config.s3.stale_grace_seconds, + now, + ); + let this_update = match OcspGeneralizedTime::try_from(this_update) { + Ok(value) => value, + Err(error) => { + tracing::error!(error = %error, "could not create OCSP snapshot time"); + return ocsp_response(OcspResponse::internal_error()); + } + }; + let next_update = next_update.and_then(|value| OcspGeneralizedTime::try_from(value).ok()); let mut builder = OcspResponseBuilder::new(issuer.signer_cert.tbs_certificate.subject.clone()); for item in &request.tbs_request.request_list { @@ -108,7 +118,7 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response { Some(_) => CertStatus::good(), None => CertStatus::unknown(), }; - let mut single = SingleResponse::new(item.req_cert.clone(), status, now); + let mut single = SingleResponse::new(item.req_cert.clone(), status, this_update); if let Some(next_update) = next_update { single = single.with_next_update(next_update); } @@ -123,7 +133,7 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response { } let mut signer = issuer.signer.lock().await; - match signer.sign(builder, vec![issuer.signer_cert.clone()], now) { + match signer.sign(builder, vec![issuer.signer_cert.clone()], produced_at) { Ok(response) => ocsp_response(response), Err(error) => { tracing::error!(issuer = %issuer.name, error = %error, "OCSP signing failed"); @@ -132,6 +142,20 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response { } } +fn response_update_times( + generated_at: DateTime, + next_update_seconds: u64, + stale_grace_seconds: u64, + now: SystemTime, +) -> (SystemTime, Option) { + let this_update = system_time(generated_at); + let expires_at = this_update.checked_add(Duration::from_secs(stale_grace_seconds)); + let next_update = now + .checked_add(Duration::from_secs(next_update_seconds)) + .and_then(|next| expires_at.map(|expires| next.min(expires))); + (this_update, next_update) +} + fn ocsp_response(response: OcspResponse) -> Response { match response.to_der() { Ok(bytes) => ( @@ -149,3 +173,28 @@ fn ocsp_response(response: OcspResponse) -> Response { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_times_use_snapshot_age_and_bound_next_update() { + let generated_at = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let generated_at_system = system_time(generated_at); + + let now = generated_at_system + Duration::from_secs(5); + let (this_update, next_update) = response_update_times(generated_at, 10, 60, now); + assert_eq!(this_update, generated_at_system); + assert_eq!(next_update, Some(now + Duration::from_secs(10))); + + let now = generated_at_system + Duration::from_secs(55); + let (_, next_update) = response_update_times(generated_at, 10, 60, now); + assert_eq!( + next_update, + Some(generated_at_system + Duration::from_secs(60)) + ); + } +} diff --git a/ocsp-responder/src/signer.rs b/ocsp-responder/src/signer.rs index d515423..299d218 100644 --- a/ocsp-responder/src/signer.rs +++ b/ocsp-responder/src/signer.rs @@ -83,10 +83,6 @@ pub fn read_certificate(path: &Path) -> Result { Certificate::from_der(&data).map_err(|error| SignerError::Certificate(error.to_string())) } -pub fn now_time() -> Result { - Ok(OcspGeneralizedTime::try_from(SystemTime::now())?) -} - pub fn system_time(value: chrono::DateTime) -> SystemTime { if value.timestamp() >= 0 { SystemTime::UNIX_EPOCH @@ -107,6 +103,4 @@ pub enum SignerError { Certificate(String), #[error("OCSP signing failed: {0}")] Sign(String), - #[error("time conversion failed: {0}")] - Time(#[from] der::Error), } diff --git a/ocsp-responder/src/snapshot.rs b/ocsp-responder/src/snapshot.rs index a424aab..367a196 100644 --- a/ocsp-responder/src/snapshot.rs +++ b/ocsp-responder/src/snapshot.rs @@ -25,6 +25,7 @@ pub struct StatusEntry { #[derive(Debug, Clone)] pub struct Snapshot { + pub generated_at: DateTime, pub entries: HashMap, StatusEntry>, } @@ -32,6 +33,7 @@ pub struct Snapshot { struct RawSnapshot { schema: String, issuer: RawIssuer, + generated_at: DateTime, #[serde(default)] entries: Vec, } @@ -65,6 +67,9 @@ impl Snapshot { if raw.schema != SCHEMA { return Err(SnapshotError::Schema(raw.schema)); } + if raw.generated_at > Utc::now() { + return Err(SnapshotError::FutureGeneration(raw.generated_at)); + } if !raw.issuer.id.eq_ignore_ascii_case(expected_ca_id) { return Err(SnapshotError::IssuerIdMismatch { expected: expected_ca_id.to_string(), @@ -110,7 +115,10 @@ impl Snapshot { } } let _ = raw.issuer.sha256; - Ok(Self { entries }) + Ok(Self { + generated_at: raw.generated_at, + entries, + }) } pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> { @@ -170,6 +178,8 @@ pub enum SnapshotError { Json(#[from] serde_json::Error), #[error("unsupported snapshot schema {0:?}")] Schema(String), + #[error("snapshot generation time {0} is in the future")] + FutureGeneration(DateTime), #[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")] IssuerIdMismatch { expected: String, actual: String }, #[error("snapshot issuer {actual} does not match configured CA {expected}")] @@ -228,6 +238,24 @@ mod tests { assert!(Snapshot::parse(duplicate.as_bytes(), &sha, "ca").is_err()); } + #[test] + fn rejects_missing_or_future_generation_time() { + let sha = sha256_hex(b"ca"); + let missing = body(&sha) + .lines() + .filter(|line| !line.contains("\"generated_at\"")) + .collect::>() + .join("\n"); + assert!(Snapshot::parse(missing.as_bytes(), &sha, "ca").is_err()); + + let future = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); + let future = body(&sha).replace("2026-01-01T00:00:00Z", &future); + assert!(matches!( + Snapshot::parse(future.as_bytes(), &sha, "ca"), + Err(SnapshotError::FutureGeneration(_)) + )); + } + #[test] fn normalizes_serials() { assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]); diff --git a/server/.env.example b/server/.env.example index 99c0b9f..31077a5 100644 --- a/server/.env.example +++ b/server/.env.example @@ -62,6 +62,9 @@ KCA_OCSP_S3_PREFIX=ocsp #KCA_OCSP_S3_ENDPOINT=http://localhost:9000 KCA_OCSP_S3_FORCE_PATH_STYLE=false KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1 +# Republish unchanged snapshots as a liveness heartbeat. Keep this comfortably +# below the responder's s3.stale_grace_seconds (default 60). +KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30 # --- CORS (optional) ------------------------------------------------------- #CORS_ALLOW_ORIGINS= diff --git a/server/packages/kca-app/src/kca/config.py b/server/packages/kca-app/src/kca/config.py index 7849176..dcbf9b6 100644 --- a/server/packages/kca-app/src/kca/config.py +++ b/server/packages/kca-app/src/kca/config.py @@ -191,6 +191,7 @@ class Settings: ocsp_s3_endpoint: Optional[str] ocsp_s3_force_path_style: bool ocsp_publish_interval_seconds: int + ocsp_heartbeat_interval_seconds: int @staticmethod def from_env() -> "Settings": @@ -245,6 +246,9 @@ class Settings: ocsp_publish_interval_seconds=max( 1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1")) ), + ocsp_heartbeat_interval_seconds=max( + 1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30")) + ), ) diff --git a/server/packages/kca-app/src/kca/ocsp.py b/server/packages/kca-app/src/kca/ocsp.py index 50b9c83..f579c7d 100644 --- a/server/packages/kca-app/src/kca/ocsp.py +++ b/server/packages/kca-app/src/kca/ocsp.py @@ -10,11 +10,12 @@ from __future__ import annotations import asyncio import json import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Awaitable, Callable, Dict, Optional from uuid import UUID from kaya.core import KayaApp, KayaMixin +from tortoise.expressions import Q from .config import Settings, settings from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault @@ -147,14 +148,16 @@ async def mark_all_cas_dirty(vault: Vault) -> None: class OcspPublisherMixin(KayaMixin): - """Background publisher for dirty OCSP snapshots.""" + """Background publisher for dirty and due OCSP snapshots.""" def __init__( self, interval_seconds: int, ensure_context: Callable[[], Awaitable[None]], + heartbeat_interval_seconds: int = 30, ) -> None: self._interval = max(1, interval_seconds) + self._heartbeat_interval = max(1, heartbeat_interval_seconds) self._ensure_context = ensure_context self._task: Optional[asyncio.Task] = None self._bootstrapped = False @@ -212,7 +215,14 @@ class OcspPublisherMixin(KayaMixin): # The publisher runs outside an HTTP request task, so it must bind # Tortoise's per-loop context before querying models. await self._ensure_context() - rows = await OcspSnapshot.filter(dirty=True).limit(25) + heartbeat_before = datetime.now(timezone.utc) - timedelta( + seconds=self._heartbeat_interval + ) + rows = await OcspSnapshot.filter( + Q(dirty=True) + | Q(published_at__isnull=True) + | Q(published_at__lte=heartbeat_before) + ).limit(25) for row in rows: ca = await Certificate.get_or_none(id=row.ca_id) if ca is None: @@ -226,7 +236,7 @@ class OcspPublisherMixin(KayaMixin): published_at = datetime.now(timezone.utc) cleared = await OcspSnapshot.filter( ca_id=row.ca_id, - dirty=True, + dirty=row.dirty, updated_at=row.updated_at, ).update( dirty=False, @@ -257,6 +267,7 @@ def publisher_mixin_from_settings( return OcspPublisherMixin( settings_obj.ocsp_publish_interval_seconds, ensure_context, + settings_obj.ocsp_heartbeat_interval_seconds, ) diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py index e3bd8a8..bf3e39b 100644 --- a/server/tests/test_routes_extra.py +++ b/server/tests/test_routes_extra.py @@ -1038,6 +1038,79 @@ class OcspSnapshotTests(unittest.TestCase): assert row.dirty is False assert row.published_at is not None + @async_test + async def test_publisher_heartbeat_republishes_due_clean_snapshot(self) -> None: + from unittest.mock import patch + + from kca import ocsp + from kca.models import OcspSnapshot + + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + ca_key = await _key(client, vault_id, "heartbeat due ca") + ca = await _cert(client, vault_id, ca_key["id"], "Heartbeat Due CA", ca=True) + previous_published_at = datetime.now(timezone.utc) - timedelta(seconds=31) + await OcspSnapshot.create( + ca_id=ca["id"], + dirty=False, + published_at=previous_published_at, + ) + published = [] + + async def fake_publish(publish_ca, snapshot): + published.append(snapshot) + return "key" + + async def ensure_context(): + return None + + mixin = ocsp.OcspPublisherMixin( + 1, ensure_context, heartbeat_interval_seconds=30 + ) + with patch.object(ocsp, "publish_snapshot", fake_publish): + await mixin.run_once() + + assert len(published) == 1 + row = await OcspSnapshot.get(ca_id=ca["id"]) + assert row.dirty is False + assert row.published_at is not None + assert row.published_at != previous_published_at + + @async_test + async def test_publisher_heartbeat_skips_recent_clean_snapshot(self) -> None: + from unittest.mock import patch + + from kca import ocsp + from kca.models import OcspSnapshot + + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + ca_key = await _key(client, vault_id, "heartbeat recent ca") + ca = await _cert(client, vault_id, ca_key["id"], "Heartbeat Recent CA", ca=True) + await OcspSnapshot.create( + ca_id=ca["id"], + dirty=False, + published_at=datetime.now(timezone.utc), + ) + published = [] + + async def fake_publish(publish_ca, snapshot): + published.append(snapshot) + return "key" + + async def ensure_context(): + return None + + mixin = ocsp.OcspPublisherMixin( + 1, ensure_context, heartbeat_interval_seconds=30 + ) + with patch.object(ocsp, "publish_snapshot", fake_publish): + await mixin.run_once() + + assert published == [] + @async_test async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None: import json