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:
hsiegeln
2026-04-26 10:48:56 +02:00
parent ddc0b686c3
commit 0499a54ebc
2 changed files with 85 additions and 24 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}