diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index b7045bb5..d186b2c0 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -22,6 +22,7 @@ public enum Endpoints { API_CLIENT_SIDE_KEYPAIRS_ADD("/api/client_side_keypairs/add"), API_CLIENT_SIDE_KEYPAIRS_UPDATE("/api/client_side_keypairs/update"), + API_CLIENT_SIDE_KEYPAIRS_DELETE("/api/client_side_keypairs/delete"), API_CLIENT_SIDE_KEYPAIRS_LIST("/api/client_side_keypairs/list"), API_CLIENT_SIDE_KEYPAIRS_SUBSCRIPTIONID("/api/client_side_keypairs/:subscriptionId"), API_CLIENT_SIDE_KEYPAIRS_BY_SITE("/api/v2/sites/:siteId/client-side-keypairs"), diff --git a/src/main/java/com/uid2/admin/vertx/service/ClientSideKeypairService.java b/src/main/java/com/uid2/admin/vertx/service/ClientSideKeypairService.java index cdeeed79..4d2715b5 100644 --- a/src/main/java/com/uid2/admin/vertx/service/ClientSideKeypairService.java +++ b/src/main/java/com/uid2/admin/vertx/service/ClientSideKeypairService.java @@ -76,6 +76,11 @@ public void setupRoutes(Router router) { this.handleUpdateKeypair(ctx); } }, new AuditParams(Collections.emptyList(), List.of("subscription_id", "name", "contact", "disabled")), Role.MAINTAINER, Role.SHARING_PORTAL)); + router.post(API_CLIENT_SIDE_KEYPAIRS_DELETE.toString()).blockingHandler(auth.handle((ctx) -> { + synchronized (writeLock) { + this.handleDeleteKeypair(ctx); + } + }, new AuditParams(Collections.emptyList(), List.of("subscription_id")), Role.PRIVILEGED, Role.SHARING_PORTAL)); router.get(API_CLIENT_SIDE_KEYPAIRS_LIST.toString()).handler( auth.handle(this::handleListAllKeypairs, Role.MAINTAINER, Role.METRICS_EXPORT)); router.get(API_CLIENT_SIDE_KEYPAIRS_SUBSCRIPTIONID.toString()).handler( @@ -119,22 +124,16 @@ private void handleAddKeypair(RoutingContext rc) { } private void handleUpdateKeypair(RoutingContext rc) { - final JsonObject body = rc.body().asJsonObject(); - final String subscriptionId = body.getString("subscription_id"); + JsonObject body = getRequestBody(rc); + if (body == null) return; + String contact = body.getString("contact"); Boolean disabled = body.getBoolean("disabled"); String name = body.getString("name"); - if (subscriptionId == null) { - ResponseUtil.error(rc, 400, "Required parameters: subscription_id"); - return; - } + ClientSideKeypair keypair = validateAndGetKeypair(rc, body); + if (keypair == null) return; - ClientSideKeypair keypair = this.keypairStore.getSnapshot().getKeypair(subscriptionId); - if (keypair == null) { - ResponseUtil.error(rc, 404, "Failed to find a keypair for subscription id: " + subscriptionId); - return; - } if (contact == null && disabled == null && name == null) { ResponseUtil.error(rc, 400, "Updatable parameters: contact, disabled, name"); @@ -179,6 +178,30 @@ private void handleUpdateKeypair(RoutingContext rc) { .end(json.encode()); } + private void handleDeleteKeypair(RoutingContext rc) { + JsonObject body = getRequestBody(rc); + if (body == null) return; + + ClientSideKeypair keypair = validateAndGetKeypair(rc, body); + if (keypair == null) return; + + Set allKeypairs = new HashSet<>(this.keypairStore.getAll()); + allKeypairs.remove(keypair); + + try { + storeWriter.upload(allKeypairs, null); + } catch (Exception e) { + ResponseUtil.errorInternal(rc, "failed to upload keypairs", e); + return; + } + + JsonObject responseJson = new JsonObject() + .put("success", true) + .put("deleted_keypair", createKeypairJsonObject(toJsonWithoutPrivateKey(keypair))); + rc.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(responseJson.encode()); + } + public Iterable getKeypairsBySite(int siteId) { return this.keypairStore.getSnapshot().getSiteKeypairs(siteId); } @@ -243,4 +266,27 @@ public ClientSideKeypair createAndSaveSiteKeypair(int siteId, String contact, bo return newKeypair; } + + private JsonObject getRequestBody(RoutingContext rc) { + JsonObject body = rc.body() != null ? rc.body().asJsonObject() : null; + if (body == null) { + ResponseUtil.error(rc, 400, "json payload required but not provided"); + } + return body; + } + + private ClientSideKeypair validateAndGetKeypair(RoutingContext rc, JsonObject body) { + String subscriptionId = body.getString("subscription_id"); + if (subscriptionId == null) { + ResponseUtil.error(rc, 400, "Required parameters: subscription_id"); + return null; + } + + ClientSideKeypair keypair = this.keypairStore.getSnapshot().getKeypair(subscriptionId); + if (keypair == null) { + ResponseUtil.error(rc, 404, "Failed to find a keypair for subscription id: " + subscriptionId); + return null; + } + return keypair; + } } diff --git a/src/test/java/com/uid2/admin/vertx/ClientSideKeypairServiceTest.java b/src/test/java/com/uid2/admin/vertx/ClientSideKeypairServiceTest.java index bf0d245d..88c0cfe7 100644 --- a/src/test/java/com/uid2/admin/vertx/ClientSideKeypairServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/ClientSideKeypairServiceTest.java @@ -14,10 +14,14 @@ import io.vertx.junit5.VertxTestContext; import org.junit.jupiter.api.BeforeEach; 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.MethodSource; import org.mockito.ArgumentCaptor; import java.time.Instant; import java.util.*; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -556,6 +560,102 @@ void updateKeypairDisabledAndName(Vertx vertx, VertxTestContext testContext) thr testContext.completeNow(); }); } + + @Test + void deleteKeypairNoSubscriptionId(Vertx vertx, VertxTestContext testContext) throws Exception { + fakeAuth(Role.PRIVILEGED); + + setKeypairs(new ArrayList<>()); + + JsonObject jo = new JsonObject(); + + post(vertx, testContext, "api/client_side_keypairs/delete", jo.encode(), response -> { + assertEquals(400, response.statusCode()); + assertEquals("Required parameters: subscription_id", response.bodyAsJsonObject().getString("message")); + verify(keypairStoreWriter, times(0)).upload(any(), isNull()); + testContext.completeNow(); + }); + } + + @Test + void deleteKeypairBadSubscriptionId(Vertx vertx, VertxTestContext testContext) throws Exception { + fakeAuth(Role.PRIVILEGED); + + Map keypairs = new HashMap<>() {{ + put("89aZ234567", new ClientSideKeypair("89aZ234567", pub1, priv1, 124, "test@example.com", Instant.now(), false, name1)); + put("9aZ2345678", new ClientSideKeypair("9aZ2345678", pub2, priv2, 125, "test@example.com", Instant.now(), false, name2)); + }}; + setKeypairs(new ArrayList<>(keypairs.values())); + + JsonObject jo = new JsonObject(); + jo.put("subscription_id", "bad-id"); + + post(vertx, testContext, "api/client_side_keypairs/delete", jo.encode(), response -> { + assertEquals(404, response.statusCode()); + assertEquals("Failed to find a keypair for subscription id: bad-id", response.bodyAsJsonObject().getString("message")); + verify(keypairStoreWriter, times(0)).upload(any(), isNull()); + testContext.completeNow(); + }); + } + + @Test + void deleteKeypair(Vertx vertx, VertxTestContext testContext) throws Exception { + fakeAuth(Role.PRIVILEGED); + + Instant time = Instant.now(); + ClientSideKeypair keypairToDelete = new ClientSideKeypair("89aZ234567", pub1, priv1, 124, "test@example.com", time, false, name1); + ClientSideKeypair remainingKeypair = new ClientSideKeypair("9aZ2345678", pub2, priv2, 124, "test@example.com", time, false, name2); + + setKeypairs(List.of(keypairToDelete, remainingKeypair)); + setSites(new Site(124, "test", true)); + + JsonObject jo = new JsonObject(); + jo.put("subscription_id", "89aZ234567"); + + post(vertx, testContext, "api/client_side_keypairs/delete", jo.encode(), response -> { + assertEquals(200, response.statusCode()); + assertEquals(true, response.bodyAsJsonObject().getBoolean("success")); + validateKeypair(keypairToDelete, "test", response.bodyAsJsonObject().getJsonObject("deleted_keypair")); + verify(keypairStoreWriter, times(1)).upload(collectionOfSize(1), isNull()); + testContext.completeNow(); + }); + } + + private static Stream deleteRoles() { + return Stream.of( + Arguments.of(Role.MAINTAINER, 401, false), + Arguments.of(Role.PRIVILEGED, 200, true), + Arguments.of(Role.SHARING_PORTAL, 200, true) + ); + } + + @ParameterizedTest + @MethodSource("deleteRoles") + void deleteKeypairAuthorization(Role role, int expectedStatus, boolean shouldSucceed, Vertx vertx, VertxTestContext testContext) throws Exception { + fakeAuth(role); + + Instant time = Instant.now(); + ClientSideKeypair keypairToDelete = new ClientSideKeypair("CC12345678", pub1, priv1, 222, "contact@example.com", time, false, name1); + ClientSideKeypair remainingKeypair = new ClientSideKeypair("DD12345678", pub2, priv2, 222, "contact@example.com", time, false, name2); + + setKeypairs(List.of(keypairToDelete, remainingKeypair)); + setSites(new Site(222, "test", true)); + + JsonObject jo = new JsonObject().put("subscription_id", "CC12345678"); + + post(vertx, testContext, "api/client_side_keypairs/delete", jo.encode(), response -> { + assertEquals(expectedStatus, response.statusCode()); + + if (shouldSucceed) { + assertTrue(response.bodyAsJsonObject().getBoolean("success")); + validateKeypair(keypairToDelete, "test", response.bodyAsJsonObject().getJsonObject("deleted_keypair")); + verify(keypairStoreWriter, times(1)).upload(collectionOfSize(1), isNull()); + } else { + verify(keypairStoreWriter, times(0)).upload(any(), isNull()); + } + testContext.completeNow(); + }); + } } diff --git a/webroot/adm/client-side-keypair.html b/webroot/adm/client-side-keypair.html index 966906d2..6550bc78 100644 --- a/webroot/adm/client-side-keypair.html +++ b/webroot/adm/client-side-keypair.html @@ -33,6 +33,7 @@

Operations

+
@@ -109,6 +110,20 @@

Output

doApiCall('POST', '/api/client_side_keypairs/update', '#standardOutput', '#errorOutput', JSON.stringify(payload)); }); + $('#doDelete').on('click', function () { + const subscriptionId = $('#subscriptionId').val(); + if (!subscriptionId) { + $('#errorOutput').text("required parameters: subscription_id"); + return; + } + + if (!confirm(`Are you sure you want to delete ${subscriptionId}?`)) return; + + const payload = {"subscription_id": subscriptionId}; + + doApiCall('POST', '/api/client_side_keypairs/delete', '#standardOutput', '#errorOutput', JSON.stringify(payload)); + }); + }); function getUpdateKeypairConfirmationMessage(disabled, subscriptionId) {