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