feat: certificate management with stage/activate/restore lifecycle
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 45s

Provider-based architecture (Docker now, K8s later):
- CertificateManager interface + DockerCertificateManager (file-based)
- Atomic swap via .wip files for safe cert replacement
- Stage -> Activate -> Archive lifecycle with one-deep rollback
- Bootstrap supports user-supplied certs via CERT_FILE/KEY_FILE/CA_FILE
- CA bundle aggregates platform + tenant CAs, distributed to containers
- Vendor UI: Certificates page with upload, activate, restore, discard
- Stale tenant tracking (ca_applied_at) with restart banner
- Conditional TLS skip removal when CA bundle exists

Includes design spec, migration V012, service + controller tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 18:29:02 +02:00
parent 51a1aef10e
commit 45bcc954ac
23 changed files with 2035 additions and 7 deletions

View File

@@ -0,0 +1,218 @@
package net.siegeln.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CertificateServiceTest {
@Mock
private CertificateManager certManager;
@Mock
private CertificateRepository certRepository;
@Mock
private TenantRepository tenantRepository;
private CertificateService service;
@BeforeEach
void setUp() {
service = new CertificateService(certManager, certRepository, tenantRepository);
}
@Test
void stage_delegatesToManagerAndSavesEntity() {
var info = new CertificateInfo("CN=test", "CN=test", Instant.now(),
Instant.now().plusSeconds(86400), false, true, "AA:BB");
when(certManager.isAvailable()).thenReturn(true);
when(certManager.stage(any(), any(), any()))
.thenReturn(CertValidationResult.ok(info));
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.empty());
when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
assertThat(result.valid()).isTrue();
var captor = ArgumentCaptor.forClass(CertificateEntity.class);
verify(certRepository).save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.STAGED);
assertThat(captor.getValue().getSubject()).isEqualTo("CN=test");
}
@Test
void stage_discardsExistingStagedFirst() {
var existing = new CertificateEntity();
when(certManager.isAvailable()).thenReturn(true);
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(existing));
when(certManager.stage(any(), any(), any()))
.thenReturn(CertValidationResult.fail(List.of("bad cert")));
service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
verify(certRepository).delete(existing);
}
@Test
void stage_returnsErrorWhenManagerUnavailable() {
when(certManager.isAvailable()).thenReturn(false);
var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID());
assertThat(result.valid()).isFalse();
assertThat(result.errors()).contains("Certificate management is not available");
}
@Test
void activate_promotesStagedToActive() {
var staged = new CertificateEntity();
staged.setStatus(CertificateEntity.Status.STAGED);
staged.setSubject("CN=new");
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(staged));
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.empty());
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.empty());
service.activate();
verify(certManager).activate();
assertThat(staged.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
assertThat(staged.getActivatedAt()).isNotNull();
}
@Test
void activate_archivesCurrentActive() {
var staged = new CertificateEntity();
staged.setStatus(CertificateEntity.Status.STAGED);
var active = new CertificateEntity();
active.setStatus(CertificateEntity.Status.ACTIVE);
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(staged));
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.empty());
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.of(active));
service.activate();
assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED);
assertThat(active.getArchivedAt()).isNotNull();
}
@Test
void activate_failsWithNoStagedCert() {
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> service.activate())
.isInstanceOf(IllegalStateException.class);
}
@Test
void restore_swapsActiveAndArchived() {
var active = new CertificateEntity();
active.setStatus(CertificateEntity.Status.ACTIVE);
active.setSubject("CN=current");
var archived = new CertificateEntity();
archived.setStatus(CertificateEntity.Status.ARCHIVED);
archived.setSubject("CN=previous");
// Set notAfter in the future so it's restorable
archived.setNotAfter(Instant.now().plusSeconds(86400 * 365));
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.of(archived));
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.of(active));
service.restore();
verify(certManager).restore();
assertThat(archived.getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
assertThat(active.getStatus()).isEqualTo(CertificateEntity.Status.ARCHIVED);
}
@Test
void restore_failsWhenArchivedExpired() {
var archived = new CertificateEntity();
archived.setStatus(CertificateEntity.Status.ARCHIVED);
archived.setNotAfter(Instant.now().minusSeconds(3600)); // expired
when(certRepository.findByStatus(CertificateEntity.Status.ARCHIVED))
.thenReturn(Optional.of(archived));
assertThatThrownBy(() -> service.restore())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("expired");
}
@Test
void discardStaged_delegatesAndDeletesEntity() {
var staged = new CertificateEntity();
when(certRepository.findByStatus(CertificateEntity.Status.STAGED))
.thenReturn(Optional.of(staged));
service.discardStaged();
verify(certManager).discardStaged();
verify(certRepository).delete(staged);
}
@Test
void seedFromFilesystem_skipsWhenActiveExists() {
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.of(new CertificateEntity()));
service.seedFromFilesystem();
verify(certManager, never()).getActive();
}
@Test
void seedFromFilesystem_seedsFromManager() {
var info = new CertificateInfo("CN=bootstrap", "CN=bootstrap", Instant.now(),
Instant.now().plusSeconds(86400), false, true, "AA:BB");
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.empty());
when(certManager.getActive()).thenReturn(info);
when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.seedFromFilesystem();
var captor = ArgumentCaptor.forClass(CertificateEntity.class);
verify(certRepository).save(captor.capture());
assertThat(captor.getValue().getSubject()).isEqualTo("CN=bootstrap");
assertThat(captor.getValue().getStatus()).isEqualTo(CertificateEntity.Status.ACTIVE);
}
@Test
void countStaleTenants_returnsZeroWhenNoActiveCert() {
when(certRepository.findByStatus(CertificateEntity.Status.ACTIVE))
.thenReturn(Optional.empty());
assertThat(service.countStaleTenants()).isZero();
}
}

