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