refactor(license): extract cameleer-license-api module from server-core
Splits the pure license contract types (LicenseInfo, LicenseValidator, LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits) into a new cameleer-license-api module under package com.cameleer.license. Why: cameleer-license-minter previously depended on cameleer-server-core for these types, dragging cameleer-server-core + cameleer-common onto the classpath of every minter consumer (notably cameleer-saas). The SaaS management plane has no business carrying server-runtime types — it only needs the license contract to mint and verify tokens. After: cameleer-license-minter -> cameleer-license-api (no server internals) cameleer-server-core -> cameleer-license-api cameleer-saas -> cameleer-license-minter -> cameleer-license-api Verified: mvn -pl cameleer-license-minter dependency:tree shows the minter no longer pulls cameleer-server-core or cameleer-common. Full reactor verify (-DskipITs) green: 371 tests pass. LicenseGate stays in server-core (server-runtime state holder, not contract). Closes cameleer/cameleer-server#156 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
cameleer-license-api/pom.xml
Normal file
54
cameleer-license-api/pom.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.cameleer</groupId>
|
||||
<artifactId>cameleer-server-parent</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>cameleer-license-api</artifactId>
|
||||
<name>Cameleer License API</name>
|
||||
<description>Pure license contract types — LicenseInfo, LicenseValidator, LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits. Shared by server-core (validation/runtime gate) and cameleer-license-minter (vendor-side signing). Has no Spring or server-runtime dependencies so consumers like cameleer-saas can depend on the minter without inheriting server internals.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<!-- Plain library JAR — no repackage. -->
|
||||
<execution>
|
||||
<id>repackage</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class DefaultTierLimits {
|
||||
|
||||
public static final Map<String, Integer> DEFAULTS;
|
||||
|
||||
static {
|
||||
Map<String, Integer> m = new LinkedHashMap<>();
|
||||
m.put("max_environments", 1);
|
||||
m.put("max_apps", 3);
|
||||
m.put("max_agents", 5);
|
||||
m.put("max_users", 3);
|
||||
m.put("max_outbound_connections", 1);
|
||||
m.put("max_alert_rules", 2);
|
||||
m.put("max_total_cpu_millis", 2000);
|
||||
m.put("max_total_memory_mb", 2048);
|
||||
m.put("max_total_replicas", 5);
|
||||
m.put("max_execution_retention_days", 1);
|
||||
m.put("max_log_retention_days", 1);
|
||||
m.put("max_metric_retention_days", 1);
|
||||
m.put("max_jar_retention_count", 3);
|
||||
DEFAULTS = Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
private DefaultTierLimits() {}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A parsed and signature-verified license. Construct via {@link LicenseValidator}. */
|
||||
public record LicenseInfo(
|
||||
UUID licenseId,
|
||||
String tenantId,
|
||||
String label,
|
||||
Map<String, Integer> limits,
|
||||
Instant issuedAt,
|
||||
Instant expiresAt,
|
||||
int gracePeriodDays
|
||||
) {
|
||||
public LicenseInfo {
|
||||
Objects.requireNonNull(licenseId, "licenseId is required");
|
||||
Objects.requireNonNull(tenantId, "tenantId is required");
|
||||
Objects.requireNonNull(limits, "limits is required");
|
||||
Objects.requireNonNull(issuedAt, "issuedAt is required");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt is required");
|
||||
if (tenantId.isBlank()) {
|
||||
throw new IllegalArgumentException("tenantId must not be blank");
|
||||
}
|
||||
if (gracePeriodDays < 0) {
|
||||
throw new IllegalArgumentException("gracePeriodDays must be >= 0");
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff now > expiresAt + gracePeriodDays. */
|
||||
public boolean isExpired() {
|
||||
Instant deadline = expiresAt.plusSeconds((long) gracePeriodDays * 86400);
|
||||
return Instant.now().isAfter(deadline);
|
||||
}
|
||||
|
||||
/** True iff now > expiresAt (regardless of grace). Used by the state machine to distinguish ACTIVE from GRACE. */
|
||||
public boolean isAfterRawExpiry() {
|
||||
return Instant.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
public int getLimit(String key, int defaultValue) {
|
||||
return limits.getOrDefault(key, defaultValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record LicenseLimits(Map<String, Integer> values) {
|
||||
|
||||
public LicenseLimits {
|
||||
Objects.requireNonNull(values, "values");
|
||||
}
|
||||
|
||||
public static LicenseLimits defaultsOnly() {
|
||||
return new LicenseLimits(DefaultTierLimits.DEFAULTS);
|
||||
}
|
||||
|
||||
public static LicenseLimits mergeOverDefaults(Map<String, Integer> overrides) {
|
||||
Map<String, Integer> merged = new LinkedHashMap<>(DefaultTierLimits.DEFAULTS);
|
||||
if (overrides != null) merged.putAll(overrides);
|
||||
return new LicenseLimits(Collections.unmodifiableMap(merged));
|
||||
}
|
||||
|
||||
public int get(String key) {
|
||||
Integer v = values.get(key);
|
||||
if (v == null) {
|
||||
throw new IllegalArgumentException("Unknown license limit key: " + key);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
public boolean isDefaultSourced(String key, LicenseInfo license) {
|
||||
if (license == null) return true;
|
||||
return !license.limits().containsKey(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
public enum LicenseState {
|
||||
ABSENT,
|
||||
ACTIVE,
|
||||
GRACE,
|
||||
EXPIRED,
|
||||
INVALID
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
public final class LicenseStateMachine {
|
||||
|
||||
private LicenseStateMachine() {}
|
||||
|
||||
/**
|
||||
* @param license parsed license, or null if no license is loaded
|
||||
* @param invalidReason non-null if the last validation attempt failed
|
||||
*/
|
||||
public static LicenseState classify(LicenseInfo license, String invalidReason) {
|
||||
if (invalidReason != null) {
|
||||
return LicenseState.INVALID;
|
||||
}
|
||||
if (license == null) {
|
||||
return LicenseState.ABSENT;
|
||||
}
|
||||
if (!license.isAfterRawExpiry()) {
|
||||
return LicenseState.ACTIVE;
|
||||
}
|
||||
if (!license.isExpired()) {
|
||||
return LicenseState.GRACE;
|
||||
}
|
||||
return LicenseState.EXPIRED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public class LicenseValidator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class);
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final PublicKey publicKey;
|
||||
private final String expectedTenantId;
|
||||
|
||||
public LicenseValidator(String publicKeyBase64, String expectedTenantId) {
|
||||
Objects.requireNonNull(expectedTenantId, "expectedTenantId is required");
|
||||
if (expectedTenantId.isBlank()) {
|
||||
throw new IllegalArgumentException("expectedTenantId must not be blank");
|
||||
}
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
||||
KeyFactory kf = KeyFactory.getInstance("Ed25519");
|
||||
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to load license public key", e);
|
||||
}
|
||||
this.expectedTenantId = expectedTenantId;
|
||||
}
|
||||
|
||||
public LicenseInfo validate(String token) {
|
||||
String[] parts = token.split("\\.", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new IllegalArgumentException("Invalid license token format: expected payload.signature");
|
||||
}
|
||||
|
||||
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
|
||||
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
|
||||
|
||||
try {
|
||||
Signature verifier = Signature.getInstance("Ed25519");
|
||||
verifier.initVerify(publicKey);
|
||||
verifier.update(payloadBytes);
|
||||
if (!verifier.verify(signatureBytes)) {
|
||||
throw new SecurityException("License signature verification failed");
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new SecurityException("License signature verification failed", e);
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(payloadBytes);
|
||||
|
||||
String licenseIdStr = textOrThrow(root, "licenseId");
|
||||
UUID licenseId;
|
||||
try {
|
||||
licenseId = UUID.fromString(licenseIdStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr);
|
||||
}
|
||||
|
||||
String tenantId = textOrThrow(root, "tenantId");
|
||||
if (!tenantId.equals(expectedTenantId)) {
|
||||
throw new IllegalArgumentException(
|
||||
"License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'");
|
||||
}
|
||||
|
||||
String label = root.has("label") ? root.get("label").asText() : null;
|
||||
|
||||
Map<String, Integer> limits = new HashMap<>();
|
||||
if (root.has("limits")) {
|
||||
root.get("limits").fields().forEachRemaining(entry ->
|
||||
limits.put(entry.getKey(), entry.getValue().asInt()));
|
||||
}
|
||||
|
||||
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
|
||||
if (!root.has("exp")) {
|
||||
throw new IllegalArgumentException("exp is required");
|
||||
}
|
||||
Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong());
|
||||
int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0;
|
||||
|
||||
LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays);
|
||||
|
||||
if (info.isExpired()) {
|
||||
throw new IllegalArgumentException("License expired at " + expiresAt
|
||||
+ " (grace period " + gracePeriodDays + " days)");
|
||||
}
|
||||
|
||||
return info;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Failed to parse license payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String textOrThrow(JsonNode root, String field) {
|
||||
if (!root.has(field) || root.get(field).asText().isBlank()) {
|
||||
throw new IllegalArgumentException(field + " is required");
|
||||
}
|
||||
return root.get(field).asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DefaultTierLimitsTest {
|
||||
|
||||
@Test
|
||||
void allDocumentedKeysHaveDefaults() {
|
||||
for (String key : new String[]{
|
||||
"max_environments", "max_apps", "max_agents", "max_users",
|
||||
"max_outbound_connections", "max_alert_rules",
|
||||
"max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas",
|
||||
"max_execution_retention_days", "max_log_retention_days",
|
||||
"max_metric_retention_days", "max_jar_retention_count"
|
||||
}) {
|
||||
assertThat(DefaultTierLimits.DEFAULTS).containsKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void specificValues() {
|
||||
assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1);
|
||||
assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3);
|
||||
assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5);
|
||||
assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000);
|
||||
assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LicenseInfoTest {
|
||||
|
||||
@Test
|
||||
void requiresLicenseId() {
|
||||
assertThatThrownBy(() -> new LicenseInfo(
|
||||
null, "acme", "label",
|
||||
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("licenseId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresTenantId() {
|
||||
assertThatThrownBy(() -> new LicenseInfo(
|
||||
UUID.randomUUID(), null, "label",
|
||||
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
|
||||
.isInstanceOf(NullPointerException.class)
|
||||
.hasMessageContaining("tenantId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyTenantIdRejected() {
|
||||
assertThatThrownBy(() -> new LicenseInfo(
|
||||
UUID.randomUUID(), " ", "label",
|
||||
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLimit_returnsDefaultWhenMissing() {
|
||||
LicenseInfo info = new LicenseInfo(
|
||||
UUID.randomUUID(), "acme", null,
|
||||
Map.of("max_apps", 5), Instant.now(),
|
||||
Instant.now().plusSeconds(60), 0);
|
||||
assertThat(info.getLimit("max_apps", 99)).isEqualTo(5);
|
||||
assertThat(info.getLimit("max_users", 99)).isEqualTo(99);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isExpired_honoursGracePeriod() {
|
||||
Instant pastByTen = Instant.now().minusSeconds(10 * 86400);
|
||||
LicenseInfo withinGrace = new LicenseInfo(
|
||||
UUID.randomUUID(), "acme", null, Map.of(),
|
||||
Instant.now().minusSeconds(40 * 86400),
|
||||
pastByTen, 30);
|
||||
assertThat(withinGrace.isExpired()).isFalse(); // 10 days into a 30-day grace
|
||||
LicenseInfo pastGrace = new LicenseInfo(
|
||||
UUID.randomUUID(), "acme", null, Map.of(),
|
||||
Instant.now().minusSeconds(40 * 86400),
|
||||
pastByTen, 5);
|
||||
assertThat(pastGrace.isExpired()).isTrue(); // 10 days is past the 5-day grace
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LicenseStateMachineTest {
|
||||
|
||||
@Test
|
||||
void noLicense_isAbsent() {
|
||||
assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidReason_isInvalid() {
|
||||
assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeBeforeExp() {
|
||||
LicenseInfo info = info(Instant.now().plusSeconds(86400), 0);
|
||||
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void graceWithinGracePeriod() {
|
||||
LicenseInfo info = info(Instant.now().minusSeconds(86400), 7);
|
||||
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredAfterGrace() {
|
||||
LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7);
|
||||
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredImmediatelyWithZeroGrace() {
|
||||
LicenseInfo info = info(Instant.now().minusSeconds(60), 0);
|
||||
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidWinsOverPresentLicense() {
|
||||
LicenseInfo info = info(Instant.now().plusSeconds(86400), 0);
|
||||
assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID);
|
||||
}
|
||||
|
||||
private LicenseInfo info(Instant exp, int graceDays) {
|
||||
return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(),
|
||||
Instant.now().minusSeconds(3600), exp, graceDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.cameleer.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LicenseValidatorTest {
|
||||
|
||||
private KeyPair generateKeyPair() throws Exception {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
|
||||
return kpg.generateKeyPair();
|
||||
}
|
||||
|
||||
private String sign(PrivateKey key, String payload) throws Exception {
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(key);
|
||||
signer.update(payload.getBytes());
|
||||
return Base64.getEncoder().encodeToString(signer.sign());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_validLicense_returnsLicenseInfo() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
|
||||
|
||||
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
|
||||
String payload = """
|
||||
{"licenseId":"%s","tenantId":"acme","label":"HIGH","tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d,"gracePeriodDays":7}
|
||||
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
|
||||
String signature = sign(kp.getPrivate(), payload);
|
||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
||||
|
||||
LicenseInfo info = validator.validate(token);
|
||||
|
||||
assertThat(info.label()).isEqualTo("HIGH");
|
||||
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
|
||||
assertThat(info.isExpired()).isFalse();
|
||||
assertThat(info.tenantId()).isEqualTo("acme");
|
||||
assertThat(info.gracePeriodDays()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_expiredLicense_throwsException() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
|
||||
|
||||
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
|
||||
String payload = """
|
||||
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":%d,"exp":%d}
|
||||
""".formatted(UUID.randomUUID(), past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
|
||||
String signature = sign(kp.getPrivate(), payload);
|
||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(token))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("expired");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_tamperedPayload_throwsException() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
|
||||
|
||||
String payload = """
|
||||
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":0,"exp":9999999999}
|
||||
""".formatted(UUID.randomUUID()).trim();
|
||||
String signature = sign(kp.getPrivate(), payload);
|
||||
|
||||
// Tamper with payload
|
||||
String tampered = payload.replace("LOW", "BUSINESS");
|
||||
String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature;
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(token))
|
||||
.isInstanceOf(SecurityException.class)
|
||||
.hasMessageContaining("signature");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_missingTenantId_throws() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
|
||||
|
||||
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
|
||||
String payload = """
|
||||
{"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d}
|
||||
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
|
||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(token))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("tenantId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_tenantIdMismatch_throws() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta");
|
||||
|
||||
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
|
||||
String payload = """
|
||||
{"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d}
|
||||
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
|
||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(token))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("tenantId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_missingLicenseId_throws() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
|
||||
|
||||
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
|
||||
String payload = """
|
||||
{"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d}
|
||||
""".formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
|
||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(token))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("licenseId");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user