feat: add license service with Ed25519 JWT signing and verification
Generates tier-aware license tokens with features/limits per tier. Verifies signature and expiry. Audit logged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.config.JwtConfig;
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantStatus;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LicenseServiceTest {
|
||||
|
||||
@Mock
|
||||
private LicenseRepository licenseRepository;
|
||||
|
||||
@Mock
|
||||
private AuditService auditService;
|
||||
|
||||
private JwtConfig jwtConfig;
|
||||
private LicenseService licenseService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
jwtConfig = new JwtConfig();
|
||||
jwtConfig.init(); // generates ephemeral keys for testing
|
||||
licenseService = new LicenseService(licenseRepository, jwtConfig, auditService);
|
||||
}
|
||||
|
||||
private TenantEntity createTenant(Tier tier) {
|
||||
var tenant = new TenantEntity();
|
||||
tenant.setName("Test Tenant");
|
||||
tenant.setSlug("test");
|
||||
tenant.setTier(tier);
|
||||
tenant.setStatus(TenantStatus.ACTIVE);
|
||||
try {
|
||||
var idField = TenantEntity.class.getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
idField.set(tenant, UUID.randomUUID());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private static LicenseEntity withGeneratedId(LicenseEntity entity) {
|
||||
try {
|
||||
var idField = LicenseEntity.class.getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
idField.set(entity, UUID.randomUUID());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateLicense_producesValidSignedToken() {
|
||||
var tenant = createTenant(Tier.MID);
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
|
||||
|
||||
assertThat(license.getToken()).isNotBlank();
|
||||
assertThat(license.getToken().split("\\.")).hasSize(3);
|
||||
assertThat(license.getTier()).isEqualTo("MID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateLicense_setsCorrectFeaturesForTier() {
|
||||
var tenant = createTenant(Tier.HIGH);
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||
|
||||
assertThat(license.getFeatures()).containsEntry("debugger", true);
|
||||
assertThat(license.getFeatures()).containsEntry("replay", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateLicense_setsCorrectLimitsForTier() {
|
||||
var tenant = createTenant(Tier.LOW);
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||
|
||||
assertThat(license.getLimits()).containsEntry("max_agents", 3);
|
||||
assertThat(license.getLimits()).containsEntry("retention_days", 7);
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyLicenseToken_validTokenReturnsPayload() {
|
||||
var tenant = createTenant(Tier.MID);
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||
var payload = licenseService.verifyLicenseToken(license.getToken());
|
||||
|
||||
assertThat(payload).isPresent();
|
||||
assertThat(payload.get().get("tier")).isEqualTo("MID");
|
||||
assertThat(payload.get().get("tenant_id")).isEqualTo(tenant.getId().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyLicenseToken_tamperedTokenReturnsEmpty() {
|
||||
var tenant = createTenant(Tier.MID);
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||
String tampered = license.getToken() + "x";
|
||||
|
||||
var payload = licenseService.verifyLicenseToken(tampered);
|
||||
|
||||
assertThat(payload).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateLicense_logsAuditEvent() {
|
||||
var tenant = createTenant(Tier.LOW);
|
||||
var actorId = UUID.randomUUID();
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.LICENSE_GENERATE);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user