diff --git a/backend/src/main/java/org/cryptomator/hub/Main.java b/backend/src/main/java/org/cryptomator/hub/Main.java new file mode 100644 index 000000000..51b3cb625 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/Main.java @@ -0,0 +1,30 @@ +package org.cryptomator.hub; + +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.QuarkusApplication; +import io.quarkus.runtime.annotations.QuarkusMain; +import jakarta.inject.Inject; +import org.cryptomator.hub.license.LicenseHolder; +import org.jboss.logging.Logger; + +@QuarkusMain +public class Main implements QuarkusApplication { + + private static final Logger LOG = Logger.getLogger(Main.class); + + @Inject + LicenseHolder license; + + @Override + public int run(String... args) throws Exception { + try { + license.ensureLicenseExists(); + } catch (RuntimeException e) { + LOG.error("Failed to validate license, shutting down...", e); + return 1; + } + Quarkus.waitForExit(); + return 0; + } + +} diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java index 01b88fdc0..ee805b09b 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java @@ -64,7 +64,7 @@ public class AuditLogResource { @APIResponse(responseCode = "402", description = "Community license used or license expired") @APIResponse(responseCode = "403", description = "requesting user does not have admin role") public List getAllEvents(@QueryParam("startDate") Instant startDate, @QueryParam("endDate") Instant endDate, @QueryParam("type") List type, @QueryParam("paginationId") Long paginationId, @QueryParam("order") @DefaultValue("desc") String order, @QueryParam("pageSize") @DefaultValue("20") int pageSize) { - if (!license.isSet() || license.isExpired()) { + if (!license.isSet() || license.isExpired()) { // TODO change to license.getClaim("auditLog") != null throw new PaymentRequiredException("Community license used or license expired"); } diff --git a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java index b849deefa..6efaf4b83 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java @@ -46,12 +46,8 @@ public class BillingResource { public BillingDto get() { int usedSeats = (int) effectiveVaultAccessRepo.countSeatOccupyingUsers(); boolean isManaged = licenseHolder.isManagedInstance(); - return Optional.ofNullable(licenseHolder.get()) - .map(jwt -> BillingDto.fromDecodedJwt(jwt, usedSeats, isManaged)) - .orElseGet(() -> { - var hubId = settingsRepo.get().getHubId(); - return BillingDto.create(hubId, (int) licenseHolder.getSeats(), usedSeats, isManaged); - }); + var licenseToken = licenseHolder.get(); + return BillingDto.fromDecodedJwt(licenseToken, usedSeats, isManaged); } @PUT @@ -75,10 +71,6 @@ public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("has @JsonProperty("licensedSeats") Integer licensedSeats, @JsonProperty("usedSeats") Integer usedSeats, @JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt, @JsonProperty("managedInstance") Boolean managedInstance) { - public static BillingDto create(String hubId, int noLicenseSeatCount, int usedSeats, boolean isManaged) { - return new BillingDto(hubId, false, null, noLicenseSeatCount, usedSeats, null, null, isManaged); - } - public static BillingDto fromDecodedJwt(DecodedJWT jwt, int usedSeats, boolean isManaged) { var id = jwt.getId(); var email = jwt.getSubject(); diff --git a/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java b/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java index dfc1efa0c..1300bf036 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java @@ -1,6 +1,5 @@ package org.cryptomator.hub.api; -import com.auth0.jwt.interfaces.DecodedJWT; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; @@ -14,7 +13,6 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import java.time.Instant; -import java.util.Optional; @Path("/license") public class LicenseResource { @@ -42,7 +40,7 @@ public record LicenseUserInfoDto(@JsonProperty("licensedSeats") Integer licensed public static LicenseUserInfoDto create(LicenseHolder licenseHolder, int usedSeats) { var licensedSeats = (int) licenseHolder.getSeats(); - var expiresAt = Optional.ofNullable(licenseHolder.get()).map(DecodedJWT::getExpiresAtAsInstant).orElse(null); + var expiresAt = licenseHolder.get().getExpiresAtAsInstant(); return new LicenseUserInfoDto(licensedSeats, usedSeats, expiresAt); } diff --git a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java index 2fa97b28b..b12ff56d9 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -286,7 +286,7 @@ public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("dev var access = legacyAccessTokenRepo.unlock(vaultId, deviceId, jwt.getSubject()); eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId); var subscriptionStateHeaderName = "Hub-Subscription-State"; - var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter + var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter // FIXME: we need to refactor this header return Response.ok(access.getJwe()).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build(); } catch (NoResultException e) { eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED, ipAddress, deviceId); @@ -328,7 +328,7 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr if (access != null) { eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId); var subscriptionStateHeaderName = "Hub-Subscription-State"; - var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter + var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter // FIXME: we need to refactor this header return Response.ok(access.getVaultKey(), MediaType.TEXT_PLAIN_TYPE).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build(); } else if (vaultRepo.findById(vaultId) == null) { throw new NotFoundException("No such vault."); diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java index 864077f66..addc7b4c9 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java @@ -3,12 +3,13 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; +import com.cronutils.utils.Preconditions; import io.quarkus.scheduler.Scheduled; import io.quarkus.scheduler.ScheduledExecution; -import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotNull; import org.cryptomator.hub.entities.Settings; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; @@ -26,8 +27,7 @@ @ApplicationScoped public class LicenseHolder { - private static final int SELFHOSTED_NOLICENSE_SEATS = 5; - private static final int MANAGED_NOLICENSE_SEATS = 0; + private static final Logger LOG = Logger.getLogger(LicenseHolder.class); @Inject @ConfigProperty(name = "hub.managed-instance", defaultValue = "false") @@ -46,49 +46,65 @@ public class LicenseHolder { @Inject RandomMinuteSleeper randomMinuteSleeper; + @Inject Settings.Repository settingsRepo; - private static final Logger LOG = Logger.getLogger(LicenseHolder.class); private DecodedJWT license; /** - * Loads the license from the database or from init props, if present + * Makes sure a valid (but possibly expired) license exists. + *

+ * Called during {@link org.cryptomator.hub.Main application startup}. + * + * @throws JWTVerificationException if the license is invalid */ - @PostConstruct - void init() { + @Transactional + public void ensureLicenseExists() throws JWTVerificationException{ var settings = settingsRepo.get(); if (settings.getLicenseKey() != null && settings.getHubId() != null) { - validateOrResetExistingLicense(settings); + validateExistingLicense(settings); } else if (initialLicenseToken.isPresent() && initialId.isPresent()) { validateAndApplyInitLicense(settings, initialLicenseToken.get(), initialId.get()); + } else { + requestAnonTrialLicense(); } } - @Transactional - void validateOrResetExistingLicense(Settings settings) { + @Transactional(Transactional.TxType.MANDATORY) + void validateExistingLicense(Settings settings) throws JWTVerificationException { try { this.license = licenseValidator.validate(settings.getLicenseKey(), settings.getHubId()); + LOG.info("Verified existing license."); } catch (JWTVerificationException e) { LOG.warn("License in database is invalid or does not match hubId", e); LOG.warn("Deleting license entry. Please add the license over the REST API again."); - settings.setLicenseKey(null); - settingsRepo.persistAndFlush(settings); + throw e; } } - @Transactional - void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) { + @Transactional(Transactional.TxType.MANDATORY) + void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) throws JWTVerificationException { try { this.license = licenseValidator.validate(initialLicenseToken, initialHubId); settings.setLicenseKey(initialLicenseToken); settings.setHubId(initialHubId); settingsRepo.persistAndFlush(settings); + LOG.info("Successfully imported license from property hub.initial-license."); } catch (JWTVerificationException e) { LOG.warn("Provided initial license is invalid or does not match inital hubId.", e); + throw e; } } + + @Transactional(Transactional.TxType.MANDATORY) + void requestAnonTrialLicense() { + LOG.info("No license found. Requesting trial license..."); + // TODO + throw new UnsupportedOperationException("Not yet implemented"); + } + /** * Parses, verifies and persists the given token as the license in the database. * @@ -106,7 +122,7 @@ public void set(String token) throws JWTVerificationException { /** * Attempts to refresh the Hub licence every day between 01:00:00 and 02:00:00 AM UTC if claim refreshURL is present. */ - @Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP, skipExecutionIf = LicenseHolder.LicenseRefreshSkipper.class) + @Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP) void refreshLicense() throws InterruptedException { randomMinuteSleeper.sleep(); // add random sleep between [0,59]min to reduce infrastructure load var refreshUrlClaim = get().getClaim("refreshUrl"); @@ -115,12 +131,12 @@ void refreshLicense() throws InterruptedException { var refreshUrl = URI.create(refreshUrlClaim.asString()); var refreshedLicense = requestLicenseRefresh(refreshUrl, get().getToken()); set(refreshedLicense); - } catch (LicenseRefreshFailedException lrfe) { - LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, lrfe.statusCode); + } catch (LicenseRefreshFailedException e) { + LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, e.statusCode); } catch (IllegalArgumentException | IOException e) { LOG.error("Failed to refresh license token", e); - } catch (JWTVerificationException jve) { - LOG.error("Failed to refresh license token. Refreshed token is invalid.", jve); + } catch (JWTVerificationException e) { + LOG.error("Failed to refresh license token. Refreshed token is invalid.", e); } } } @@ -144,8 +160,9 @@ String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws Interru } } + @NotNull public DecodedJWT get() { - return license; + return Preconditions.checkNotNull(license); } /** @@ -153,6 +170,7 @@ public DecodedJWT get() { * * @return {@code true}, if the license _is not null_. Otherwise false. */ + @Deprecated // FIXME remove this method! public boolean isSet() { return license != null; } @@ -160,33 +178,19 @@ public boolean isSet() { /** * Checks if the license is expired. * - * @return {@code true}, if the license _is not nul and expired_. Otherwise false. + * @return {@code true}, if the license expired, {@code false} otherwise. */ public boolean isExpired() { - return Optional.ofNullable(license) // - .map(l -> l.getExpiresAt().toInstant().isBefore(Instant.now())) // - .orElse(false); + return Preconditions.checkNotNull(license).getExpiresAt().toInstant().isBefore(Instant.now()); } /** * Gets the number of seats in the license * - * @return Number of seats of the license, if license is not null. Otherwise {@value SELFHOSTED_NOLICENSE_SEATS}. + * @return Number of seats of the license */ public long getSeats() { - return Optional.ofNullable(license) // - .map(l -> l.getClaim("seats")) // - .map(Claim::asLong) // - .orElseGet(this::seatsOnNotExisingLicense); - } - - //visible for testing - public long seatsOnNotExisingLicense() { - if (!managedInstance) { - return SELFHOSTED_NOLICENSE_SEATS; - } else { - return MANAGED_NOLICENSE_SEATS; - } + return Preconditions.checkNotNull(license).getClaim("seats").asLong(); } public boolean isManagedInstance() { @@ -203,15 +207,4 @@ static class LicenseRefreshFailedException extends RuntimeException { } } - @ApplicationScoped - public static class LicenseRefreshSkipper implements Scheduled.SkipPredicate { - - @Inject - LicenseHolder licenseHolder; - - @Override - public boolean test(ScheduledExecution execution) { - return licenseHolder.license == null; - } - } } diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java index 1813251c1..65348d64b 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseValidator.java @@ -55,6 +55,13 @@ private static ECPublicKey decodePublicKey(String pemEncodedPublicKey) { } } + /** + * Validates the token signature and whether it matches the Hub ID. It does NOT check the expiration date, though. + * @param token JWT + * @param expectedHubId the ID of this Hub instance + * @return the verified token. + * @throws JWTVerificationException If validation fails. + */ public DecodedJWT validate(String token, String expectedHubId) throws JWTVerificationException { var jwt = verifier.verify(token); if (!jwt.getId().equals(expectedHubId)) { diff --git a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java index 4a75e9a4b..555750567 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java @@ -30,7 +30,7 @@ public static void beforeAll() { @BeforeEach public void beforeEach() { - Mockito.doReturn(true).when(licenseHolder).isSet(); + Mockito.doReturn(true).when(licenseHolder).isSet(); // TODO Mockito.doReturn(false).when(licenseHolder).isExpired(); } diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java index da47f9ffe..df1cb75ee 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java @@ -52,22 +52,7 @@ public class AsAdmin { private static final String MALFORMED_TOKEN = "hello world"; @Test - @DisplayName("GET /billing returns 200 with empty license self-hosted") - public void testGetEmptySelfHosted() { - Mockito.when(licenseHolder.get()).thenReturn(null); - Mockito.when(licenseHolder.getSeats()).thenReturn(5L); - when().get("/billing") - .then().statusCode(200) - .body("hubId", is("42")) - .body("hasLicense", is(false)) - .body("email", nullValue()) - .body("licensedSeats", is(5)) //community license - .body("usedSeats", is(2)) //depends on the flyway test data migration - .body("issuedAt", nullValue()) - .body("expiresAt", nullValue()); - } - - @Test + @Order(2) @DisplayName("PUT /billing/token returns 204 for initial token") public void testPutInitialToken() { given().contentType(ContentType.TEXT).body(INITIAL_TOKEN) diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java index 53d3b8483..d0544c6f0 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceIT.java @@ -1,7 +1,5 @@ package org.cryptomator.hub.api; -import io.agroal.api.AgroalDataSource; -import io.quarkus.arc.Arc; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.TestProfile; @@ -12,15 +10,15 @@ import jakarta.inject.Inject; import org.cryptomator.hub.license.LicenseHolder; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.sql.SQLException; +import java.time.Instant; import java.util.Map; import static io.restassured.RestAssured.when; import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; @QuarkusTest @DisplayName("Resource /billing managed instance") @@ -32,40 +30,41 @@ public class BillingResourceManagedInstanceIT { @Inject - AgroalDataSource dataSource; + LicenseHolder licenseHolder; @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); - Arc.container().instance(LicenseHolder.class).destroy(); + } + + @BeforeEach + public void setup() { + licenseHolder.ensureLicenseExists(); } public static class ManagedInstanceTestProfile implements QuarkusTestProfile { @Override public Map getConfigOverrides() { - return Map.of("hub.managed-instance", "true"); + return Map.of( + "hub.managed-instance", "true", + "hub.initial-id", "42", + "hub.initial-license", "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AKyoZ0WQ8xhs8vPymWPHCsc6ch6pZpfxBcrF5QjVLSQVnYz2s5QF3nnkwn4AGR7V14TuhkJMZLUZxMdQAYLyL95sAV2Fu0E4-e1v3IVKlNKtze89eqYvEs6Ak9jWjtecOgPWNWjz2itI4MfJBDmbFtTnehOtqRqUdsDoC9NFik2C7tHm" + ); } } @Test - @DisplayName("GET /billing returns 401 with empty license managed instance") - public void testGetEmptyManagedInstance() throws SQLException { - try (var c = dataSource.getConnection(); var s = c.createStatement()) { - s.execute(""" - UPDATE "settings" - SET "hub_id" = '42', "license_key" = null - WHERE "id" = 0; - """); - } - + @DisplayName("GET /billing returns 200 billing data with managedInstance=true") + public void testGetInitial() { when().get("/billing") .then().statusCode(200) .body("hubId", is("42")) - .body("hasLicense", is(false)) - .body("email", nullValue()) - .body("licensedSeats", is(0)) + .body("hasLicense", is(true)) + .body("email", is("hub@cryptomator.org")) + .body("licensedSeats", is(5)) .body("usedSeats", is(2)) - .body("issuedAt", nullValue()) - .body("expiresAt", nullValue()); + .body("issuedAt", is("2022-03-23T15:29:20Z")) + .body("expiresAt", is("9999-12-31T00:00:00Z")) + .body("managedInstance", is(true)); } } \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java index 4778b8185..e02d805c3 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java @@ -293,7 +293,7 @@ public void test999Gets998() { @TestSecurity(user = "Admin", roles = {"admin"}) @DisplayName("As admin, GET /auditlog contains signature events") public void testGetAuditLogEntries() { - Mockito.doReturn(true).when(licenseHolder).isSet(); + Mockito.doReturn(true).when(licenseHolder).isSet(); // TODO Mockito.doReturn(false).when(licenseHolder).isExpired(); given().param("startDate", DateTimeFormatter.ISO_INSTANT.format(testStart)) diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java index 60f5e51b6..8d652aeb5 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java @@ -2,8 +2,10 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; import io.agroal.api.AgroalDataSource; import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.quarkus.test.security.oidc.Claim; @@ -14,6 +16,7 @@ import jakarta.validation.Validator; import org.cryptomator.hub.entities.EffectiveVaultAccess; import org.cryptomator.hub.entities.Vault; +import org.cryptomator.hub.license.LicenseHolder; import org.cryptomator.hub.rollback.DBRollbackAfter; import org.cryptomator.hub.rollback.DBRollbackBefore; import org.flywaydb.core.Flyway; @@ -23,6 +26,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; @@ -32,6 +36,7 @@ import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mockito; import java.security.GeneralSecurityException; import java.security.KeyFactory; @@ -70,6 +75,10 @@ public class VaultResourceIT { Vault.Repository vaultRepo; @Inject Validator validator; + @InjectMock + LicenseHolder licenseHolder; + + @SuppressWarnings("unused") // used by @DBRollbackAfter annotation @Inject public Flyway flyway; @@ -78,6 +87,14 @@ public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); } + @BeforeEach + public void setup() { +// var decodedJWT = Mockito.mock(DecodedJWT.class); +// Mockito.doReturn(decodedJWT).when(licenseHolder).get(); + Mockito.doReturn(false).when(licenseHolder).isExpired(); + Mockito.doReturn(5L).when(licenseHolder).getSeats(); + } + private static PrivateKey getPrivateKey(String keyBytes) throws NoSuchAlgorithmException, InvalidKeySpecException { return KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyBytes))); } @@ -190,7 +207,7 @@ public void testUnlockArchived2() { @Test @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-token returns 200 for archived vaults with evenIfArchived set to true") - public void testUnlockArchived3() throws SQLException { + public void testUnlockArchived3() { when().get("/vaults/{vaultId}/access-token?evenIfArchived=true", "7E57C0DE-0000-4000-8000-00010000AAAA") .then().statusCode(200); } diff --git a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java index 2e2e7db6e..b3df49990 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; @@ -46,76 +47,76 @@ public void resetTestclass() { } @Nested - @DisplayName("Testing Init Method") - class TestInit { + @DisplayName("Testing ensureLicenseExists()") + class TestEnsureLicenseExists { + + private Settings settings; + private LicenseHolder licenseHolderSpy; + + @BeforeEach + public void setup() { + settings = mock(Settings.class); + licenseHolderSpy = Mockito.spy(licenseHolder); + Mockito.doReturn(settings).when(settingsRepo).get(); + Mockito.doNothing().when(licenseHolderSpy).validateExistingLicense(any()); + Mockito.doNothing().when(licenseHolderSpy).validateAndApplyInitLicense(any(), any(), any()); + Mockito.doNothing().when(licenseHolderSpy).requestAnonTrialLicense(); + } @Test - @DisplayName("If db token and hubId is set, call validateExisting") - public void testTokenAndIdPresentInDatabase() { + @DisplayName("call validateExistingLicense(), if DB contains existing token") + public void testValidateExistingLicense() { //to show check, that db has higher precedence - licenseHolder.initialId = Optional.of("43"); - licenseHolder.initialLicenseToken = Optional.of("initToken"); - - Settings settings = mock(Settings.class); + licenseHolderSpy.initialId = Optional.of("43"); + licenseHolderSpy.initialLicenseToken = Optional.of("initToken"); when(settings.getLicenseKey()).thenReturn("token"); when(settings.getHubId()).thenReturn("42"); - when(settingsRepo.get()).thenReturn(settings); - var licenseHolderSpy = Mockito.spy(licenseHolder); - licenseHolderSpy.init(); - verify(licenseHolderSpy).validateOrResetExistingLicense(settings); + licenseHolderSpy.ensureLicenseExists(); + + verify(licenseHolderSpy).validateExistingLicense(settings); + verify(licenseHolderSpy, never()).validateAndApplyInitLicense(any(), any(), any()); + verify(licenseHolderSpy, never()).requestAnonTrialLicense(); } - @DisplayName("If dbToken or dbHubId is null, use set init config values") + @DisplayName("call validateAndApplyInitLicense(), if DB doesn't contain token but init config does") @ParameterizedTest - @MethodSource("provideInitValuesCases") - public void testInitValues(String dbToken, String dbHubId) { - Settings settings = mock(Settings.class); + @CsvSource(value = { + "dbToken, null", + "null, null", + "null, 42" + }, nullValues = {"null"}) + public void testApplyInitLicense(String dbToken, String dbHubId) { + licenseHolderSpy.initialLicenseToken = Optional.of("token"); + licenseHolderSpy.initialId = Optional.of("43"); when(settings.getLicenseKey()).thenReturn(dbToken); when(settings.getHubId()).thenReturn(dbHubId); - when(settingsRepo.get()).thenReturn(settings); - licenseHolder.initialLicenseToken = Optional.of("token"); - licenseHolder.initialId = Optional.of("43"); + licenseHolderSpy.ensureLicenseExists(); - var licenseHolderSpy = Mockito.spy(licenseHolder); - licenseHolderSpy.init(); + verify(licenseHolderSpy, never()).validateExistingLicense(any()); verify(licenseHolderSpy).validateAndApplyInitLicense(settings, "token", "43"); + verify(licenseHolderSpy, never()).requestAnonTrialLicense(); } - public static Stream provideInitValuesCases() { - return Stream.of( - Arguments.of("dbToken", null), - Arguments.of(null, null), - Arguments.of(null, "42") - ); - } - - @DisplayName("Do nothing, if db and init have a null value") + @DisplayName("call requestAnonTrialLicense(), if neither DB nor init config contains token") @ParameterizedTest - @MethodSource("provideDoNothingCases") - public void testDoNothingCases(String dbToken, String dbHubId, String initToken, String initId) { - Settings settings = mock(Settings.class); + @CsvSource(value = { + "dbToken, null, null, 43", + "null, 42, null, 43", + "dbToken, null, initToken, null" + }, nullValues = {"null"}) + public void testRequestTrialLicense(String dbToken, String dbHubId, String initToken, String initId) { + licenseHolderSpy.initialLicenseToken = Optional.ofNullable(initToken); + licenseHolderSpy.initialId = Optional.ofNullable(initId); when(settings.getLicenseKey()).thenReturn(dbToken); when(settings.getHubId()).thenReturn(dbHubId); - when(settingsRepo.get()).thenReturn(settings); - licenseHolder.initialLicenseToken = Optional.ofNullable(initToken); - licenseHolder.initialId = Optional.ofNullable(initId); + licenseHolderSpy.ensureLicenseExists(); - var licenseHolderSpy = Mockito.spy(licenseHolder); - licenseHolderSpy.init(); - verify(licenseHolderSpy, never()).validateOrResetExistingLicense(settings); + verify(licenseHolderSpy, never()).validateExistingLicense(settings); verify(licenseHolderSpy, never()).validateAndApplyInitLicense(Mockito.eq(settings), any(), any()); - } - - public static Stream provideDoNothingCases() { - return Stream.of( - Arguments.of("dbToken", null, null, "43"), - Arguments.of(null, "42", null, "43"), - Arguments.of(null, "42", "initToken", null), - Arguments.of("dbToken", null, "initToken", null) - ); + verify(licenseHolderSpy).requestAnonTrialLicense(); } } @@ -129,13 +130,13 @@ public void testValidateExistingSuccess() { when(validator.validate("token", "42")).thenReturn(Mockito.mock(DecodedJWT.class)); - licenseHolder.validateOrResetExistingLicense(settings); + licenseHolder.validateExistingLicense(settings); verify(settings, never()).setHubId(any()); verify(settings, never()).setLicenseKey(any()); } @Test - @DisplayName("Invalid db token is set to null and persisted") + @DisplayName("Invalid db token fails validation") public void testValidateExistingFailure() { Settings settings = mock(Settings.class); when(settings.getLicenseKey()).thenReturn("token"); @@ -144,10 +145,7 @@ public void testValidateExistingFailure() { when(validator.validate("token", "42")).thenThrow(JWTVerificationException.class); - licenseHolder.validateOrResetExistingLicense(settings); - verify(settings, never()).setHubId(any()); - verify(settings).setLicenseKey(Mockito.isNull()); - verify(settingsRepo).persistAndFlush(settings); + Assertions.assertThrows(JWTVerificationException.class, () -> licenseHolder.validateExistingLicense(settings)); } @Test @@ -173,11 +171,17 @@ public void testApplyInitFailure() { when(validator.validate("token", "42")).thenThrow(JWTVerificationException.class); - licenseHolder.validateAndApplyInitLicense(settings, "token", "42"); + Assertions.assertThrows(JWTVerificationException.class, () -> licenseHolder.validateAndApplyInitLicense(settings, "token", "42")); verify(settings, never()).setHubId(any()); verify(settings, never()).setLicenseKey(any()); } + @Test + @DisplayName("Requesting a trial license contacts the license server") + public void testRequestAnonTrialLicense() { + // TODO + } + @Nested @DisplayName("Testing set() method") class TestSetter { @@ -208,18 +212,15 @@ public void testSetValidToken() { @Test @DisplayName("Setting an invalid token fails with exception") public void testSetInvalidToken() { - when(validator.validate("token", "42")).thenAnswer(invocationOnMock -> { - throw new JWTVerificationException(""); - }); Settings settings = mock(Settings.class); - when(settings.getHubId()).thenReturn("42"); - when(settingsRepo.get()).thenReturn(settings); + Mockito.doReturn(settings).when(settingsRepo).get(); + Mockito.doReturn("42").when(settings).getHubId(); + Mockito.doThrow(new JWTVerificationException("")).when(validator).validate("token", "42"); Assertions.assertThrows(JWTVerificationException.class, () -> licenseHolder.set("token")); verify(validator).validate("token", "42"); verify(settingsRepo, never()).persist((Settings) any()); - Assertions.assertNull(licenseHolder.get()); //TODO: not very unit test like } } @@ -227,14 +228,26 @@ public void testSetInvalidToken() { @DisplayName("Testing refreshLicense()") class RefreshLicense { + private LicenseHolder licenseHolderSpy; + private Claim refreshClaim; + private DecodedJWT licenseJwt; + + @BeforeEach + public void setup() { + licenseHolderSpy = Mockito.spy(licenseHolder); + refreshClaim = mock(Claim.class); + licenseJwt = mock(DecodedJWT.class); + + Mockito.doReturn("http://localhost:3000").when(refreshClaim).asString(); + Mockito.doReturn(refreshClaim).when(licenseJwt).getClaim("refreshUrl"); + Mockito.doReturn("token").when(licenseJwt).getToken(); + Mockito.doReturn(licenseJwt).when(licenseHolderSpy).get(); + } + @Test @DisplayName("If license does not have a refreshUrl, skip refresh") public void testRefreshLicenseNoRefreshURL() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(null); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); + Mockito.doReturn(null).when(licenseJwt).getClaim("refreshUrl"); licenseHolderSpy.refreshLicense(); @@ -248,13 +261,7 @@ public void testRefreshLicenseNoRefreshURL() throws InterruptedException, IOExce @Test @DisplayName("If license does not have a valid refreshUrl, skip refresh") public void testRefreshLicenseBadURL() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("*:not:an::uri"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); + Mockito.doReturn("*:not:an::uri").when(refreshClaim).asString(); licenseHolderSpy.refreshLicense(); @@ -268,14 +275,6 @@ public void testRefreshLicenseBadURL() throws InterruptedException, IOException @ParameterizedTest @MethodSource("provideRefreshLicenseFailingRequestCases") public void testRefreshLicenseFailingRequest(Throwable t) throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("http://localhost:3000"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseJwt.getToken()).thenReturn("token"); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); Mockito.doThrow(t).when(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); licenseHolderSpy.refreshLicense(); @@ -293,14 +292,6 @@ static Stream provideRefreshLicenseFailingRequestCases() { @Test @DisplayName("Successful refresh request, but failing validation") public void testRefreshLicenseFailedValidation() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("http://localhost:3000"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseJwt.getToken()).thenReturn("token"); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); Mockito.doReturn("newToken").when(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); Mockito.doThrow(JWTVerificationException.class).when(licenseHolderSpy).set("newToken"); @@ -313,25 +304,20 @@ public void testRefreshLicenseFailedValidation() throws InterruptedException, IO } @Test - @DisplayName("Successful refresh request, but failing validation") + @DisplayName("Successful refresh") public void testRefreshLicenseSuccess() throws InterruptedException, IOException { - var licenseHolderSpy = Mockito.spy(licenseHolder); - - var refreshClaim = mock(Claim.class); - when(refreshClaim.asString()).thenReturn("http://localhost:3000"); - var licenseJwt = mock(DecodedJWT.class); - when(licenseJwt.getClaim("refreshUrl")).thenReturn(refreshClaim); - when(licenseJwt.getToken()).thenReturn("token"); - when(licenseHolderSpy.get()).thenReturn(licenseJwt); + var settings = Mockito.mock(Settings.class); + Mockito.doReturn("42").when(settings).getHubId(); + Mockito.doReturn(settings).when(settingsRepo).get(); Mockito.doReturn("newToken").when(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); - Mockito.doThrow(JWTVerificationException.class).when(licenseHolderSpy).set("newToken"); licenseHolderSpy.refreshLicense(); verify(licenseHolderSpy).requestLicenseRefresh(any(), eq("token")); verify(licenseHolderSpy).set("newToken"); - verify(settingsRepo, never()).get(); - verify(settingsRepo, never()).persistAndFlush(any()); + verify(validator).validate("newToken", "42"); + verify(settings).setLicenseKey("newToken"); + verify(settingsRepo).persistAndFlush(settings); } } diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index 403bf2b51..5ee7bd3d0 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -101,7 +101,7 @@ export type TrustDto = { export type BillingDto = { hubId: string; - hasLicense: boolean; + hasLicense: boolean; // TODO remove email: string; licensedSeats: number; usedSeats: number; @@ -134,7 +134,7 @@ export class LicenseUserInfoDto { } public isExceeded(): boolean { - return this.usedSeats > this.licensedSeats; + return this.licensedSeats == 0 || this.usedSeats > this.licensedSeats; } } diff --git a/frontend/src/components/AdminSettings.vue b/frontend/src/components/AdminSettings.vue index 24fd98449..7b325da96 100644 --- a/frontend/src/components/AdminSettings.vue +++ b/frontend/src/components/AdminSettings.vue @@ -1,5 +1,5 @@