View File

@@ -0,0 +1,224 @@
package net.siegeln.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DockerCertificateManagerTest {
@TempDir
Path certsDir;
private DockerCertificateManager manager;
@BeforeEach
void setUp() {
manager = new DockerCertificateManager(certsDir);
}
@Test
void isAvailable_returnsTrueForWritableDirectory() {
assertThat(manager.isAvailable()).isTrue();
}
@Test
void getActive_returnsNullWhenNoCert() {
assertThat(manager.getActive()).isNull();
}
@Test
void stage_validatesAndWritesFiles() throws Exception {
byte[][] pair = generateCertAndKey("CN=test.example.com");
var result = manager.stage(pair[0], pair[1], null);
assertThat(result.valid()).isTrue();
assertThat(result.info()).isNotNull();
assertThat(result.info().subject()).contains("test.example.com");
assertThat(result.info().selfSigned()).isTrue();
assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue();
assertThat(Files.exists(certsDir.resolve("staged/key.pem"))).isTrue();
}
@Test
void stage_rejectsInvalidCertPem() {
var result = manager.stage("not a cert".getBytes(), "not a key".getBytes(), null);
assertThat(result.valid()).isFalse();
assertThat(result.errors()).isNotEmpty();
}
@Test
void stage_rejectsMismatchedKeyAndCert() throws Exception {
byte[][] pair1 = generateCertAndKey("CN=test.example.com");
byte[][] pair2 = generateCertAndKey("CN=other.example.com");
var result = manager.stage(pair1[0], pair2[1], null);
assertThat(result.valid()).isFalse();
assertThat(result.errors()).anyMatch(e -> e.contains("does not match"));
}
@Test
void activateAndRestore_lifecycle() throws Exception {
// Set up an initial active cert
byte[][] pair1 = generateCertAndKey("CN=first.example.com");
Files.write(certsDir.resolve("cert.pem"), pair1[0]);
Files.write(certsDir.resolve("key.pem"), pair1[1]);
// Stage a second cert
byte[][] pair2 = generateCertAndKey("CN=second.example.com");
var stageResult = manager.stage(pair2[0], pair2[1], null);
assertThat(stageResult.valid()).isTrue();
// Activate: first -> archived, second -> active
manager.activate();
var active = manager.getActive();
assertThat(active).isNotNull();
assertThat(active.subject()).contains("second.example.com");
var archived = manager.getArchived();
assertThat(archived).isNotNull();
assertThat(archived.subject()).contains("first.example.com");
assertThat(manager.getStaged()).isNull();
// Restore: swap active <-> archived
manager.restore();
active = manager.getActive();
assertThat(active.subject()).contains("first.example.com");
archived = manager.getArchived();
assertThat(archived.subject()).contains("second.example.com");
}
@Test
void activate_failsWithNoStagedCert() {
assertThatThrownBy(() -> manager.activate())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No staged certificate");
}
@Test
void restore_failsWithNoArchivedCert() {
assertThatThrownBy(() -> manager.restore())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No archived certificate");
}
@Test
void discardStaged_removesFiles() throws Exception {
byte[][] pair = generateCertAndKey("CN=test.example.com");
manager.stage(pair[0], pair[1], null);
assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue();
manager.discardStaged();
assertThat(Files.exists(certsDir.resolve("staged"))).isFalse();
}
@Test
void stage_withCaBundle() throws Exception {
byte[][] pair = generateCertAndKey("CN=test.example.com");
// Use the cert itself as a "CA" for testing purposes
var result = manager.stage(pair[0], pair[1], pair[0]);
assertThat(result.valid()).isTrue();
assertThat(result.info().hasCaBundle()).isTrue();
assertThat(Files.exists(certsDir.resolve("staged/ca.pem"))).isTrue();
}
@Test
void getCaBundle_returnsNullWhenMissing() {
assertThat(manager.getCaBundle()).isNull();
}
@Test
void getCaBundle_returnsContentWhenPresent() throws Exception {
byte[] caContent = "test-ca-content".getBytes();
Files.write(certsDir.resolve("ca.pem"), caContent);
assertThat(manager.getCaBundle()).isEqualTo(caContent);
}
@Test
void activate_deletesExistingArchive() throws Exception {
// Create initial active
byte[][] pair1 = generateCertAndKey("CN=first.example.com");
Files.write(certsDir.resolve("cert.pem"), pair1[0]);
Files.write(certsDir.resolve("key.pem"), pair1[1]);
// Create existing archive
Files.createDirectories(certsDir.resolve("prev"));
byte[][] pairOld = generateCertAndKey("CN=old.example.com");
Files.write(certsDir.resolve("prev/cert.pem"), pairOld[0]);
Files.write(certsDir.resolve("prev/key.pem"), pairOld[1]);
// Stage new cert
byte[][] pair2 = generateCertAndKey("CN=second.example.com");
manager.stage(pair2[0], pair2[1], null);
// Activate: old archive should be deleted, first becomes archive
manager.activate();
var archived = manager.getArchived();
assertThat(archived).isNotNull();
assertThat(archived.subject()).contains("first.example.com");
}
// --- Test helpers: generate self-signed certs via keytool + openssl ---
/**
* Generates a matched cert.pem + key.pem pair using keytool and openssl.
* Returns [certPem, keyPem].
*/
private static byte[][] generateCertAndKey(String cn) throws Exception {
Path tmpDir = Files.createTempDirectory("cert-test");
Path ks = tmpDir.resolve("keystore.p12");
Path certFile = tmpDir.resolve("cert.pem");
Path keyFile = tmpDir.resolve("key.pem");
try {
// Generate keystore with self-signed cert
exec("keytool", "-genkeypair", "-alias", "test", "-keyalg", "RSA", "-keysize", "2048",
"-validity", "365", "-dname", cn, "-storetype", "PKCS12",
"-keystore", ks.toString(), "-storepass", "changeit");
// Export cert to PEM
exec("keytool", "-exportcert", "-alias", "test", "-rfc",
"-keystore", ks.toString(), "-storepass", "changeit",
"-file", certFile.toString());
// Export key via openssl
exec("openssl", "pkcs12", "-in", ks.toString(), "-passin", "pass:changeit",
"-nocerts", "-nodes", "-out", keyFile.toString());
return new byte[][] { Files.readAllBytes(certFile), Files.readAllBytes(keyFile) };
} finally {
Files.deleteIfExists(ks);
Files.deleteIfExists(certFile);
Files.deleteIfExists(keyFile);
Files.deleteIfExists(tmpDir);
}
}
private static void exec(String... cmd) throws Exception {
var pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
var proc = pb.start();
proc.getInputStream().readAllBytes(); // consume output
int exit = proc.waitFor();
if (exit != 0) {
throw new RuntimeException("Command failed (exit " + exit + "): " + String.join(" ", cmd));
}
}
}