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>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
<executions>
|
<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>
|
<execution>
|
||||||
<id>repackage-cli</id>
|
<id>repackage-cli</id>
|
||||||
<goals>
|
<goals>
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
<groupId>com.cameleer</groupId>
|
<groupId>com.cameleer</groupId>
|
||||||
<artifactId>cameleer-server-core</artifactId>
|
<artifactId>cameleer-server-core</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.cameleer</groupId>
|
||||||
|
<artifactId>cameleer-license-minter</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<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