diff --git a/pom.xml b/pom.xml index cea33e99..4806932b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.uid2 uid2-admin - 6.12.11 + 6.12.14-alpha-223-SNAPSHOT UTF-8 diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index 414d4377..7fb5df32 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -60,8 +60,12 @@ public enum Endpoints { API_OPERATOR_UPDATE("/api/operator/update"), API_OPERATOR_ROLES("/api/operator/roles"), - API_PARTNER_CONFIG_GET("/api/partner_config/get"), + API_PARTNER_CONFIG_LIST("/api/partner_config/list"), + API_PARTNER_CONFIG_GET("/api/partner_config/get/:partner_name"), + API_PARTNER_CONFIG_ADD("/api/partner_config/add"), API_PARTNER_CONFIG_UPDATE("/api/partner_config/update"), + API_PARTNER_CONFIG_DELETE("/api/partner_config/delete"), + API_PARTNER_CONFIG_BULK_REPLACE("/api/partner_config/bulk_replace"), API_PRIVATE_SITES_REFRESH("/api/private-sites/refresh"), API_PRIVATE_SITES_REFRESH_NOW("/api/private-sites/refreshNow"), diff --git a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java index c95c4877..01900017 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -9,14 +9,21 @@ import com.uid2.shared.auth.Role; import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_LIST; import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_GET; +import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_ADD; import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_UPDATE; +import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_DELETE; +import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_BULK_REPLACE; public class PartnerConfigService implements IService { private final AdminAuthMiddleware auth; @@ -36,17 +43,37 @@ public PartnerConfigService(AdminAuthMiddleware auth, @Override public void setupRoutes(Router router) { + router.get(API_PARTNER_CONFIG_LIST.toString()).handler( + auth.handle(this::handlePartnerConfigList, Role.MAINTAINER)); router.get(API_PARTNER_CONFIG_GET.toString()).handler( auth.handle(this::handlePartnerConfigGet, Role.MAINTAINER)); - router.post(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> { + + router.post(API_PARTNER_CONFIG_ADD.toString()).blockingHandler(auth.handle((ctx) -> { + synchronized (writeLock) { + this.handlePartnerConfigAdd(ctx); + } + }, new AuditParams(Collections.emptyList(), List.of("name")), Role.MAINTAINER)); + router.put(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> { synchronized (writeLock) { this.handlePartnerConfigUpdate(ctx); } - }, new AuditParams(Collections.emptyList(), List.of("partner_id", "config")), Role.PRIVILEGED)); + }, new AuditParams(Collections.emptyList(), List.of("name")), Role.MAINTAINER)); + + router.delete(API_PARTNER_CONFIG_DELETE.toString()).blockingHandler(auth.handle((ctx) -> { + synchronized (writeLock) { + this.handlePartnerConfigDelete(ctx); + } + }, new AuditParams(List.of("partner_name"), Collections.emptyList()), Role.PRIVILEGED)); + router.post(API_PARTNER_CONFIG_BULK_REPLACE.toString()).blockingHandler(auth.handle((ctx) -> { + synchronized (writeLock) { + this.handlePartnerConfigBulkReplace(ctx); + } + }, new AuditParams(Collections.emptyList(), List.of("name")), Role.SUPER_USER)); } - private void handlePartnerConfigGet(RoutingContext rc) { + private void handlePartnerConfigList(RoutingContext rc) { try { + this.partnerConfigProvider.loadContent(); String config = this.partnerConfigProvider.getConfig(); rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") @@ -56,23 +83,305 @@ private void handlePartnerConfigGet(RoutingContext rc) { } } + private void handlePartnerConfigGet(RoutingContext rc) { + try { + final String partnerName = rc.pathParam("partner_name"); + if (partnerName == null || partnerName.isEmpty()) { + ResponseUtil.error(rc, 400, "Partner name is required"); + return; + } + + this.partnerConfigProvider.loadContent(); + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + int index = findPartnerIndex(allPartnerConfigs, partnerName); + + if (index == -1) { + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + return; + } + + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(allPartnerConfigs.getJsonObject(index).encode()); + } catch (Exception e) { + rc.fail(500, e); + } + } + + private void handlePartnerConfigAdd(RoutingContext rc) { + try { + JsonObject newConfig = rc.body().asJsonObject(); + if (newConfig == null) { + ResponseUtil.error(rc, 400, "Body must include Partner config"); + return; + } + + // Validate required fields + if (!validatePartnerConfig(rc, newConfig)) { + return; + } + + String newPartnerName = newConfig.getString("name"); + this.partnerConfigProvider.loadContent(); + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + + // Validate partner doesn't exist + if (findPartnerIndex(allPartnerConfigs, newPartnerName) != -1) { + ResponseUtil.error(rc, 409, "Partner '" + newPartnerName + "' already exists"); + return; + } + + // Upload + allPartnerConfigs.add(newConfig); + storageManager.upload(allPartnerConfigs); + + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(newConfig.encode()); + } catch (Exception e) { + rc.fail(500, e); + } + } + private void handlePartnerConfigUpdate(RoutingContext rc) { + try { + JsonObject partialConfig = rc.body().asJsonObject(); + if (partialConfig == null) { + ResponseUtil.error(rc, 400, "Body must include Partner config"); + return; + } + + String partnerName = partialConfig.getString("name"); + if (partnerName == null || partnerName.trim().isEmpty()) { + ResponseUtil.error(rc, 400, "Partner config 'name' is required"); + return; + } + + this.partnerConfigProvider.loadContent(); + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + + // Find existing partner config + int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, partnerName); + if (existingPartnerIdx == -1) { + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + return; + } + + JsonObject existingConfig = allPartnerConfigs.getJsonObject(existingPartnerIdx); + + // Validate partial config + if (!validatePartnerConfigForUpdate(rc, partialConfig)) { + return; + } + + // Merge: start with existing config, overlay with new fields + JsonObject mergedConfig = existingConfig.copy(); + partialConfig.forEach(entry -> { + mergedConfig.put(entry.getKey(), entry.getValue()); + }); + + // Replace with merged config + allPartnerConfigs.set(existingPartnerIdx, mergedConfig); + storageManager.upload(allPartnerConfigs); + + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(mergedConfig.encode()); + } catch (Exception e) { + rc.fail(500, e); + } + } + + private void handlePartnerConfigDelete(RoutingContext rc) { + try { + final List partnerNames = rc.queryParam("partner_name"); + if (partnerNames.isEmpty()) { + ResponseUtil.error(rc, 400, "Partner name is required"); + return; + } + final String partnerName = partnerNames.getFirst(); + + this.partnerConfigProvider.loadContent(); + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + + // Find partner config + int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, partnerName); + if (existingPartnerIdx == -1) { + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + return; + } + + // Remove and return the deleted config + JsonObject deletedConfig = allPartnerConfigs.getJsonObject(existingPartnerIdx); + allPartnerConfigs.remove(existingPartnerIdx); + storageManager.upload(allPartnerConfigs); + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(deletedConfig.encode()); + } catch (Exception e) { + rc.fail(500, e); + } + } + + private void handlePartnerConfigBulkReplace(RoutingContext rc) { try { // refresh manually this.partnerConfigProvider.loadContent(); JsonArray partners = rc.body().asJsonArray(); + if (partners == null) { - ResponseUtil.error(rc, 400, "Body must be none empty"); + ResponseUtil.error(rc, 400, "Body must be non-empty"); return; } + // Keep track of names to check for duplicates + Set partnerNames = new HashSet<>(); + + // Validate each config + for (int i = 0; i < partners.size(); i++) { + JsonObject config = partners.getJsonObject(i); + if (config == null) { + ResponseUtil.error(rc, 400, "Could not parse config at index " + i); + return; + } + + if (!validatePartnerConfig(rc, config)) { + return; + } + + String name = partners.getJsonObject(i).getString("name"); + if (name != null && !partnerNames.add(name.toLowerCase())) { + ResponseUtil.error(rc, 400, "Duplicate partner name: " + name); + return; + } + } + storageManager.upload(partners); rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .end("\"success\""); + .end(partners.encode()); } catch (Exception e) { rc.fail(500, e); } } + + private boolean validatePartnerConfig(RoutingContext rc, JsonObject config) { + if (config == null) { + ResponseUtil.error(rc, 400, "Partner config is required"); + return false; + } + + String name = config.getString("name"); + String url = config.getString("url"); + String method = config.getString("method"); + Integer retryCount = config.getInteger("retry_count"); + Integer retryBackoffMs = config.getInteger("retry_backoff_ms"); + + if (name == null || name.trim().isEmpty()) { + ResponseUtil.error(rc, 400, "Partner config 'name' is required"); + return false; + } + if (url == null || url.trim().isEmpty()) { + ResponseUtil.error(rc, 400, "Partner config 'url' is required"); + return false; + } + if (method == null || method.trim().isEmpty()) { + ResponseUtil.error(rc, 400, "Partner config 'method' is required"); + return false; + } + if (retryCount == null || retryCount < 0) { + ResponseUtil.error(rc, 400, "Partner config 'retry_count' is required and must be >= 0"); + return false; + } + if (retryBackoffMs == null || retryBackoffMs < 0) { + ResponseUtil.error(rc, 400, "Partner config 'retry_backoff_ms' is required and must be >= 0"); + return false; + } + + // Validate optional array fields + if (!validateArrayField(rc, config, "query_params")) { + return false; + } + if (!validateArrayField(rc, config, "additional_headers")) { + return false; + } + + return true; + } + + private boolean validatePartnerConfigForUpdate(RoutingContext rc, JsonObject config) { + if (config == null) { + ResponseUtil.error(rc, 400, "Partner config is required"); + return false; + } + + // Name is always required to identify the partner (already checked in handlePartnerConfigUpdate) + // Validate fields that are present (not null) + String url = config.getString("url"); + if (url != null && url.trim().isEmpty()) { + ResponseUtil.error(rc, 400, "Partner config 'url' cannot be empty"); + return false; + } + + String method = config.getString("method"); + if (method != null && method.trim().isEmpty()) { + ResponseUtil.error(rc, 400, "Partner config 'method' cannot be empty"); + return false; + } + + Integer retryCount = config.getInteger("retry_count"); + if (retryCount != null && retryCount < 0) { + ResponseUtil.error(rc, 400, "Partner config 'retry_count' must be >= 0"); + return false; + } + + Integer retryBackoffMs = config.getInteger("retry_backoff_ms"); + if (retryBackoffMs != null && retryBackoffMs < 0) { + ResponseUtil.error(rc, 400, "Partner config 'retry_backoff_ms' must be >= 0"); + return false; + } + + // Validate optional array fields + if (!validateArrayField(rc, config, "query_params")) { + return false; + } + if (!validateArrayField(rc, config, "additional_headers")) { + return false; + } + + return true; + } + + private boolean validateArrayField(RoutingContext rc, JsonObject config, String fieldName) { + // Field is optional, so null is acceptable + if (!config.containsKey(fieldName)) { + return true; + } + + Object value = config.getValue(fieldName); + if (value == null) { + return true; // null is acceptable + } + + // If present, must be a JsonArray + if (!(value instanceof JsonArray)) { + ResponseUtil.error(rc, 400, "Partner config '" + fieldName + "' must be an array"); + return false; + } + + return true; + } + + private int findPartnerIndex(JsonArray configs, String partnerName) { + if (partnerName == null) return -1; + for (int i = 0; i < configs.size(); i++) { + JsonObject config = configs.getJsonObject(i); + String name = config.getString("name"); + if (partnerName.equalsIgnoreCase(name)) { + return i; + } + } + return -1; + } } diff --git a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java new file mode 100644 index 00000000..1f885b0f --- /dev/null +++ b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java @@ -0,0 +1,625 @@ +package com.uid2.admin.vertx; + +import com.uid2.admin.store.reader.RotatingPartnerStore; +import com.uid2.admin.vertx.service.IService; +import com.uid2.admin.vertx.service.PartnerConfigService; +import com.uid2.admin.vertx.test.ServiceTestBase; +import com.uid2.shared.auth.Role; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.junit5.VertxTestContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class PartnerConfigServiceTest extends ServiceTestBase { + @Mock + protected RotatingPartnerStore partnerConfigProvider; + + @Override + protected IService createService() { + return new PartnerConfigService(auth, writeLock, partnerStoreWriter, partnerConfigProvider); + } + + private void setPartnerConfigs(JsonObject... configs) { + JsonArray partnerConfigs = new JsonArray(); + for (JsonObject config : configs) { + partnerConfigs.add(config); + } + when(partnerConfigProvider.getConfig()).thenReturn(partnerConfigs.encode()); + } + + private JsonObject createPartnerConfig(String name, String url) { + JsonObject config = new JsonObject(); + config.put("name", name); + config.put("url", url); + config.put("method", "GET"); + config.put("retry_count", 600); + config.put("retry_backoff_ms", 6000); + return config; + } + + private JsonObject createPartnerConfigWithArrays(String name, String url, JsonArray queryParams, JsonArray additionalHeaders) { + JsonObject config = createPartnerConfig(name, url); + if (queryParams != null) { + config.put("query_params", queryParams); + } + if (additionalHeaders != null) { + config.put("additional_headers", additionalHeaders); + } + return config; + } + + // LIST endpoint tests + @Test + void listPartnerConfigsWithConfigs(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject config1 = createPartnerConfig("partner1", "https://example.com/webhook1"); + JsonObject config2 = createPartnerConfig("partner2", "https://example.com/webhook2"); + setPartnerConfigs(config1, config2); + + get(vertx, testContext, "api/partner_config/list", response -> { + JsonArray result = response.bodyAsJsonArray(); + assertAll( + () -> assertEquals(200, response.statusCode()), + () -> assertEquals(2, result.size()), + () -> assertEquals("partner1", result.getJsonObject(0).getString("name")), + () -> assertEquals("partner2", result.getJsonObject(1).getString("name")) + ); + testContext.completeNow(); + }); + } + + @Test + void listPartnerConfigsUnauthorized(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAPPER); + + get(vertx, testContext, "api/partner_config/list", response -> { + assertEquals(401, response.statusCode()); + testContext.completeNow(); + }); + } + + // GET by name endpoint tests + @Test + void getPartnerConfigByNameSuccess(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject config1 = createPartnerConfig("partner1", "https://example.com/webhook1"); + JsonObject config2 = createPartnerConfig("partner2", "https://example.com/webhook2"); + setPartnerConfigs(config1, config2); + + get(vertx, testContext, "api/partner_config/get/partner1", response -> { + JsonObject result = response.bodyAsJsonObject(); + assertAll( + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("partner1", result.getString("name")), + () -> assertEquals("https://example.com/webhook1", result.getString("url")) + ); + testContext.completeNow(); + }); + } + + @Test + void getPartnerConfigByNameCaseInsensitive(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject config = createPartnerConfig("Partner1", "https://example.com/webhook1"); + setPartnerConfigs(config); + + get(vertx, testContext, "api/partner_config/get/PARTNER1", response -> { + assertEquals(200, response.statusCode()); + testContext.completeNow(); + }); + } + + @Test + void getPartnerConfigByNameNotFound(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject config = createPartnerConfig("partner1", "https://example.com/webhook1"); + setPartnerConfigs(config); + + get(vertx, testContext, "api/partner_config/get/nonexistent", response -> { + assertEquals(404, response.statusCode()); + testContext.completeNow(); + }); + } + + // ADD endpoint tests + @Test + void addPartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("existing-partner", "https://example.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject newConfig = createPartnerConfig("new-partner", "https://new.com/webhook"); + + post(vertx, testContext, "api/partner_config/add", newConfig.encode(), response -> { + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 2) return false; + JsonObject addedConfig = arr.getJsonObject(1); + return "new-partner".equals(addedConfig.getString("name")) && + "https://new.com/webhook".equals(addedConfig.getString("url")); + })); + testContext.completeNow(); + }); + } + + @Test + void addPartnerConfigAlreadyExists(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("existing-partner", "https://example.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject duplicateConfig = createPartnerConfig("existing-partner", "https://new.com/webhook"); + + post(vertx, testContext, "api/partner_config/add", duplicateConfig.encode(), response -> { + assertEquals(409, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void addPartnerConfigAlreadyExistsCaseInsensitive(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("Existing-Partner", "https://example.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject duplicateConfig = createPartnerConfig("EXISTING-PARTNER", "https://new.com/webhook"); + + post(vertx, testContext, "api/partner_config/add", duplicateConfig.encode(), response -> { + assertEquals(409, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @ParameterizedTest + @CsvSource(value = { + "''", + "'{\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_count\":600,\"retry_backoff_ms\":6000}'", + "'{\"name\":\"test\",\"method\":\"GET\",\"retry_count\":600,\"retry_backoff_ms\":6000}'", + "'{\"name\":\"test\",\"url\":\"https://example.com\",\"retry_count\":600,\"retry_backoff_ms\":6000}'", + "'{\"name\":\"test\",\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_backoff_ms\":6000}'", + "'{\"name\":\"test\",\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_count\":600}'" + }) + void addPartnerConfigMissingRequiredFields(String body, Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + setPartnerConfigs(); + + post(vertx, testContext, "api/partner_config/add", body, response -> { + assertEquals(400, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void addPartnerConfigUnauthorized(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAPPER); + + JsonObject newConfig = createPartnerConfig("new-partner", "https://new.com/webhook"); + + post(vertx, testContext, "api/partner_config/add", newConfig.encode(), response -> { + assertEquals(401, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + // UPDATE endpoint tests + @Test + void updatePartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject config1 = createPartnerConfig("partner1", "https://p1.com/webhook"); + JsonObject config2 = createPartnerConfig("partner2", "https://p2.com/webhook"); + setPartnerConfigs(config1, config2); + + JsonObject updatedConfig = createPartnerConfig("partner2", "https://updated.com/webhook"); + + put(vertx, testContext, "api/partner_config/update", updatedConfig.encode(), response -> { + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 2) return false; + JsonObject updated = arr.getJsonObject(1); + return "partner2".equals(updated.getString("name")) && + "https://updated.com/webhook".equals(updated.getString("url")); + })); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigNotFound(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner1", "https://example.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject updateConfig = createPartnerConfig("nonexistent", "https://new.com/webhook"); + + put(vertx, testContext, "api/partner_config/update", updateConfig.encode(), response -> { + assertEquals(404, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigCaseInsensitive(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("Partner1", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject updatedConfig = createPartnerConfig("PARTNER1", "https://new.com/webhook"); + + put(vertx, testContext, "api/partner_config/update", updatedConfig.encode(), response -> { + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(any(JsonArray.class)); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigMissingName(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner1", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + put(vertx, testContext, "api/partner_config/update", "", response -> { + assertEquals(400, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigPartialUrlOnly(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner1", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject partialUpdate = new JsonObject() + .put("name", "partner1") + .put("url", "https://new-url.com/webhook"); + + put(vertx, testContext, "api/partner_config/update", partialUpdate.encode(), response -> { + assertAll( + "updatePartnerConfigPartialUrlOnly", + () -> assertEquals(200, response.statusCode())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 1) return false; + JsonObject updated = arr.getJsonObject(0); + return "partner1".equals(updated.getString("name")) && + "https://new-url.com/webhook".equals(updated.getString("url")) && + "GET".equals(updated.getString("method")) && + 600 == updated.getInteger("retry_count") && + 6000 == updated.getInteger("retry_backoff_ms"); + })); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigPartialMultipleFields(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner2", "https://old.com/webhook"); + existingConfig.put("method", "POST"); + existingConfig.put("retry_count", 300); + setPartnerConfigs(existingConfig); + + JsonObject partialUpdate = new JsonObject() + .put("name", "partner2") + .put("retry_count", 1000) + .put("retry_backoff_ms", 10000); + + put(vertx, testContext, "api/partner_config/update", partialUpdate.encode(), response -> { + assertAll( + "updatePartnerConfigPartialMultipleFields", + () -> assertEquals(200, response.statusCode())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 1) return false; + JsonObject updated = arr.getJsonObject(0); + return "partner2".equals(updated.getString("name")) && + "https://old.com/webhook".equals(updated.getString("url")) && + "POST".equals(updated.getString("method")) && + 1000 == updated.getInteger("retry_count") && + 10000 == updated.getInteger("retry_backoff_ms"); + })); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigPartialInvalidValue(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner1", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject partialUpdate = new JsonObject() + .put("name", "partner1") + .put("retry_count", -5); + + put(vertx, testContext, "api/partner_config/update", partialUpdate.encode(), response -> { + assertAll( + "updatePartnerConfigPartialInvalidValue", + () -> assertEquals(400, response.statusCode()), + () -> assertTrue(response.bodyAsJsonObject().getString("message").contains("retry_count"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigPartialEmptyString(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner1", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonObject partialUpdate = new JsonObject() + .put("name", "partner1") + .put("url", ""); + + put(vertx, testContext, "api/partner_config/update", partialUpdate.encode(), response -> { + assertAll( + "updatePartnerConfigPartialEmptyString", + () -> assertEquals(400, response.statusCode()), + () -> assertTrue(response.bodyAsJsonObject().getString("message").contains("url"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void addPartnerConfigWithOptionalArrays(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + setPartnerConfigs(); + + JsonArray queryParams = new JsonArray().add("action=dooptout").add("uid2=${ADVERTISING_ID}"); + JsonArray headers = new JsonArray().add("Authorization: Bearer token"); + JsonObject newConfig = createPartnerConfigWithArrays("partner1", "https://example.com/webhook", queryParams, headers); + + post(vertx, testContext, "api/partner_config/add", newConfig.encode(), response -> { + assertAll( + "addPartnerConfigWithOptionalArrays", + () -> assertEquals(200, response.statusCode())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 1) return false; + JsonObject added = arr.getJsonObject(0); + return "partner1".equals(added.getString("name")) && + added.getJsonArray("query_params").size() == 2 && + added.getJsonArray("additional_headers").size() == 1; + })); + testContext.completeNow(); + }); + } + + @Test + void updatePartnerConfigWithArrays(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner1", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonArray newQueryParams = new JsonArray().add("param1=value1"); + JsonObject partialUpdate = new JsonObject() + .put("name", "partner1") + .put("query_params", newQueryParams); + + put(vertx, testContext, "api/partner_config/update", partialUpdate.encode(), response -> { + assertAll( + "updatePartnerConfigWithArrays", + () -> assertEquals(200, response.statusCode())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 1) return false; + JsonObject updated = arr.getJsonObject(0); + return "partner1".equals(updated.getString("name")) && + updated.getJsonArray("query_params").size() == 1 && + "param1=value1".equals(updated.getJsonArray("query_params").getString(0)); + })); + testContext.completeNow(); + }); + } + + @Test + void addPartnerConfigInvalidArrayField(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + setPartnerConfigs(); + + JsonObject invalidConfig = createPartnerConfig("partner1", "https://example.com/webhook"); + invalidConfig.put("query_params", "not an array"); // Invalid - should be array + + post(vertx, testContext, "api/partner_config/add", invalidConfig.encode(), response -> { + assertAll( + "addPartnerConfigInvalidArrayField", + () -> assertEquals(400, response.statusCode()), + () -> assertTrue(response.bodyAsJsonObject().getString("message").contains("query_params"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + // DELETE endpoint tests + @Test + void deletePartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + + JsonObject config1 = createPartnerConfig("partner1", "https://p1.com/webhook"); + JsonObject config2 = createPartnerConfig("partner2", "https://p2.com/webhook"); + setPartnerConfigs(config1, config2); + + delete(vertx, testContext, "api/partner_config/delete?partner_name=partner1", response -> { + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 1) return false; + return "partner2".equals(arr.getJsonObject(0).getString("name")); + })); + testContext.completeNow(); + }); + } + + @Test + void deletePartnerConfigNotFound(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + + JsonObject config = createPartnerConfig("partner1", "https://example.com/webhook"); + setPartnerConfigs(config); + + delete(vertx, testContext, "api/partner_config/delete?partner_name=nonexistent", response -> { + assertEquals(404, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void deletePartnerConfigCaseInsensitive(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + + JsonObject config = createPartnerConfig("Partner1", "https://example.com/webhook"); + setPartnerConfigs(config); + + delete(vertx, testContext, "api/partner_config/delete?partner_name=PARTNER1", response -> { + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(any(JsonArray.class)); + testContext.completeNow(); + }); + } + + @Test + void deletePartnerConfigNoPartnerName(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + setPartnerConfigs(); + + delete(vertx, testContext, "api/partner_config/delete", response -> { + assertEquals(400, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + // BULK_REPLACE endpoint tests + @Test + void bulkReplacePartnerConfigsSuccess(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.SUPER_USER); + + JsonObject existingConfig = createPartnerConfig("old-partner", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + JsonArray newConfigs = new JsonArray(); + newConfigs.add(createPartnerConfig("partner1", "https://p1.com/webhook")); + newConfigs.add(createPartnerConfig("partner2", "https://p2.com/webhook")); + + post(vertx, testContext, "api/partner_config/bulk_replace", newConfigs.encode(), response -> { + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + if (arr.size() != 2) return false; + return "partner1".equals(arr.getJsonObject(0).getString("name")) && + "partner2".equals(arr.getJsonObject(1).getString("name")); + })); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsEmptyArray(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.SUPER_USER); + setPartnerConfigs(); + + JsonArray emptyConfigs = new JsonArray(); + + post(vertx, testContext, "api/partner_config/bulk_replace", emptyConfigs.encode(), response -> { + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + return arr.size() == 0; + })); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsNullBody(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.SUPER_USER); + setPartnerConfigs(); + + post(vertx, testContext, "api/partner_config/bulk_replace", "", response -> { + assertEquals(400, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsInvalidConfig(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.SUPER_USER); + setPartnerConfigs(); + + JsonArray configs = new JsonArray(); + configs.add(createPartnerConfig("partner1", "https://p1.com/webhook")); + configs.add(new JsonObject().put("name", "partner2")); // Missing required fields + + post(vertx, testContext, "api/partner_config/bulk_replace", configs.encode(), response -> { + assertEquals(400, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsDuplicateNames(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.SUPER_USER); + setPartnerConfigs(); + + JsonArray configs = new JsonArray(); + configs.add(createPartnerConfig("partner1", "https://p1.com/webhook")); + configs.add(createPartnerConfig("partner1", "https://p2.com/webhook")); // Duplicate name + + post(vertx, testContext, "api/partner_config/bulk_replace", configs.encode(), response -> { + assertEquals(400, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsUnauthorized(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); // Bulk replace requires SUPER_USER + + JsonArray newConfigs = new JsonArray(); + newConfigs.add(createPartnerConfig("partner1", "https://p1.com/webhook")); + + post(vertx, testContext, "api/partner_config/bulk_replace", newConfigs.encode(), response -> { + assertEquals(401, response.statusCode()); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } +} diff --git a/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java b/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java index debaa987..2754d108 100644 --- a/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java +++ b/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java @@ -243,6 +243,16 @@ protected void postWithoutBody(Vertx vertx, VertxTestContext testContext, String post(vertx, testContext, endpoint, null, handler); } + protected void put(Vertx vertx, VertxTestContext testContext, String endpoint, String body, TestHandler> handler) { + WebClient client = WebClient.create(vertx); + client.putAbs(getUrlForEndpoint(endpoint)).sendBuffer(body != null ? Buffer.buffer(body) : null).onComplete(testContext.succeeding(response -> testContext.verify(() -> handler.handle(response)))); + } + + protected void delete(Vertx vertx, VertxTestContext testContext, String endpoint, TestHandler> handler) { + WebClient client = WebClient.create(vertx); + client.deleteAbs(getUrlForEndpoint(endpoint)).send().onComplete(testContext.succeeding(response -> testContext.verify(() -> handler.handle(response)))); + } + protected void setServices(Service... services) { when(serviceProvider.getAllServices()).thenReturn(Arrays.asList(services)); for (Service s : services) { diff --git a/webroot/adm/partner-config.html b/webroot/adm/partner-config.html index 9ae48bcd..53bb42e4 100644 --- a/webroot/adm/partner-config.html +++ b/webroot/adm/partner-config.html @@ -20,43 +20,269 @@

UID2 Env - Partner Config

import { initializeOutput } from '/js/component/output.js'; document.addEventListener('DOMContentLoaded', function () { - const configDataMultilineInput = { - name: 'configData', + const searchPartnerNameInput = { + name: 'searchPartnerName', + label: 'Partner Name', + required: true + }; + + const deletePartnerNameInput = { + name: 'deletePartnerName', + label: 'Partner Name', + required: true + }; + + // 'Add' operation inputs + const addPartnerNameInput = { + name: 'addPartnerName', + label: 'Partner Name', + required: true, + size: 2 + }; + + const addUrlInput = { + name: 'addUrl', + label: 'Webhook URL', + required: true, + size: 3, + placeholder: 'https://example.com/webhook' + }; + + const addMethodInput = { + name: 'addMethod', + label: 'HTTP Method', + required: true, + placeholder: 'GET' + }; + + const addRetryCountInput = { + name: 'addRetryCount', + label: 'Retry Count', + required: true, + type: 'number', + placeholder: '600' + }; + + const addRetryBackoffMsInput = { + name: 'addRetryBackoffMs', + label: 'Retry Backoff (ms)', + required: true, + type: 'number', + placeholder: '6000' + }; + + const addQueryParamsInput = { + name: 'addQueryParams', + label: 'Query Parameters (comma-separated)', + required: false, + type: 'multi-line', + placeholder: 'action=dooptout,\nuid2=${ADVERTISING_ID},\ntimestamp=${OPTOUT_EPOCH}' + }; + + const addAdditionalHeadersInput = { + name: 'addAdditionalHeaders', + label: 'Additional Headers (comma-separated)', + required: false, + type: 'multi-line', + placeholder: 'Authorization: Bearer token,\nX-Custom-Header: value' + }; + + // 'Update' operation inputs + const updatePartnerNameInput = { + name: 'updatePartnerName', + label: 'Partner Name', + required: true, + size: 2 + }; + + const updateUrlInput = { + name: 'updateUrl', + label: 'Webhook URL', + required: false, + size: 3, + placeholder: 'https://example.com/webhook' + }; + + const updateMethodInput = { + name: 'updateMethod', + label: 'HTTP Method', + required: false, + placeholder: 'GET' + }; + + const updateRetryCountInput = { + name: 'updateRetryCount', + label: 'Retry Count', + required: false, + type: 'number', + placeholder: '600' + }; + + const updateRetryBackoffMsInput = { + name: 'updateRetryBackoffMs', + label: 'Retry Backoff (ms)', + required: false, + type: 'number', + placeholder: '6000' + }; + + const updateQueryParamsInput = { + name: 'updateQueryParams', + label: 'Query Parameters (comma-separated)', + required: false, + type: 'multi-line', + placeholder: 'action=dooptout,\nuid2=${ADVERTISING_ID},\ntimestamp=${OPTOUT_EPOCH}' + }; + + const updateAdditionalHeadersInput = { + name: 'updateAdditionalHeaders', + label: 'Additional Headers (comma-separated)', + required: false, + type: 'multi-line', + placeholder: 'Authorization: Bearer token,\nX-Custom-Header: value' + }; + + const bulkReplaceConfigInput = { + name: 'bulkReplaceConfigData', label: 'Configuration Data', required: true, type: 'multi-line', - placeholder: 'Enter JSON configuration data...' + placeholder: 'Enter JSON array of all partner configurations...' }; const operationConfig = { read: [ { - id: 'getPartnerConfig', - title: 'Get Partner Config', + id: 'listPartnerConfigs', + title: 'List Partner Configs', role: 'maintainer', inputs: [], apiCall: { method: 'GET', - url: '/api/partner_config/get' + url: '/api/partner_config/list' + }, + postProcess: (response) => JSON.stringify(response, null, 2) + }, + { + id: 'searchPartnerConfig', + title: 'Search Partner Config', + role: 'maintainer', + inputs: [searchPartnerNameInput], + apiCall: { + method: 'GET', + getUrl: (inputs) => `/api/partner_config/get/${encodeURIComponent(inputs.searchPartnerName)}` }, - postProcess: (response) => JSON.parse(JSON.stringify(response, null, 2)) + postProcess: (response) => JSON.stringify(response, null, 2) } ], write: [ + { + id: 'addPartnerConfig', + title: 'Add Partner Config', + role: 'maintainer', + description: 'This will add a new partner config.', + inputs: [ + addPartnerNameInput, + addUrlInput, + addMethodInput, + addQueryParamsInput, + addAdditionalHeadersInput, + addRetryCountInput, + addRetryBackoffMsInput + ], + apiCall: { + method: 'POST', + url: '/api/partner_config/add', + getPayload: (inputs) => { + const payload = { + name: inputs.addPartnerName, + url: inputs.addUrl, + method: inputs.addMethod, + retry_count: parseInt(inputs.addRetryCount), + retry_backoff_ms: parseInt(inputs.addRetryBackoffMs) + }; + + // Parse comma-separated inputs into arrays + if (inputs.addQueryParams && inputs.addQueryParams.trim()) { + payload.query_params = inputs.addQueryParams.split(',').map(s => s.trim()).filter(s => s); + } + if (inputs.addAdditionalHeaders && inputs.addAdditionalHeaders.trim()) { + payload.additional_headers = inputs.addAdditionalHeaders.split(',').map(s => s.trim()).filter(s => s); + } + + return payload; + } + } + }, { id: 'updatePartnerConfig', title: 'Update Partner Config', + role: 'maintainer', + description: 'This will update an existing partner config. Only fill in the fields you want to change.', + inputs: [ + updatePartnerNameInput, + updateUrlInput, + updateMethodInput, + updateQueryParamsInput, + updateAdditionalHeadersInput, + updateRetryCountInput, + updateRetryBackoffMsInput + ], + apiCall: { + method: 'PUT', + url: '/api/partner_config/update', + getPayload: (inputs) => { + const payload = { name: inputs.updatePartnerName }; + if (inputs.updateUrl) payload.url = inputs.updateUrl; + if (inputs.updateMethod) payload.method = inputs.updateMethod; + if (inputs.updateRetryCount) payload.retry_count = parseInt(inputs.updateRetryCount); + if (inputs.updateRetryBackoffMs) payload.retry_backoff_ms = parseInt(inputs.updateRetryBackoffMs); + + // Parse comma-separated inputs into arrays (only if provided) + if (inputs.updateQueryParams && inputs.updateQueryParams.trim()) { + payload.query_params = inputs.updateQueryParams.split(',').map(s => s.trim()).filter(s => s); + } + if (inputs.updateAdditionalHeaders && inputs.updateAdditionalHeaders.trim()) { + payload.additional_headers = inputs.updateAdditionalHeaders.split(',').map(s => s.trim()).filter(s => s); + } + + return payload; + } + } + } + ], + danger: [ + { + id: 'deletePartnerConfig', + title: 'Delete Partner Config', role: 'elevated', + description: 'This will remove the partner configuration.', + inputs: [deletePartnerNameInput], + apiCall: { + method: 'DELETE', + getUrl: (inputs) => `/api/partner_config/delete?partner_name=${encodeURIComponent(inputs.deletePartnerName)}` + } + }, + { + id: 'bulkReplacePartnerConfigs', + title: 'Replace ALL Partner Configs', + role: 'superuser', + description: 'This will completely replace ALL partner configurations.', + confirmationText: 'You are about to replace ALL partner configurations. This will delete all existing partner configs and replace them with the provided configuration.', inputs: [ - configDataMultilineInput + bulkReplaceConfigInput ], apiCall: { method: 'POST', - url: '/api/partner_config/update', + url: '/api/partner_config/bulk_replace', getPayload: (inputs) => { - // Validate and parse JSON + // Validate and parse JSON array try { - return JSON.parse(inputs.configData); + const configs = JSON.parse(inputs.bulkReplaceConfigData); + if (!Array.isArray(configs)) { + throw new Error('Configuration must be a JSON array'); + } + return configs; } catch (e) { throw new Error('Invalid JSON format: ' + e.message); } diff --git a/webroot/js/httpClient.js b/webroot/js/httpClient.js index 21a996bb..78c6a874 100644 --- a/webroot/js/httpClient.js +++ b/webroot/js/httpClient.js @@ -1,11 +1,24 @@ class HttpClient { async makeRequest(url, options = {}) { const response = await fetch(url, options); - + if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + // Try to parse error response body for detailed error message + let errorMessage = response.statusText || 'Error'; + try { + const errorBody = await response.text(); + if (errorBody) { + const errorJson = JSON.parse(errorBody); + if (errorJson.message) { + errorMessage = errorJson.message; + } + } + } catch (e) { + // If parsing fails, fall back to statusText + } + throw new Error(`HTTP ${response.status}: ${errorMessage}`); } - + return this.parseResponse(response); }