feat(license): expand LicenseInfo with licenseId, tenantId, grace period

Required fields per spec §2.1. tenantId is non-blank; gracePeriodDays
defines the post-exp window during which limits keep applying.
isExpired() now honours the grace; isAfterRawExpiry() distinguishes
ACTIVE from GRACE for the state machine in Task 4.

Validator and gate use placeholder values temporarily; Task 3 wires
the validator to read the new fields, Task 5 rewrites the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 10:33:16 +02:00
parent 551a7f12b5
commit 2ebe4989bb
6 changed files with 113 additions and 15 deletions

View File

@@ -20,9 +20,9 @@ class LicenseGateTest {
@Test @Test
void loaded_exposesLimits() { void loaded_exposesLimits() {
LicenseGate gate = new LicenseGate(); LicenseGate gate = new LicenseGate();
LicenseInfo info = new LicenseInfo("MID", LicenseInfo info = new LicenseInfo(java.util.UUID.randomUUID(), "acme", "MID",
Map.of("max_agents", 10, "retention_days", 30), Map.of("max_agents", 10, "retention_days", 30),
Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS)); Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS), 0);
gate.load(info); gate.load(info);
assertThat(gate.getTier()).isEqualTo("MID"); assertThat(gate.getTier()).isEqualTo("MID");

View File

@@ -40,7 +40,7 @@ class LicenseValidatorTest {
LicenseInfo info = validator.validate(token); LicenseInfo info = validator.validate(token);
assertThat(info.tier()).isEqualTo("HIGH"); assertThat(info.label()).isEqualTo("HIGH");
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50); assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
assertThat(info.isExpired()).isFalse(); assertThat(info.isExpired()).isFalse();
} }

View File

@@ -9,16 +9,16 @@ public class LicenseGate {
private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); private static final Logger log = LoggerFactory.getLogger(LicenseGate.class);
private final AtomicReference<LicenseInfo> current = new AtomicReference<>(LicenseInfo.open()); private final AtomicReference<LicenseInfo> current = new AtomicReference<>(openSentinel());
public void load(LicenseInfo license) { public void load(LicenseInfo license) {
current.set(license); current.set(license);
log.info("License loaded: tier={}, limits={}, expires={}", log.info("License loaded: tier={}, limits={}, expires={}",
license.tier(), license.limits(), license.expiresAt()); license.label(), license.limits(), license.expiresAt());
} }
public String getTier() { public String getTier() {
return current.get().tier(); return current.get().label();
} }
public int getLimit(String key, int defaultValue) { public int getLimit(String key, int defaultValue) {
@@ -28,4 +28,10 @@ public class LicenseGate {
public LicenseInfo getCurrent() { public LicenseInfo getCurrent() {
return current.get(); return current.get();
} }
// TODO Task 5 — replace with proper null-sentinel ABSENT state
private static LicenseInfo openSentinel() {
return new LicenseInfo(java.util.UUID.randomUUID(), "open", "open",
java.util.Map.of(), java.time.Instant.EPOCH, java.time.Instant.MAX, 0);
}
} }

View File

@@ -2,22 +2,45 @@ package com.cameleer.server.core.license;
import java.time.Instant; import java.time.Instant;
import java.util.Map; 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( public record LicenseInfo(
String tier, UUID licenseId,
String tenantId,
String label,
Map<String, Integer> limits, Map<String, Integer> limits,
Instant issuedAt, Instant issuedAt,
Instant expiresAt 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() { public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt); 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) { public int getLimit(String key, int defaultValue) {
return limits.getOrDefault(key, defaultValue); return limits.getOrDefault(key, defaultValue);
} }
public static LicenseInfo open() {
return new LicenseInfo("open", Map.of(), Instant.now(), null);
}
} }

View File

@@ -8,7 +8,9 @@ import org.slf4j.LoggerFactory;
import java.security.*; import java.security.*;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class LicenseValidator { public class LicenseValidator {
@@ -65,7 +67,10 @@ public class LicenseValidator {
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now(); Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null; Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null;
LicenseInfo info = new LicenseInfo(tier, limits, issuedAt, expiresAt); // TODO Task 3 — replace with parsed licenseId/tenantId/gracePeriodDays/label
LicenseInfo info = new LicenseInfo(
java.util.UUID.randomUUID(), "placeholder", tier,
limits, issuedAt, expiresAt, 0);
if (info.isExpired()) { if (info.isExpired()) {
throw new IllegalArgumentException("License expired at " + expiresAt); throw new IllegalArgumentException("License expired at " + expiresAt);

View File

@@ -0,0 +1,64 @@
package com.cameleer.server.core.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
}
}