From 0499a54ebc063aac8dde9c0c730b7aedd5142b18 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:48:56 +0200 Subject: [PATCH] feat(license): rewrite LicenseGate around state + effective limits LicenseGate now exposes getState() (delegates to LicenseStateMachine), getEffectiveLimits() (merged over DefaultTierLimits in ACTIVE/GRACE, defaults-only in ABSENT/EXPIRED/INVALID), markInvalid(reason), and clear(). Internal snapshot is an immutable record-like class swapped atomically so concurrent reads see a consistent license+reason pair. Removes the transient openSentinel() and getTier() introduced by earlier tasks (no production consumers). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/core/license/LicenseGateTest.java | 54 ++++++++++++++---- .../server/core/license/LicenseGate.java | 55 ++++++++++++++----- 2 files changed, 85 insertions(+), 24 deletions(-) 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 f7afdc6e..47804c4e 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 @@ -3,30 +3,62 @@ package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Map; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class LicenseGateTest { @Test - void noLicense_returnsOpenTier() { + void absent_byDefault() { LicenseGate gate = new LicenseGate(); - assertThat(gate.getTier()).isEqualTo("open"); - assertThat(gate.getLimit("max_apps", 99)).isEqualTo(99); + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getCurrent()).isNull(); + assertThat(gate.getInvalidReason()).isNull(); } @Test - void loaded_exposesLimits() { + void load_setsActiveAndMergesLimits() { LicenseGate gate = new LicenseGate(); - 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), 0); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", "label", + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); gate.load(info); - assertThat(gate.getTier()).isEqualTo("MID"); - assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10); - assertThat(gate.getLimit("missing", 7)).isEqualTo(7); + assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE); + assertThat(gate.getEffectiveLimits().get("max_apps")).isEqualTo(50); + assertThat(gate.getEffectiveLimits().get("max_users")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_users")); + } + + @Test + void markInvalid_overridesActive() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of("max_apps", 50), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + + gate.markInvalid("signature failed"); + assertThat(gate.getState()).isEqualTo(LicenseState.INVALID); + assertThat(gate.getEffectiveLimits().get("max_apps")) + .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); + assertThat(gate.getInvalidReason()).isEqualTo("signature failed"); + } + + @Test + void clear_returnsToAbsent() { + LicenseGate gate = new LicenseGate(); + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(info); + gate.clear(); + + assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); + assertThat(gate.getCurrent()).isNull(); } } 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 78118ecc..be6b2af3 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,29 +9,58 @@ public class LicenseGate { private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); - private final AtomicReference current = new AtomicReference<>(openSentinel()); + private static final class Snapshot { + final LicenseInfo license; // null when ABSENT or INVALID + final String invalidReason; // null unless INVALID + Snapshot(LicenseInfo l, String r) { this.license = l; this.invalidReason = r; } + } + + private final AtomicReference snap = new AtomicReference<>(new Snapshot(null, null)); public void load(LicenseInfo license) { - current.set(license); - log.info("License loaded: tier={}, limits={}, expires={}", - license.label(), license.limits(), license.expiresAt()); + snap.set(new Snapshot(license, null)); + log.info("License loaded: licenseId={}, tenantId={}, exp={}, gracePeriodDays={}", + license.licenseId(), license.tenantId(), license.expiresAt(), license.gracePeriodDays()); } - public String getTier() { - return current.get().label(); + public void markInvalid(String reason) { + snap.set(new Snapshot(null, reason)); + log.error("License marked INVALID: {}", reason); } - public int getLimit(String key, int defaultValue) { - return current.get().getLimit(key, defaultValue); + public void clear() { + snap.set(new Snapshot(null, null)); + log.info("License cleared"); } public LicenseInfo getCurrent() { - return current.get(); + return snap.get().license; } - // 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); + public String getInvalidReason() { + return snap.get().invalidReason; + } + + public LicenseState getState() { + Snapshot s = snap.get(); + return LicenseStateMachine.classify(s.license, s.invalidReason); + } + + /** Effective limits = defaults UNION license.limits, except in EXPIRED/ABSENT/INVALID where defaults win. */ + public LicenseLimits getEffectiveLimits() { + Snapshot s = snap.get(); + LicenseState state = LicenseStateMachine.classify(s.license, s.invalidReason); + if (state == LicenseState.ACTIVE || state == LicenseState.GRACE) { + return LicenseLimits.mergeOverDefaults(s.license.limits()); + } + return LicenseLimits.defaultsOnly(); + } + + public int getLimit(String key, int defaultValue) { + try { + return getEffectiveLimits().get(key); + } catch (IllegalArgumentException e) { + return defaultValue; + } } }