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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 → 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user