feat: certificate management with stage/activate/restore lifecycle
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user