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) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,29 +9,58 @@ public class LicenseGate {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseGate.class);
|
||||
|
||||
private final AtomicReference<LicenseInfo> 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<Snapshot> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user