org.springframework.boot
spring-boot-starter-web
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java
new file mode 100644
index 00000000..2706a3e3
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java
@@ -0,0 +1,223 @@
+package com.cameleer.server.app.license;
+
+import com.cameleer.license.minter.LicenseMinter;
+import com.cameleer.server.app.AbstractPostgresIT;
+import com.cameleer.server.app.TestSecurityHelper;
+import com.cameleer.server.core.license.LicenseGate;
+import com.cameleer.server.core.license.LicenseInfo;
+import com.cameleer.server.core.license.LicenseState;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end integration test for the license install / persist / revalidate / reject lifecycle.
+ *
+ * Mints a real Ed25519-signed token via {@code cameleer-license-minter} (test scope), POSTs
+ * it through {@code /api/v1/admin/license}, then verifies:
+ *
+ * - Gate transitions ABSENT → ACTIVE.
+ * - Row persists in the {@code license} PostgreSQL table.
+ * - After {@code gate.clear()}, {@code revalidate()} restores ACTIVE from the persisted token.
+ * - A token with a tampered signature is rejected (HTTP 400) and audited as FAILURE
+ * under {@code AuditCategory.LICENSE} without mutating the gate.
+ *
+ *
+ * The Ed25519 keypair is generated once per JVM and the public key is published as a Spring
+ * property via {@code @DynamicPropertySource} (which composes with the JDBC overrides in
+ * {@link AbstractPostgresIT}). Scenario 3 from the plan
+ * (revalidateAfterPublicKeyChange_marksInvalid) is intentionally skipped — it would require
+ * mid-context re-binding of the validator bean, which is more complex than the value warrants.
+ */
+class LicenseLifecycleIT extends AbstractPostgresIT {
+
+ private static final KeyPair KEY_PAIR = generateKeyPair();
+
+ private static KeyPair generateKeyPair() {
+ try {
+ return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("Ed25519 not available", e);
+ }
+ }
+
+ @DynamicPropertySource
+ static void licensePublicKey(DynamicPropertyRegistry registry) {
+ registry.add("cameleer.server.license.publickey", () ->
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded()));
+ }
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ @Autowired
+ private LicenseGate gate;
+
+ @Autowired
+ private LicenseService licenseService;
+
+ @Autowired
+ private LicenseRepository licenseRepository;
+
+ private String adminJwt;
+
+ @BeforeEach
+ void setUp() {
+ adminJwt = securityHelper.adminToken();
+ // Sibling ITs may have left state behind.
+ gate.clear();
+ licenseRepository.delete("default");
+ jdbcTemplate.update("DELETE FROM audit_log WHERE category = 'LICENSE'");
+ }
+
+ @AfterEach
+ void tearDown() {
+ gate.clear();
+ licenseRepository.delete("default");
+ }
+
+ /** Scenario 1 — install via REST, verify gate + DB, clear, revalidate, gate restored. */
+ @Test
+ void install_persists_andSurvivesGateClear() throws Exception {
+ Instant now = Instant.now();
+ UUID licenseId = UUID.randomUUID();
+ LicenseInfo info = new LicenseInfo(
+ licenseId,
+ "default",
+ "lifecycle-it",
+ Map.of("max_apps", 25),
+ now,
+ now.plus(1, ChronoUnit.DAYS),
+ 0);
+ String token = LicenseMinter.mint(info, KEY_PAIR.getPrivate());
+
+ // POST to /api/v1/admin/license
+ Map body = new LinkedHashMap<>();
+ body.put("token", token);
+ ResponseEntity response = restTemplate.exchange(
+ "/api/v1/admin/license", HttpMethod.POST,
+ new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
+ String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ JsonNode payload = objectMapper.readTree(response.getBody());
+ assertThat(payload.path("state").asText()).isEqualTo("ACTIVE");
+ assertThat(payload.path("envelope").path("licenseId").asText()).isEqualTo(licenseId.toString());
+
+ // Gate is ACTIVE, parsed envelope matches.
+ assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
+ assertThat(gate.getCurrent()).isNotNull();
+ assertThat(gate.getCurrent().licenseId()).isEqualTo(licenseId);
+
+ // Row persisted in PostgreSQL.
+ Integer rowCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM license WHERE tenant_id = ? AND license_id = ?",
+ Integer.class, "default", licenseId);
+ assertThat(rowCount).isEqualTo(1);
+
+ // Audit row written under LICENSE category.
+ Integer auditCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM audit_log WHERE category = 'LICENSE' AND result = 'SUCCESS'",
+ Integer.class);
+ assertThat(auditCount).isGreaterThanOrEqualTo(1);
+
+ // Now simulate a server restart effect: clear in-memory gate, then revalidate from DB.
+ gate.clear();
+ assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);
+
+ licenseService.revalidate();
+
+ assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
+ assertThat(gate.getCurrent()).isNotNull();
+ assertThat(gate.getCurrent().licenseId()).isEqualTo(licenseId);
+ }
+
+ /** Scenario 2 — tampered signature → 400 + LICENSE/FAILURE audit row, gate stays ABSENT. */
+ @Test
+ void postWithBadSignature_returns400_andDoesNotMutateGate() throws Exception {
+ Instant now = Instant.now();
+ LicenseInfo info = new LicenseInfo(
+ UUID.randomUUID(),
+ "default",
+ "tamper-it",
+ Map.of(),
+ now,
+ now.plus(1, ChronoUnit.DAYS),
+ 0);
+ String token = LicenseMinter.mint(info, KEY_PAIR.getPrivate());
+ String tampered = tamperSignature(token);
+
+ // Sanity: gate starts ABSENT.
+ assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);
+
+ Map body = new LinkedHashMap<>();
+ body.put("token", tampered);
+ ResponseEntity response = restTemplate.exchange(
+ "/api/v1/admin/license", HttpMethod.POST,
+ new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
+ String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ JsonNode payload = objectMapper.readTree(response.getBody());
+ assertThat(payload.path("error").asText()).isNotBlank();
+
+ // Gate moved to INVALID (LicenseService.install() calls gate.markInvalid on validation
+ // failure, then re-throws — the controller converts to 400). The DB stays empty.
+ assertThat(gate.getState()).isEqualTo(LicenseState.INVALID);
+ assertThat(gate.getCurrent()).isNull();
+ assertThat(gate.getInvalidReason()).isNotBlank();
+
+ // No persisted row.
+ assertThat(licenseRepository.findByTenantId("default")).isEmpty();
+
+ // Exactly one audit row, LICENSE/FAILURE for action 'reject_license'.
+ Integer failureCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM audit_log " +
+ "WHERE category = 'LICENSE' AND result = 'FAILURE' AND action = 'reject_license'",
+ Integer.class);
+ assertThat(failureCount).isEqualTo(1);
+ }
+
+ /**
+ * Flips a single byte in the signature segment of a {@code base64(payload).base64(sig)} token
+ * so the Ed25519 verifier fails. Stays decodable as base64 so the parse-format check passes
+ * and the failure is reported as a signature-mismatch SecurityException, not a parse error.
+ */
+ private static String tamperSignature(String token) {
+ int dot = token.indexOf('.');
+ String payloadB64 = token.substring(0, dot);
+ String sigB64 = token.substring(dot + 1);
+ byte[] sig = Base64.getDecoder().decode(sigB64);
+ sig[0] ^= 0x01;
+ return payloadB64 + "." + Base64.getEncoder().encodeToString(sig);
+ }
+}