test(license): LicenseLifecycleIT — install/persist/revalidate/reject

End-to-end IT covering the full lifecycle: mint a token via
cameleer-license-minter (test-scope), POST it via /api/v1/admin/license,
verify state=ACTIVE, clear gate, revalidate from PG, verify state restored.
Plus: tampered signature -> 400 + LICENSE/FAILURE audit row, gate not
mutated to ACTIVE.

Adds cameleer-license-minter as a test-scope dep on cameleer-server-app
(verified absent from runtime/compile classpaths). Also disables the
default spring-boot:repackage execution on the minter pom so the main
artifact stays as a plain library JAR consumable as a Maven dependency
(the cli classifier still produces the executable jar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 15:56:01 +02:00
parent 885f2be16b
commit 1a307da6b2
3 changed files with 235 additions and 0 deletions

View File

@@ -46,6 +46,12 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<!-- Disable the default repackage so the main artifact stays as a plain library
JAR consumable as a Maven test-scope dependency by cameleer-server-app. -->
<execution>
<id>repackage</id>
<phase>none</phase>
</execution>
<execution>
<id>repackage-cli</id>
<goals>

View File

@@ -19,6 +19,12 @@
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId>
</dependency>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-minter</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View File

@@ -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.
*
* <p>Mints a real Ed25519-signed token via {@code cameleer-license-minter} (test scope), POSTs
* it through {@code /api/v1/admin/license}, then verifies:
* <ol>
* <li>Gate transitions ABSENT &rarr; ACTIVE.</li>
* <li>Row persists in the {@code license} PostgreSQL table.</li>
* <li>After {@code gate.clear()}, {@code revalidate()} restores ACTIVE from the persisted token.</li>
* <li>A token with a tampered signature is rejected (HTTP 400) and audited as FAILURE
* under {@code AuditCategory.LICENSE} without mutating the gate.</li>
* </ol>
*
* <p>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.</p>
*/
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<String, Object> body = new LinkedHashMap<>();
body.put("token", token);
ResponseEntity<String> 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<String, Object> body = new LinkedHashMap<>();
body.put("token", tampered);
ResponseEntity<String> 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);
}
}