diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java new file mode 100644 index 00000000..81563a35 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java @@ -0,0 +1,18 @@ +package com.cameleer.server.app.license; + +public class LicenseCapExceededException extends RuntimeException { + private final String limitKey; + private final long current; + private final long cap; + + public LicenseCapExceededException(String limitKey, long current, long cap) { + super("license cap reached: " + limitKey + " current=" + current + " cap=" + cap); + this.limitKey = limitKey; + this.current = current; + this.cap = cap; + } + + public String limitKey() { return limitKey; } + public long current() { return current; } + public long cap() { return cap; } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java new file mode 100644 index 00000000..d994d678 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java @@ -0,0 +1,36 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseInfo; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.LinkedHashMap; +import java.util.Map; + +@ControllerAdvice +public class LicenseExceptionAdvice { + + private final LicenseGate gate; + + public LicenseExceptionAdvice(LicenseGate gate) { + this.gate = gate; + } + + @ExceptionHandler(LicenseCapExceededException.class) + public ResponseEntity> handle(LicenseCapExceededException e) { + var state = gate.getState(); + LicenseInfo info = gate.getCurrent(); + String reason = gate.getInvalidReason(); + Map body = new LinkedHashMap<>(); + body.put("error", "license cap reached"); + body.put("limit", e.limitKey()); + body.put("current", e.current()); + body.put("cap", e.cap()); + body.put("state", state.name()); + body.put("message", LicenseMessageRenderer.forCap(state, info, e.limitKey(), e.current(), e.cap(), reason)); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java new file mode 100644 index 00000000..d1d9fbaa --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java @@ -0,0 +1,83 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseState; + +import java.time.Duration; +import java.time.Instant; + +public final class LicenseMessageRenderer { + + private LicenseMessageRenderer() {} + + public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap) { + return forCap(state, info, limit, current, cap, null); + } + + public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap, String invalidReason) { + switch (state) { + case ABSENT: + return "No license installed: default tier applies (cap = " + cap + " for " + limit + + "). Install a license to raise this."; + case ACTIVE: + return "License cap reached: " + limit + " = " + cap + ". Current usage is " + current + + ". Contact your vendor to raise the cap."; + case GRACE: { + long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); + long graceRemaining = info == null ? 0 + : Math.max(0, info.gracePeriodDays() - expiredDaysAgo); + return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period " + + "(ends in " + graceRemaining + " days). Cap unchanged at " + cap + + ". Renew before grace ends."; + } + case EXPIRED: { + long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); + return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier (cap = " + + cap + " for " + limit + "). Current usage is " + current + + ". Renew the license to lift the cap."; + } + case INVALID: + return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason) + + "): default tier applies (cap = " + cap + " for " + limit + "). Fix the license to raise this."; + default: + return "License cap reached: " + limit + " = " + cap; + } + } + + /** + * State-only message used by the /usage endpoint and metrics surfaces where no specific + * cap is being checked. Mirrors forCap() phrasing but omits limit/current/cap details. + */ + public static String forState(LicenseState state, LicenseInfo info) { + return forState(state, info, null); + } + + public static String forState(LicenseState state, LicenseInfo info, String invalidReason) { + switch (state) { + case ABSENT: + return "No license installed: default tier applies. Install a license to raise the caps."; + case ACTIVE: + return "License is active."; + case GRACE: { + long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); + long graceRemaining = info == null ? 0 + : Math.max(0, info.gracePeriodDays() - expiredDaysAgo); + return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period " + + "(ends in " + graceRemaining + " days). Renew before grace ends."; + } + case EXPIRED: { + long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); + return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier. Renew the license to lift the caps."; + } + case INVALID: + return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason) + + "): default tier applies. Fix the license to raise the caps."; + default: + return "License state: " + state.name(); + } + } + + private static long daysSince(Instant t) { + return Math.max(0, Duration.between(t, Instant.now()).toDays()); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java new file mode 100644 index 00000000..ec7961f9 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java @@ -0,0 +1,86 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseState; +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; + +class LicenseMessageRendererTest { + + @Test + void absent_message() { + var msg = LicenseMessageRenderer.forCap(LicenseState.ABSENT, null, "max_apps", 0, 3); + assertThat(msg).contains("No license").contains("max_apps").contains("3"); + } + + @Test + void active_message() { + LicenseInfo info = info(Instant.now().plusSeconds(86400 * 100), 0); + var msg = LicenseMessageRenderer.forCap(LicenseState.ACTIVE, info, "max_apps", 50, 50); + assertThat(msg).contains("cap reached").contains("50"); + } + + @Test + void grace_message_includesDayCount() { + LicenseInfo info = info(Instant.now().minusSeconds(86400L * 5), 30); + var msg = LicenseMessageRenderer.forCap(LicenseState.GRACE, info, "max_apps", 10, 10); + assertThat(msg).contains("expired").contains("5").contains("grace"); + } + + @Test + void expired_message_explainsRevert() { + LicenseInfo info = info(Instant.now().minusSeconds(86400L * 60), 30); + var msg = LicenseMessageRenderer.forCap(LicenseState.EXPIRED, info, "max_apps", 40, 3); + assertThat(msg).contains("expired").contains("default tier").contains("3"); + } + + @Test + void invalid_message_includesReason() { + var msg = LicenseMessageRenderer.forCap(LicenseState.INVALID, null, + "max_apps", 0, 3, "signature failed"); + assertThat(msg).contains("rejected").contains("signature failed"); + } + + @Test + void forState_absent() { + var msg = LicenseMessageRenderer.forState(LicenseState.ABSENT, null); + assertThat(msg).contains("No license").contains("default tier"); + } + + @Test + void forState_active() { + LicenseInfo info = info(Instant.now().plusSeconds(86400 * 100), 0); + var msg = LicenseMessageRenderer.forState(LicenseState.ACTIVE, info); + assertThat(msg).contains("License is active"); + } + + @Test + void forState_grace() { + LicenseInfo info = info(Instant.now().minusSeconds(86400L * 5), 30); + var msg = LicenseMessageRenderer.forState(LicenseState.GRACE, info); + assertThat(msg).contains("expired").contains("grace"); + } + + @Test + void forState_expired() { + LicenseInfo info = info(Instant.now().minusSeconds(86400L * 60), 30); + var msg = LicenseMessageRenderer.forState(LicenseState.EXPIRED, info); + assertThat(msg).contains("expired").contains("default tier"); + } + + @Test + void forState_invalid_includesReason() { + var msg = LicenseMessageRenderer.forState(LicenseState.INVALID, null, "signature failed"); + assertThat(msg).contains("rejected").contains("signature failed"); + } + + private LicenseInfo info(Instant exp, int graceDays) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(86400L * 365), exp, graceDays); + } +}