diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java index 45772531..f7afdc6e 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java @@ -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"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java index 00c698e7..cbb8c1a9 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java @@ -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(); } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java index 245c8be0..78118ecc 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java @@ -9,16 +9,16 @@ public class LicenseGate { private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); - private final AtomicReference current = new AtomicReference<>(LicenseInfo.open()); + private final AtomicReference 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); + } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java index ead03616..d18354c8 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java @@ -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 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); - } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java index 7436f941..d4f9210d 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java @@ -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); diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java new file mode 100644 index 00000000..22017e08 --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java @@ -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 + } +}