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
void loaded_exposesLimits() {
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),
Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS));
Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS), 0);
gate.load(info);
assertThat(gate.getTier()).isEqualTo("MID");

View File

@@ -40,7 +40,7 @@ class LicenseValidatorTest {
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.isExpired()).isFalse();
}

View File

@@ -9,16 +9,16 @@ public class LicenseGate {
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) {
current.set(license);
log.info("License loaded: tier={}, limits={}, expires={}",
license.tier(), license.limits(), license.expiresAt());
license.label(), license.limits(), license.expiresAt());
}
public String getTier() {
return current.get().tier();
return current.get().label();
}
public int getLimit(String key, int defaultValue) {
@@ -28,4 +28,10 @@ public class LicenseGate {
public LicenseInfo getCurrent() {
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.util.Map;
import java.util.Objects;
import java.util.UUID;
/** A parsed and signature-verified license. Construct via {@link LicenseValidator}. */
public record LicenseInfo(
String tier,
UUID licenseId,
String tenantId,
String label,
Map<String, Integer> limits,
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() {
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) {
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.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.*;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class LicenseValidator {
@@ -65,7 +67,10 @@ public class LicenseValidator {
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;
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()) {
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
}
}