diff --git a/cameleer-license-minter/pom.xml b/cameleer-license-minter/pom.xml index ee6c4798..354c8ec2 100644 --- a/cameleer-license-minter/pom.xml +++ b/cameleer-license-minter/pom.xml @@ -46,6 +46,12 @@ org.springframework.boot spring-boot-maven-plugin + + + repackage + none + repackage-cli diff --git a/cameleer-server-app/pom.xml b/cameleer-server-app/pom.xml index d80a01e8..a40820f5 100644 --- a/cameleer-server-app/pom.xml +++ b/cameleer-server-app/pom.xml @@ -19,6 +19,12 @@ com.cameleer cameleer-server-core + + com.cameleer + cameleer-license-minter + ${project.version} + test + 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: + *

    + *
  1. Gate transitions ABSENT → ACTIVE.
  2. + *
  3. Row persists in the {@code license} PostgreSQL table.
  4. + *
  5. After {@code gate.clear()}, {@code revalidate()} restores ACTIVE from the persisted token.
  6. + *
  7. A token with a tampered signature is rejected (HTTP 400) and audited as FAILURE + * under {@code AuditCategory.LICENSE} without mutating the gate.
  8. + *
+ * + *

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); + } +}