feat(license): cap-exceeded exception + state-aware message renderer
LicenseCapExceededException + @ControllerAdvice mapping to 403 with a body that includes state, limit, current, cap, and a per-state human message templated by LicenseMessageRenderer (covers ABSENT/ACTIVE/ GRACE/EXPIRED/INVALID with day counts and reason). Adds the forState() overload now (used by the /usage endpoint in Task 30) so both surfaces share identical phrasing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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<Map<String, Object>> handle(LicenseCapExceededException e) {
|
||||
var state = gate.getState();
|
||||
LicenseInfo info = gate.getCurrent();
|
||||
String reason = gate.getInvalidReason();
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user