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:
hsiegeln
2026-04-26 12:26:39 +02:00
parent 6f658b6648
commit 2bad9c3e48
4 changed files with 223 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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