From 650f5143f3ac3658437ab716eceb09f2439561b8 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Wed, 11 Feb 2026 10:23:46 +1100 Subject: [PATCH 01/32] rename /api/partner_config/get to /api/partner_config/list --- src/main/java/com/uid2/admin/vertx/Endpoints.java | 2 +- .../uid2/admin/vertx/service/PartnerConfigService.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index 414d4377..482dbb78 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -60,7 +60,7 @@ 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_UPDATE("/api/partner_config/update"), API_PRIVATE_SITES_REFRESH("/api/private-sites/refresh"), 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..7b840ee7 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -15,7 +15,7 @@ import java.util.Collections; import java.util.List; -import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_GET; +import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_LIST; import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_UPDATE; public class PartnerConfigService implements IService { @@ -36,8 +36,8 @@ public PartnerConfigService(AdminAuthMiddleware auth, @Override public void setupRoutes(Router router) { - router.get(API_PARTNER_CONFIG_GET.toString()).handler( - auth.handle(this::handlePartnerConfigGet, Role.MAINTAINER)); + router.get(API_PARTNER_CONFIG_LIST.toString()).handler( + auth.handle(this::handlePartnerConfigList, Role.MAINTAINER)); router.post(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> { synchronized (writeLock) { this.handlePartnerConfigUpdate(ctx); @@ -45,7 +45,7 @@ public void setupRoutes(Router router) { }, new AuditParams(Collections.emptyList(), List.of("partner_id", "config")), Role.PRIVILEGED)); } - private void handlePartnerConfigGet(RoutingContext rc) { + private void handlePartnerConfigList(RoutingContext rc) { try { String config = this.partnerConfigProvider.getConfig(); rc.response() From 06add62fea8fb72186bcd74cb63eccb8ab548ed9 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Wed, 11 Feb 2026 11:40:01 +1100 Subject: [PATCH 02/32] implement /api/partner_config/:partner_name --- .../java/com/uid2/admin/vertx/Endpoints.java | 1 + .../vertx/service/PartnerConfigService.java | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index 482dbb78..66b88516 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -61,6 +61,7 @@ public enum Endpoints { API_OPERATOR_ROLES("/api/operator/roles"), API_PARTNER_CONFIG_LIST("/api/partner_config/list"), + API_PARTNER_CONFIG_GET("/api/partner_config/:partner_name"), API_PARTNER_CONFIG_UPDATE("/api/partner_config/update"), API_PRIVATE_SITES_REFRESH("/api/private-sites/refresh"), 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 7b840ee7..0b2daae6 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -9,6 +9,7 @@ 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; @@ -16,6 +17,7 @@ import java.util.List; 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_UPDATE; public class PartnerConfigService implements IService { @@ -38,6 +40,8 @@ public PartnerConfigService(AdminAuthMiddleware auth, 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) -> { synchronized (writeLock) { this.handlePartnerConfigUpdate(ctx); @@ -56,6 +60,34 @@ private void handlePartnerConfigList(RoutingContext rc) { } } + private void handlePartnerConfigGet(RoutingContext rc) { + try { + final String partnerName = rc.pathParam("partner_name"); + if (partnerName == null) { + ResponseUtil.error(rc, 400, "Partner name is required"); + return; + } + + String config = this.partnerConfigProvider.getConfig(); + JsonObject allPartnerConfigs = new JsonObject(config); + + // Look for the specific partner + if (allPartnerConfigs.containsKey(partnerName)) { + JsonObject partnerConfig = allPartnerConfigs.getJsonObject(partnerName); + + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(partnerConfig.encode()); + + } else { + // Partner not found + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + } + } catch (Exception e) { + rc.fail(500, e); + } + } + private void handlePartnerConfigUpdate(RoutingContext rc) { try { // refresh manually From e524a95ed950ebad3ee180a1cda00a8f9ff0d34b Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Wed, 11 Feb 2026 13:13:41 +1100 Subject: [PATCH 03/32] use JsonArray --- .../vertx/service/PartnerConfigService.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 0b2daae6..104cd99e 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -69,20 +69,21 @@ private void handlePartnerConfigGet(RoutingContext rc) { } String config = this.partnerConfigProvider.getConfig(); - JsonObject allPartnerConfigs = new JsonObject(config); + JsonArray allPartnerConfigs = new JsonArray(config); // Look for the specific partner - if (allPartnerConfigs.containsKey(partnerName)) { - JsonObject partnerConfig = allPartnerConfigs.getJsonObject(partnerName); - - rc.response() - .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .end(partnerConfig.encode()); - - } else { - // Partner not found - ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + for (int i = 0; i < allPartnerConfigs.size(); i++) { + JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); + if (partnerName.equals(partnerConfig.getString("name"))) { + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(partnerConfig.encode()); + return; + } } + + // Partner not found + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); } catch (Exception e) { rc.fail(500, e); } From a10418290ec79519b0a4d6657b7ed0139a01a78d Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Wed, 11 Feb 2026 13:22:18 +1100 Subject: [PATCH 04/32] ignore case when comparing Partner names --- .../java/com/uid2/admin/vertx/service/PartnerConfigService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 104cd99e..c144bc03 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -74,7 +74,7 @@ private void handlePartnerConfigGet(RoutingContext rc) { // Look for the specific partner for (int i = 0; i < allPartnerConfigs.size(); i++) { JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); - if (partnerName.equals(partnerConfig.getString("name"))) { + if (partnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") .end(partnerConfig.encode()); From 628a4f30f87c879c079c87eaba54f32a2bde6ce0 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Wed, 11 Feb 2026 13:28:29 +1100 Subject: [PATCH 05/32] change endpoint path to /get/:partner_name --- src/main/java/com/uid2/admin/vertx/Endpoints.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index 66b88516..b4bc3880 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -61,7 +61,7 @@ public enum Endpoints { API_OPERATOR_ROLES("/api/operator/roles"), API_PARTNER_CONFIG_LIST("/api/partner_config/list"), - API_PARTNER_CONFIG_GET("/api/partner_config/:partner_name"), + API_PARTNER_CONFIG_GET("/api/partner_config/get/:partner_name"), API_PARTNER_CONFIG_UPDATE("/api/partner_config/update"), API_PRIVATE_SITES_REFRESH("/api/private-sites/refresh"), From 84268a99c83f8039f58e3e3f686af1432d99ab65 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 12 Feb 2026 09:12:30 +1100 Subject: [PATCH 06/32] implement /api/partner_config/add --- .../java/com/uid2/admin/vertx/Endpoints.java | 1 + .../vertx/service/PartnerConfigService.java | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index b4bc3880..31aebe36 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -62,6 +62,7 @@ public enum Endpoints { 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_PRIVATE_SITES_REFRESH("/api/private-sites/refresh"), 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 c144bc03..c5728116 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -18,6 +18,7 @@ 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; public class PartnerConfigService implements IService { @@ -42,6 +43,8 @@ public void setupRoutes(Router router) { 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_ADD.toString()).handler( + auth.handle(this::handlePartnerConfigAdd, Role.MAINTAINER)); router.post(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> { synchronized (writeLock) { this.handlePartnerConfigUpdate(ctx); @@ -89,6 +92,42 @@ private void handlePartnerConfigGet(RoutingContext rc) { } } + private void handlePartnerConfigAdd(RoutingContext rc) { + try { + + JsonObject newConfig = rc.body().asJsonObject(); + if (newConfig == null) { + ResponseUtil.error(rc, 400, "Body must include Partner config"); + return; + } + + // TODO: Validate JSON config structure + + String newPartnerName = newConfig.getString("name"); + + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + + // Validate partner doesn't exist + for (int i = 0; i < allPartnerConfigs.size(); i++) { + JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); + if (newPartnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { + ResponseUtil.error(rc, 400, "Partner '" + newPartnerName + "' already exists"); + return; + } + } + + // Upload + allPartnerConfigs.add(newConfig); + storageManager.upload(allPartnerConfigs); + + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end("\"success\""); + } catch (Exception e) { + rc.fail(500, e); + } + } + private void handlePartnerConfigUpdate(RoutingContext rc) { try { // refresh manually From 2ee8d0a3208cf710d73d7bc7786a5599a85f14c3 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 12 Feb 2026 10:57:09 +1100 Subject: [PATCH 07/32] implement update --- .../vertx/service/PartnerConfigService.java | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) 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 c5728116..163e82d0 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -43,13 +43,17 @@ public void setupRoutes(Router router) { 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_ADD.toString()).handler( - auth.handle(this::handlePartnerConfigAdd, Role.MAINTAINER)); + + 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.post(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)); } private void handlePartnerConfigList(RoutingContext rc) { @@ -130,15 +134,37 @@ private void handlePartnerConfigAdd(RoutingContext rc) { private void handlePartnerConfigUpdate(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"); + + JsonObject newConfig = rc.body().asJsonObject(); + if (newConfig == null) { + ResponseUtil.error(rc, 400, "Body must include Partner config"); return; } - storageManager.upload(partners); + // TODO: Validate JSON config structure + + String newPartnerName = newConfig.getString("name"); + + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + + // Validate partner exists + int existingPartnerIdx = -1; + for (int i = 0; i < allPartnerConfigs.size(); i++) { + JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); + if (newPartnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { + existingPartnerIdx = i; + break; + } + } + + if (existingPartnerIdx == -1) { + ResponseUtil.error(rc, 404, "Partner '" + newPartnerName + "' not found"); + return; + } + + // Upload + allPartnerConfigs.set(existingPartnerIdx, newConfig); + storageManager.upload(allPartnerConfigs); rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") @@ -147,4 +173,24 @@ private void handlePartnerConfigUpdate(RoutingContext rc) { rc.fail(500, e); } } + +// private void handlePartnerConfigBulkUpdate(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"); +// return; +// } +// +// storageManager.upload(partners); +// +// rc.response() +// .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") +// .end("\"success\""); +// } catch (Exception e) { +// rc.fail(500, e); +// } +// } } From 7e9d5572c691b5965040678274b9ae17df546a1f Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 12 Feb 2026 14:37:50 +1100 Subject: [PATCH 08/32] implement delete --- .../java/com/uid2/admin/vertx/Endpoints.java | 1 + .../vertx/service/PartnerConfigService.java | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index 31aebe36..c4b5666c 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -64,6 +64,7 @@ public enum Endpoints { 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_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 163e82d0..32b58788 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -20,6 +20,7 @@ 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; public class PartnerConfigService implements IService { private final AdminAuthMiddleware auth; @@ -49,11 +50,16 @@ public void setupRoutes(Router router) { this.handlePartnerConfigAdd(ctx); } }, new AuditParams(Collections.emptyList(), List.of("name")), Role.MAINTAINER)); - router.post(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> { + router.put(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> { synchronized (writeLock) { this.handlePartnerConfigUpdate(ctx); } }, 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.MAINTAINER)); } private void handlePartnerConfigList(RoutingContext rc) { @@ -174,6 +180,44 @@ private void handlePartnerConfigUpdate(RoutingContext rc) { } } + 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(); + + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + + // Find partner config + int existingPartnerIdx = -1; + for (int i = 0; i < allPartnerConfigs.size(); i++) { + JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); + if (partnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { + existingPartnerIdx = i; + break; + } + } + + if (existingPartnerIdx == -1) { + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + return; + } + + // Remove + allPartnerConfigs.remove(existingPartnerIdx); + storageManager.upload(allPartnerConfigs); + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end("\"success\""); + } catch (Exception e) { + rc.fail(500, e); + } + } + // private void handlePartnerConfigBulkUpdate(RoutingContext rc) { // try { // // refresh manually From f89f61afca769d642dadd92780310eb4ebba6f5d Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 12 Feb 2026 15:50:52 +1100 Subject: [PATCH 09/32] reinstate bulk replace functionality at /bulk_replace --- .../java/com/uid2/admin/vertx/Endpoints.java | 1 + .../vertx/service/PartnerConfigService.java | 45 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index c4b5666c..7fb5df32 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -65,6 +65,7 @@ public enum Endpoints { 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 32b58788..a1917c14 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -21,6 +21,7 @@ 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; @@ -60,6 +61,11 @@ public void setupRoutes(Router router) { this.handlePartnerConfigDelete(ctx); } }, new AuditParams(List.of("partner_name"), Collections.emptyList()), Role.MAINTAINER)); + 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.PRIVILEGED)); } private void handlePartnerConfigList(RoutingContext rc) { @@ -218,23 +224,24 @@ private void handlePartnerConfigDelete(RoutingContext rc) { } } -// private void handlePartnerConfigBulkUpdate(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"); -// return; -// } -// -// storageManager.upload(partners); -// -// rc.response() -// .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") -// .end("\"success\""); -// } catch (Exception e) { -// rc.fail(500, e); -// } -// } + private void handlePartnerConfigBulkReplace(RoutingContext rc) { + try { + // refresh manually + this.partnerConfigProvider.loadContent(); + JsonArray partners = rc.body().asJsonArray(); + // TODO: Validate configs + if (partners == null) { + ResponseUtil.error(rc, 400, "Body must be non empty"); + return; + } + + storageManager.upload(partners); + + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end("\"success\""); + } catch (Exception e) { + rc.fail(500, e); + } + } } From 6ce4c29e7c3f79c1d197e879f478c600512dc95e Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 12 Feb 2026 16:47:06 +1100 Subject: [PATCH 10/32] add validation added/updated configs --- .../vertx/service/PartnerConfigService.java | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) 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 a1917c14..98fd35a5 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -117,7 +117,10 @@ private void handlePartnerConfigAdd(RoutingContext rc) { return; } - // TODO: Validate JSON config structure + // Validate required fields + if (!validatePartnerConfig(rc, newConfig)) { + return; + } String newPartnerName = newConfig.getString("name"); @@ -153,7 +156,10 @@ private void handlePartnerConfigUpdate(RoutingContext rc) { return; } - // TODO: Validate JSON config structure + // Validate required fields + if (!validatePartnerConfig(rc, newConfig)) { + return; + } String newPartnerName = newConfig.getString("name"); @@ -229,12 +235,25 @@ private void handlePartnerConfigBulkReplace(RoutingContext rc) { // refresh manually this.partnerConfigProvider.loadContent(); JsonArray partners = rc.body().asJsonArray(); - // TODO: Validate configs - if (partners == null) { - ResponseUtil.error(rc, 400, "Body must be non empty"); + + if (partners == null || partners.isEmpty()) { + ResponseUtil.error(rc, 400, "Body must be a non-empty array"); return; } + // 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; + } + } + storageManager.upload(partners); rc.response() @@ -244,4 +263,35 @@ private void handlePartnerConfigBulkReplace(RoutingContext rc) { rc.fail(500, e); } } + + private boolean validatePartnerConfig(RoutingContext rc, JsonObject config) { + 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; + } + + return true; + } } From ea38ac6367ffd4297aea79cc40337744656fc660 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 12 Feb 2026 17:26:55 +1100 Subject: [PATCH 11/32] add unit tests --- .../admin/vertx/PartnerConfigServiceTest.java | 483 ++++++++++++++++++ .../admin/vertx/test/ServiceTestBase.java | 10 + 2 files changed, 493 insertions(+) create mode 100644 src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java 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..97979122 --- /dev/null +++ b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java @@ -0,0 +1,483 @@ +package com.uid2.admin.vertx; + +import com.uid2.admin.store.reader.RotatingPartnerStore; +import com.uid2.admin.store.writer.PartnerStoreWriter; +import com.uid2.admin.vertx.service.IService; +import com.uid2.admin.vertx.service.PartnerConfigService; +import com.uid2.admin.vertx.test.ServiceTestBase; +import com.uid2.admin.vertx.test.TestHandler; +import com.uid2.shared.Const; +import com.uid2.shared.Utils; +import com.uid2.shared.auth.Role; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +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.junit.jupiter.params.provider.EnumSource; +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; + } + + // 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 -> { + assertAll( + "listPartnerConfigsWithConfigs", + () -> assertEquals(200, response.statusCode()), + () -> assertEquals(2, response.bodyAsJsonArray().size()), + () -> assertEquals("partner1", response.bodyAsJsonArray().getJsonObject(0).getString("name")), + () -> assertEquals("partner2", response.bodyAsJsonArray().getJsonObject(1).getString("name"))); + testContext.completeNow(); + }); + } + + @ParameterizedTest + @EnumSource(value = Role.class, names = {"MAINTAINER", "PRIVILEGED", "SUPER_USER"}, mode = EnumSource.Mode.EXCLUDE) + void listPartnerConfigsUnauthorized(Role role, Vertx vertx, VertxTestContext testContext) { + fakeAuth(role); + setPartnerConfigs(); + + 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 -> { + assertAll( + "getPartnerConfigByNameSuccess", + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("partner1", response.bodyAsJsonObject().getString("name")), + () -> assertEquals("https://example.com/webhook1", response.bodyAsJsonObject().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 -> { + assertAll( + "getPartnerConfigByNameCaseInsensitive", + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("Partner1", response.bodyAsJsonObject().getString("name"))); + 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 -> { + assertAll( + "getPartnerConfigByNameNotFound", + () -> assertEquals(404, response.statusCode()), + () -> assertEquals("Partner 'nonexistent' not found", response.bodyAsJsonObject().getString("message"))); + 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 -> { + assertAll( + "addPartnerConfigSuccess", + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("\"success\"", response.bodyAsString())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + return arr.size() == 2 && + arr.getJsonObject(0).getString("name").equals("existing-partner") && + arr.getJsonObject(1).getString("name").equals("new-partner"); + })); + 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 -> { + assertAll( + "addPartnerConfigAlreadyExists", + () -> assertEquals(400, response.statusCode()), + () -> assertEquals("Partner 'existing-partner' already exists", response.bodyAsJsonObject().getString("message"))); + 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 -> { + assertAll( + "addPartnerConfigAlreadyExistsCaseInsensitive", + () -> assertEquals(400, response.statusCode()), + () -> assertEquals("Partner 'EXISTING-PARTNER' already exists", response.bodyAsJsonObject().getString("message"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @ParameterizedTest + @CsvSource(value = { + "'',Body must include Partner config", + "'{\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_count\":600,\"retry_backoff_ms\":6000}',name", + "'{\"name\":\"test\",\"method\":\"GET\",\"retry_count\":600,\"retry_backoff_ms\":6000}',url", + "'{\"name\":\"test\",\"url\":\"https://example.com\",\"retry_count\":600,\"retry_backoff_ms\":6000}',method", + "'{\"name\":\"test\",\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_backoff_ms\":6000}',retry_count", + "'{\"name\":\"test\",\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_count\":600}',retry_backoff_ms" + }) + void addPartnerConfigMissingRequiredFields(String body, String expectedMessageFragment, Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + setPartnerConfigs(); + + post(vertx, testContext, "api/partner_config/add", body, response -> { + assertAll( + () -> assertEquals(400, response.statusCode()), + () -> assertTrue(response.bodyAsJsonObject().getString("message").contains(expectedMessageFragment)) + ); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @ParameterizedTest + @EnumSource(value = Role.class, names = {"MAINTAINER", "PRIVILEGED", "SUPER_USER"}, mode = EnumSource.Mode.EXCLUDE) + void addPartnerConfigUnauthorized(Role role, Vertx vertx, VertxTestContext testContext) { + fakeAuth(role); + setPartnerConfigs(); + + 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 -> { + assertAll( + "updatePartnerConfigSuccess", + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("\"success\"", response.bodyAsString())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + return arr.size() == 2 && + arr.getJsonObject(1).getString("name").equals("partner2") && + arr.getJsonObject(1).getString("url").equals("https://updated.com/webhook"); + })); + 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 -> { + assertAll( + "updatePartnerConfigNotFound", + () -> assertEquals(404, response.statusCode()), + () -> assertEquals("Partner 'nonexistent' not found", response.bodyAsJsonObject().getString("message"))); + 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(); + }); + } + + @ParameterizedTest + @CsvSource(value = { + "'',Body must include Partner config", + "'{\"name\":\"partner1\",\"url\":\"https://new.com\"}',method" + }) + void updatePartnerConfigMissingRequiredFields(String body, String expectedMessageFragment, Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject existingConfig = createPartnerConfig("partner1", "https://old.com/webhook"); + setPartnerConfigs(existingConfig); + + put(vertx, testContext, "api/partner_config/update", body, response -> { + assertAll( + () -> assertEquals(400, response.statusCode()), + () -> assertTrue(response.bodyAsJsonObject().getString("message").contains(expectedMessageFragment)) + ); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + // DELETE endpoint tests + @Test + void deletePartnerConfigSuccess(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); + + delete(vertx, testContext, "api/partner_config/delete?partner_name=partner1", response -> { + assertAll( + "deletePartnerConfigSuccess", + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("\"success\"", response.bodyAsString())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + return arr.size() == 1 && + arr.getJsonObject(0).getString("name").equals("partner2"); + })); + testContext.completeNow(); + }); + } + + @Test + void deletePartnerConfigNotFound(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + JsonObject config = createPartnerConfig("partner1", "https://example.com/webhook"); + setPartnerConfigs(config); + + delete(vertx, testContext, "api/partner_config/delete?partner_name=nonexistent", response -> { + assertAll( + "deletePartnerConfigNotFound", + () -> assertEquals(404, response.statusCode()), + () -> assertEquals("Partner 'nonexistent' not found", response.bodyAsJsonObject().getString("message"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void deletePartnerConfigCaseInsensitive(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + 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.MAINTAINER); + setPartnerConfigs(); + + delete(vertx, testContext, "api/partner_config/delete", response -> { + assertAll( + "deletePartnerConfigNoPartnerName", + () -> assertEquals(400, response.statusCode()), + () -> assertEquals("Partner name is required", response.bodyAsJsonObject().getString("message"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + // BULK_REPLACE endpoint tests + @Test + void bulkReplacePartnerConfigsSuccess(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + + 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 -> { + assertAll( + "bulkReplacePartnerConfigsSuccess", + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("\"success\"", response.bodyAsString())); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + return arr.size() == 2 && + arr.getJsonObject(0).getString("name").equals("partner1") && + arr.getJsonObject(1).getString("name").equals("partner2"); + })); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsEmptyArray(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + setPartnerConfigs(); + + JsonArray emptyConfigs = new JsonArray(); + + post(vertx, testContext, "api/partner_config/bulk_replace", emptyConfigs.encode(), response -> { + assertAll( + "bulkReplacePartnerConfigsEmptyArray", + () -> assertEquals(400, response.statusCode()), + () -> assertEquals("Body must be a non-empty array", response.bodyAsJsonObject().getString("message"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsNullBody(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + setPartnerConfigs(); + + post(vertx, testContext, "api/partner_config/bulk_replace", "", response -> { + assertAll( + "bulkReplacePartnerConfigsNullBody", + () -> assertEquals(400, response.statusCode()), + () -> assertEquals("Body must be a non-empty array", response.bodyAsJsonObject().getString("message"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @Test + void bulkReplacePartnerConfigsInvalidConfig(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + 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 -> { + assertAll( + "bulkReplacePartnerConfigsInvalidConfig", + () -> assertEquals(400, response.statusCode()), + () -> assertTrue(response.bodyAsJsonObject().getString("message").contains("url"))); + verify(partnerStoreWriter, never()).upload(any()); + testContext.completeNow(); + }); + } + + @ParameterizedTest + @EnumSource(value = Role.class, names = {"PRIVILEGED", "SUPER_USER"}, mode = EnumSource.Mode.EXCLUDE) + void bulkReplacePartnerConfigsUnauthorized(Role role, Vertx vertx, VertxTestContext testContext) { + fakeAuth(role); + setPartnerConfigs(); + + 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) { From ba04922f9491e564b48c4f963cab176d4288ade7 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 10:49:40 +1100 Subject: [PATCH 12/32] update response code --- .../java/com/uid2/admin/vertx/service/PartnerConfigService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 98fd35a5..2881c405 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -130,7 +130,7 @@ private void handlePartnerConfigAdd(RoutingContext rc) { for (int i = 0; i < allPartnerConfigs.size(); i++) { JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); if (newPartnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { - ResponseUtil.error(rc, 400, "Partner '" + newPartnerName + "' already exists"); + ResponseUtil.error(rc, 409, "Partner '" + newPartnerName + "' already exists"); return; } } From eca0b05fd030cd0d1c95557022373f50e80d5d34 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 10:50:30 +1100 Subject: [PATCH 13/32] update operations on Partner Management page --- webroot/adm/partner-config.html | 130 +++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 11 deletions(-) diff --git a/webroot/adm/partner-config.html b/webroot/adm/partner-config.html index 9ae48bcd..362cf4a7 100644 --- a/webroot/adm/partner-config.html +++ b/webroot/adm/partner-config.html @@ -20,43 +20,151 @@

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 + }; + + const addConfigInput = { + name: 'addConfigData', + label: 'Configuration Data', + required: true, + type: 'multi-line', + placeholder: 'Enter JSON configuration data (single partner object)...' + }; + + const updateConfigInput = { + name: 'updateConfigData', + label: 'Configuration Data', + required: true, + type: 'multi-line', + placeholder: 'Enter JSON configuration data (single partner object)...' + }; + + 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.parse(JSON.stringify(response, null, 2)) + 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.stringify(response, null, 2) } ], write: [ + { + id: 'addPartnerConfig', + title: 'Add Partner Config', + role: 'maintainer', + description: 'This will add a new partner config.', + inputs: [ + addConfigInput + ], + apiCall: { + method: 'POST', + url: '/api/partner_config/add', + getPayload: (inputs) => { + // Validate and parse JSON + try { + const config = JSON.parse(inputs.addConfigData); + if (!config.name) { + throw new Error('Partner config must include "name" field'); + } + return config; + } catch (e) { + throw new Error('Invalid JSON format: ' + e.message); + } + } + } + }, { id: 'updatePartnerConfig', title: 'Update Partner Config', - role: 'elevated', + role: 'maintainer', + description: 'This will replace an existing partner config.', inputs: [ - configDataMultilineInput + updateConfigInput ], apiCall: { - method: 'POST', + method: 'PUT', url: '/api/partner_config/update', getPayload: (inputs) => { // Validate and parse JSON try { - return JSON.parse(inputs.configData); + const config = JSON.parse(inputs.updateConfigData); + if (!config.name) { + throw new Error('Partner config must include "name" field'); + } + return config; + } catch (e) { + throw new Error('Invalid JSON format: ' + e.message); + } + } + } + }, + { + id: 'deletePartnerConfig', + title: 'Delete Partner Config', + role: 'maintainer', + description: 'This will remove the partner configuration.', + inputs: [deletePartnerNameInput], + apiCall: { + method: 'DELETE', + getUrl: (inputs) => `/api/partner_config/delete?partner_name=${encodeURIComponent(inputs.deletePartnerName)}` + } + } + ], + danger: [ + { + id: 'bulkReplacePartnerConfigs', + title: 'Replace ALL Partner Configs', + role: 'privileged', + 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: [ + bulkReplaceConfigInput + ], + apiCall: { + method: 'POST', + url: '/api/partner_config/bulk_replace', + getPayload: (inputs) => { + // Validate and parse JSON array + try { + 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); } From 28ccf16f34f3b0580d1768f4a9b07bf5ec5a2351 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 10:56:27 +1100 Subject: [PATCH 14/32] allow bulk update to include an empty array --- .../com/uid2/admin/vertx/service/PartnerConfigService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2881c405..040f6fa7 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -236,8 +236,8 @@ private void handlePartnerConfigBulkReplace(RoutingContext rc) { this.partnerConfigProvider.loadContent(); JsonArray partners = rc.body().asJsonArray(); - if (partners == null || partners.isEmpty()) { - ResponseUtil.error(rc, 400, "Body must be a non-empty array"); + if (partners == null) { + ResponseUtil.error(rc, 400, "Body must be non-empty"); return; } From e715a00d0fee4aff02b13bc2b932a80064362959 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 13:33:57 +1100 Subject: [PATCH 15/32] simplify unit tests --- .../admin/vertx/PartnerConfigServiceTest.java | 174 +++++++----------- 1 file changed, 64 insertions(+), 110 deletions(-) diff --git a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java index 97979122..0bcc0caf 100644 --- a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java @@ -16,6 +16,7 @@ import io.vertx.ext.web.client.HttpResponse; import io.vertx.ext.web.client.WebClient; 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.CsvSource; @@ -62,21 +63,20 @@ void listPartnerConfigsWithConfigs(Vertx vertx, VertxTestContext testContext) { setPartnerConfigs(config1, config2); get(vertx, testContext, "api/partner_config/list", response -> { + JsonArray result = response.bodyAsJsonArray(); assertAll( - "listPartnerConfigsWithConfigs", - () -> assertEquals(200, response.statusCode()), - () -> assertEquals(2, response.bodyAsJsonArray().size()), - () -> assertEquals("partner1", response.bodyAsJsonArray().getJsonObject(0).getString("name")), - () -> assertEquals("partner2", response.bodyAsJsonArray().getJsonObject(1).getString("name"))); + () -> assertEquals(200, response.statusCode()), + () -> assertEquals(2, result.size()), + () -> assertEquals("partner1", result.getJsonObject(0).getString("name")), + () -> assertEquals("partner2", result.getJsonObject(1).getString("name")) + ); testContext.completeNow(); }); } - @ParameterizedTest - @EnumSource(value = Role.class, names = {"MAINTAINER", "PRIVILEGED", "SUPER_USER"}, mode = EnumSource.Mode.EXCLUDE) - void listPartnerConfigsUnauthorized(Role role, Vertx vertx, VertxTestContext testContext) { - fakeAuth(role); - setPartnerConfigs(); + @Test + void listPartnerConfigsUnauthorized(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAPPER); get(vertx, testContext, "api/partner_config/list", response -> { assertEquals(401, response.statusCode()); @@ -94,11 +94,12 @@ void getPartnerConfigByNameSuccess(Vertx vertx, VertxTestContext testContext) { setPartnerConfigs(config1, config2); get(vertx, testContext, "api/partner_config/get/partner1", response -> { + JsonObject result = response.bodyAsJsonObject(); assertAll( - "getPartnerConfigByNameSuccess", - () -> assertEquals(200, response.statusCode()), - () -> assertEquals("partner1", response.bodyAsJsonObject().getString("name")), - () -> assertEquals("https://example.com/webhook1", response.bodyAsJsonObject().getString("url"))); + () -> assertEquals(200, response.statusCode()), + () -> assertEquals("partner1", result.getString("name")), + () -> assertEquals("https://example.com/webhook1", result.getString("url")) + ); testContext.completeNow(); }); } @@ -111,10 +112,7 @@ void getPartnerConfigByNameCaseInsensitive(Vertx vertx, VertxTestContext testCon setPartnerConfigs(config); get(vertx, testContext, "api/partner_config/get/PARTNER1", response -> { - assertAll( - "getPartnerConfigByNameCaseInsensitive", - () -> assertEquals(200, response.statusCode()), - () -> assertEquals("Partner1", response.bodyAsJsonObject().getString("name"))); + assertEquals(200, response.statusCode()); testContext.completeNow(); }); } @@ -127,10 +125,7 @@ void getPartnerConfigByNameNotFound(Vertx vertx, VertxTestContext testContext) { setPartnerConfigs(config); get(vertx, testContext, "api/partner_config/get/nonexistent", response -> { - assertAll( - "getPartnerConfigByNameNotFound", - () -> assertEquals(404, response.statusCode()), - () -> assertEquals("Partner 'nonexistent' not found", response.bodyAsJsonObject().getString("message"))); + assertEquals(404, response.statusCode()); testContext.completeNow(); }); } @@ -146,15 +141,13 @@ void addPartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { JsonObject newConfig = createPartnerConfig("new-partner", "https://new.com/webhook"); post(vertx, testContext, "api/partner_config/add", newConfig.encode(), response -> { - assertAll( - "addPartnerConfigSuccess", - () -> assertEquals(200, response.statusCode()), - () -> assertEquals("\"success\"", response.bodyAsString())); + assertEquals(200, response.statusCode()); verify(partnerStoreWriter).upload(argThat(array -> { JsonArray arr = (JsonArray) array; - return arr.size() == 2 && - arr.getJsonObject(0).getString("name").equals("existing-partner") && - arr.getJsonObject(1).getString("name").equals("new-partner"); + 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(); }); @@ -170,10 +163,7 @@ void addPartnerConfigAlreadyExists(Vertx vertx, VertxTestContext testContext) { JsonObject duplicateConfig = createPartnerConfig("existing-partner", "https://new.com/webhook"); post(vertx, testContext, "api/partner_config/add", duplicateConfig.encode(), response -> { - assertAll( - "addPartnerConfigAlreadyExists", - () -> assertEquals(400, response.statusCode()), - () -> assertEquals("Partner 'existing-partner' already exists", response.bodyAsJsonObject().getString("message"))); + assertEquals(409, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); @@ -189,10 +179,7 @@ void addPartnerConfigAlreadyExistsCaseInsensitive(Vertx vertx, VertxTestContext JsonObject duplicateConfig = createPartnerConfig("EXISTING-PARTNER", "https://new.com/webhook"); post(vertx, testContext, "api/partner_config/add", duplicateConfig.encode(), response -> { - assertAll( - "addPartnerConfigAlreadyExistsCaseInsensitive", - () -> assertEquals(400, response.statusCode()), - () -> assertEquals("Partner 'EXISTING-PARTNER' already exists", response.bodyAsJsonObject().getString("message"))); + assertEquals(409, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); @@ -200,32 +187,27 @@ void addPartnerConfigAlreadyExistsCaseInsensitive(Vertx vertx, VertxTestContext @ParameterizedTest @CsvSource(value = { - "'',Body must include Partner config", - "'{\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_count\":600,\"retry_backoff_ms\":6000}',name", - "'{\"name\":\"test\",\"method\":\"GET\",\"retry_count\":600,\"retry_backoff_ms\":6000}',url", - "'{\"name\":\"test\",\"url\":\"https://example.com\",\"retry_count\":600,\"retry_backoff_ms\":6000}',method", - "'{\"name\":\"test\",\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_backoff_ms\":6000}',retry_count", - "'{\"name\":\"test\",\"url\":\"https://example.com\",\"method\":\"GET\",\"retry_count\":600}',retry_backoff_ms" + "''", + "'{\"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, String expectedMessageFragment, Vertx vertx, VertxTestContext testContext) { + void addPartnerConfigMissingRequiredFields(String body, Vertx vertx, VertxTestContext testContext) { fakeAuth(Role.MAINTAINER); setPartnerConfigs(); post(vertx, testContext, "api/partner_config/add", body, response -> { - assertAll( - () -> assertEquals(400, response.statusCode()), - () -> assertTrue(response.bodyAsJsonObject().getString("message").contains(expectedMessageFragment)) - ); + assertEquals(400, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); } - @ParameterizedTest - @EnumSource(value = Role.class, names = {"MAINTAINER", "PRIVILEGED", "SUPER_USER"}, mode = EnumSource.Mode.EXCLUDE) - void addPartnerConfigUnauthorized(Role role, Vertx vertx, VertxTestContext testContext) { - fakeAuth(role); - setPartnerConfigs(); + @Test + void addPartnerConfigUnauthorized(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAPPER); JsonObject newConfig = createPartnerConfig("new-partner", "https://new.com/webhook"); @@ -248,15 +230,13 @@ void updatePartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { JsonObject updatedConfig = createPartnerConfig("partner2", "https://updated.com/webhook"); put(vertx, testContext, "api/partner_config/update", updatedConfig.encode(), response -> { - assertAll( - "updatePartnerConfigSuccess", - () -> assertEquals(200, response.statusCode()), - () -> assertEquals("\"success\"", response.bodyAsString())); + assertEquals(200, response.statusCode()); verify(partnerStoreWriter).upload(argThat(array -> { JsonArray arr = (JsonArray) array; - return arr.size() == 2 && - arr.getJsonObject(1).getString("name").equals("partner2") && - arr.getJsonObject(1).getString("url").equals("https://updated.com/webhook"); + 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(); }); @@ -272,10 +252,7 @@ void updatePartnerConfigNotFound(Vertx vertx, VertxTestContext testContext) { JsonObject updateConfig = createPartnerConfig("nonexistent", "https://new.com/webhook"); put(vertx, testContext, "api/partner_config/update", updateConfig.encode(), response -> { - assertAll( - "updatePartnerConfigNotFound", - () -> assertEquals(404, response.statusCode()), - () -> assertEquals("Partner 'nonexistent' not found", response.bodyAsJsonObject().getString("message"))); + assertEquals(404, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); @@ -299,20 +276,17 @@ void updatePartnerConfigCaseInsensitive(Vertx vertx, VertxTestContext testContex @ParameterizedTest @CsvSource(value = { - "'',Body must include Partner config", - "'{\"name\":\"partner1\",\"url\":\"https://new.com\"}',method" + "''", + "'{\"name\":\"partner1\",\"url\":\"https://new.com\"}'" }) - void updatePartnerConfigMissingRequiredFields(String body, String expectedMessageFragment, Vertx vertx, VertxTestContext testContext) { + void updatePartnerConfigMissingRequiredFields(String body, Vertx vertx, VertxTestContext testContext) { fakeAuth(Role.MAINTAINER); JsonObject existingConfig = createPartnerConfig("partner1", "https://old.com/webhook"); setPartnerConfigs(existingConfig); put(vertx, testContext, "api/partner_config/update", body, response -> { - assertAll( - () -> assertEquals(400, response.statusCode()), - () -> assertTrue(response.bodyAsJsonObject().getString("message").contains(expectedMessageFragment)) - ); + assertEquals(400, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); @@ -328,14 +302,11 @@ void deletePartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { setPartnerConfigs(config1, config2); delete(vertx, testContext, "api/partner_config/delete?partner_name=partner1", response -> { - assertAll( - "deletePartnerConfigSuccess", - () -> assertEquals(200, response.statusCode()), - () -> assertEquals("\"success\"", response.bodyAsString())); + assertEquals(200, response.statusCode()); verify(partnerStoreWriter).upload(argThat(array -> { JsonArray arr = (JsonArray) array; - return arr.size() == 1 && - arr.getJsonObject(0).getString("name").equals("partner2"); + if (arr.size() != 1) return false; + return "partner2".equals(arr.getJsonObject(0).getString("name")); })); testContext.completeNow(); }); @@ -349,10 +320,7 @@ void deletePartnerConfigNotFound(Vertx vertx, VertxTestContext testContext) { setPartnerConfigs(config); delete(vertx, testContext, "api/partner_config/delete?partner_name=nonexistent", response -> { - assertAll( - "deletePartnerConfigNotFound", - () -> assertEquals(404, response.statusCode()), - () -> assertEquals("Partner 'nonexistent' not found", response.bodyAsJsonObject().getString("message"))); + assertEquals(404, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); @@ -378,10 +346,7 @@ void deletePartnerConfigNoPartnerName(Vertx vertx, VertxTestContext testContext) setPartnerConfigs(); delete(vertx, testContext, "api/partner_config/delete", response -> { - assertAll( - "deletePartnerConfigNoPartnerName", - () -> assertEquals(400, response.statusCode()), - () -> assertEquals("Partner name is required", response.bodyAsJsonObject().getString("message"))); + assertEquals(400, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); @@ -400,15 +365,12 @@ void bulkReplacePartnerConfigsSuccess(Vertx vertx, VertxTestContext testContext) newConfigs.add(createPartnerConfig("partner2", "https://p2.com/webhook")); post(vertx, testContext, "api/partner_config/bulk_replace", newConfigs.encode(), response -> { - assertAll( - "bulkReplacePartnerConfigsSuccess", - () -> assertEquals(200, response.statusCode()), - () -> assertEquals("\"success\"", response.bodyAsString())); + assertEquals(200, response.statusCode()); verify(partnerStoreWriter).upload(argThat(array -> { JsonArray arr = (JsonArray) array; - return arr.size() == 2 && - arr.getJsonObject(0).getString("name").equals("partner1") && - arr.getJsonObject(1).getString("name").equals("partner2"); + if (arr.size() != 2) return false; + return "partner1".equals(arr.getJsonObject(0).getString("name")) && + "partner2".equals(arr.getJsonObject(1).getString("name")); })); testContext.completeNow(); }); @@ -422,11 +384,11 @@ void bulkReplacePartnerConfigsEmptyArray(Vertx vertx, VertxTestContext testConte JsonArray emptyConfigs = new JsonArray(); post(vertx, testContext, "api/partner_config/bulk_replace", emptyConfigs.encode(), response -> { - assertAll( - "bulkReplacePartnerConfigsEmptyArray", - () -> assertEquals(400, response.statusCode()), - () -> assertEquals("Body must be a non-empty array", response.bodyAsJsonObject().getString("message"))); - verify(partnerStoreWriter, never()).upload(any()); + assertEquals(200, response.statusCode()); + verify(partnerStoreWriter).upload(argThat(array -> { + JsonArray arr = (JsonArray) array; + return arr.size() == 0; + })); testContext.completeNow(); }); } @@ -437,10 +399,7 @@ void bulkReplacePartnerConfigsNullBody(Vertx vertx, VertxTestContext testContext setPartnerConfigs(); post(vertx, testContext, "api/partner_config/bulk_replace", "", response -> { - assertAll( - "bulkReplacePartnerConfigsNullBody", - () -> assertEquals(400, response.statusCode()), - () -> assertEquals("Body must be a non-empty array", response.bodyAsJsonObject().getString("message"))); + assertEquals(400, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); @@ -456,20 +415,15 @@ void bulkReplacePartnerConfigsInvalidConfig(Vertx vertx, VertxTestContext testCo configs.add(new JsonObject().put("name", "partner2")); // Missing required fields post(vertx, testContext, "api/partner_config/bulk_replace", configs.encode(), response -> { - assertAll( - "bulkReplacePartnerConfigsInvalidConfig", - () -> assertEquals(400, response.statusCode()), - () -> assertTrue(response.bodyAsJsonObject().getString("message").contains("url"))); + assertEquals(400, response.statusCode()); verify(partnerStoreWriter, never()).upload(any()); testContext.completeNow(); }); } - @ParameterizedTest - @EnumSource(value = Role.class, names = {"PRIVILEGED", "SUPER_USER"}, mode = EnumSource.Mode.EXCLUDE) - void bulkReplacePartnerConfigsUnauthorized(Role role, Vertx vertx, VertxTestContext testContext) { - fakeAuth(role); - setPartnerConfigs(); + @Test + void bulkReplacePartnerConfigsUnauthorized(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); // Bulk replace requires PRIVILEGED JsonArray newConfigs = new JsonArray(); newConfigs.add(createPartnerConfig("partner1", "https://p1.com/webhook")); From 8a9e51752368db5e63c07eef859914bc438e37be Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 13:34:47 +1100 Subject: [PATCH 16/32] remove unused imports --- .../com/uid2/admin/vertx/PartnerConfigServiceTest.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java index 0bcc0caf..932f9f5a 100644 --- a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java @@ -1,26 +1,17 @@ package com.uid2.admin.vertx; import com.uid2.admin.store.reader.RotatingPartnerStore; -import com.uid2.admin.store.writer.PartnerStoreWriter; import com.uid2.admin.vertx.service.IService; import com.uid2.admin.vertx.service.PartnerConfigService; import com.uid2.admin.vertx.test.ServiceTestBase; -import com.uid2.admin.vertx.test.TestHandler; -import com.uid2.shared.Const; -import com.uid2.shared.Utils; import com.uid2.shared.auth.Role; import io.vertx.core.Vertx; -import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.ext.web.client.HttpResponse; -import io.vertx.ext.web.client.WebClient; 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.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mock; import static org.junit.jupiter.api.Assertions.*; From 83bc46c2e3ac20529a45bf1dbb412cf7a50f9ff3 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 14:48:24 +1100 Subject: [PATCH 17/32] check that no duplicate partner names are added during bulk replace --- .../vertx/service/PartnerConfigService.java | 11 +++++++++++ .../admin/vertx/PartnerConfigServiceTest.java | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) 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 040f6fa7..695e80fc 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -14,7 +14,9 @@ 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; @@ -241,6 +243,9 @@ private void handlePartnerConfigBulkReplace(RoutingContext rc) { 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); @@ -252,6 +257,12 @@ private void handlePartnerConfigBulkReplace(RoutingContext rc) { 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); diff --git a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java index 932f9f5a..2c488227 100644 --- a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java @@ -412,6 +412,22 @@ void bulkReplacePartnerConfigsInvalidConfig(Vertx vertx, VertxTestContext testCo }); } + @Test + void bulkReplacePartnerConfigsDuplicateNames(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.PRIVILEGED); + 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.MAINTAINER); // Bulk replace requires PRIVILEGED From 5e7d0f29f69a109a0676ffe55cfd3970c1545fb2 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 15:47:07 +1100 Subject: [PATCH 18/32] check for empty partner name --- .../com/uid2/admin/vertx/service/PartnerConfigService.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 695e80fc..85d5484d 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -84,13 +84,12 @@ private void handlePartnerConfigList(RoutingContext rc) { private void handlePartnerConfigGet(RoutingContext rc) { try { final String partnerName = rc.pathParam("partner_name"); - if (partnerName == null) { + if (partnerName == null || partnerName.isEmpty()) { ResponseUtil.error(rc, 400, "Partner name is required"); return; } - String config = this.partnerConfigProvider.getConfig(); - JsonArray allPartnerConfigs = new JsonArray(config); + JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); // Look for the specific partner for (int i = 0; i < allPartnerConfigs.size(); i++) { @@ -112,7 +111,6 @@ private void handlePartnerConfigGet(RoutingContext rc) { private void handlePartnerConfigAdd(RoutingContext rc) { try { - JsonObject newConfig = rc.body().asJsonObject(); if (newConfig == null) { ResponseUtil.error(rc, 400, "Body must include Partner config"); From 7786c5d9ccc93d1555d42c8a282eeab9be8701d5 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 15:51:41 +1100 Subject: [PATCH 19/32] add findPartnerIndex helper function --- .../vertx/service/PartnerConfigService.java | 63 +++++++------------ 1 file changed, 24 insertions(+), 39 deletions(-) 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 85d5484d..945c9b78 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -90,20 +90,16 @@ private void handlePartnerConfigGet(RoutingContext rc) { } JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); + int index = findPartnerIndex(allPartnerConfigs, partnerName); - // Look for the specific partner - for (int i = 0; i < allPartnerConfigs.size(); i++) { - JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); - if (partnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { - rc.response() - .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .end(partnerConfig.encode()); - return; - } + if (index == -1) { + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + return; } - // Partner not found - ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(allPartnerConfigs.getJsonObject(index).encode()); } catch (Exception e) { rc.fail(500, e); } @@ -123,16 +119,12 @@ private void handlePartnerConfigAdd(RoutingContext rc) { } String newPartnerName = newConfig.getString("name"); - JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); // Validate partner doesn't exist - for (int i = 0; i < allPartnerConfigs.size(); i++) { - JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); - if (newPartnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { - ResponseUtil.error(rc, 409, "Partner '" + newPartnerName + "' already exists"); - return; - } + if (findPartnerIndex(allPartnerConfigs, newPartnerName) != -1) { + ResponseUtil.error(rc, 409, "Partner '" + newPartnerName + "' already exists"); + return; } // Upload @@ -149,7 +141,6 @@ private void handlePartnerConfigAdd(RoutingContext rc) { private void handlePartnerConfigUpdate(RoutingContext rc) { try { - JsonObject newConfig = rc.body().asJsonObject(); if (newConfig == null) { ResponseUtil.error(rc, 400, "Body must include Partner config"); @@ -162,19 +153,10 @@ private void handlePartnerConfigUpdate(RoutingContext rc) { } String newPartnerName = newConfig.getString("name"); - JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); // Validate partner exists - int existingPartnerIdx = -1; - for (int i = 0; i < allPartnerConfigs.size(); i++) { - JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); - if (newPartnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { - existingPartnerIdx = i; - break; - } - } - + int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, newPartnerName); if (existingPartnerIdx == -1) { ResponseUtil.error(rc, 404, "Partner '" + newPartnerName + "' not found"); return; @@ -194,7 +176,6 @@ private void handlePartnerConfigUpdate(RoutingContext rc) { private void handlePartnerConfigDelete(RoutingContext rc) { try { - final List partnerNames = rc.queryParam("partner_name"); if (partnerNames.isEmpty()) { ResponseUtil.error(rc, 400, "Partner name is required"); @@ -205,15 +186,7 @@ private void handlePartnerConfigDelete(RoutingContext rc) { JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); // Find partner config - int existingPartnerIdx = -1; - for (int i = 0; i < allPartnerConfigs.size(); i++) { - JsonObject partnerConfig = allPartnerConfigs.getJsonObject(i); - if (partnerName.equalsIgnoreCase(partnerConfig.getString("name"))) { - existingPartnerIdx = i; - break; - } - } - + int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, partnerName); if (existingPartnerIdx == -1) { ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); return; @@ -303,4 +276,16 @@ private boolean validatePartnerConfig(RoutingContext rc, JsonObject config) { 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; + } } From d6bc7cf2dad57a361dd0cd391ee3361b7cff7f6f Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 16:01:55 +1100 Subject: [PATCH 20/32] manual refresh in every handler --- .../com/uid2/admin/vertx/service/PartnerConfigService.java | 5 +++++ 1 file changed, 5 insertions(+) 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 945c9b78..5e6dde96 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -72,6 +72,7 @@ public void setupRoutes(Router router) { private void handlePartnerConfigList(RoutingContext rc) { try { + this.partnerConfigProvider.loadContent(); String config = this.partnerConfigProvider.getConfig(); rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") @@ -89,6 +90,7 @@ private void handlePartnerConfigGet(RoutingContext rc) { return; } + this.partnerConfigProvider.loadContent(); JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); int index = findPartnerIndex(allPartnerConfigs, partnerName); @@ -119,6 +121,7 @@ private void handlePartnerConfigAdd(RoutingContext rc) { } String newPartnerName = newConfig.getString("name"); + this.partnerConfigProvider.loadContent(); JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); // Validate partner doesn't exist @@ -153,6 +156,7 @@ private void handlePartnerConfigUpdate(RoutingContext rc) { } String newPartnerName = newConfig.getString("name"); + this.partnerConfigProvider.loadContent(); JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); // Validate partner exists @@ -183,6 +187,7 @@ private void handlePartnerConfigDelete(RoutingContext rc) { } final String partnerName = partnerNames.getFirst(); + this.partnerConfigProvider.loadContent(); JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); // Find partner config From 673f0ec132e3c4a2d7e218e222fc6f42482b9269 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 16:04:26 +1100 Subject: [PATCH 21/32] return with updated config instead of just success --- .../com/uid2/admin/vertx/service/PartnerConfigService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5e6dde96..bed1c30a 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -136,7 +136,7 @@ private void handlePartnerConfigAdd(RoutingContext rc) { rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .end("\"success\""); + .end(newConfig.encode()); } catch (Exception e) { rc.fail(500, e); } @@ -172,7 +172,7 @@ private void handlePartnerConfigUpdate(RoutingContext rc) { rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .end("\"success\""); + .end(newConfig.encode()); } catch (Exception e) { rc.fail(500, e); } From 4e99a1fd5edca05d16566ee7b76353e144f10c15 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 16:08:57 +1100 Subject: [PATCH 22/32] add null check to validatePartnerConfig --- .../com/uid2/admin/vertx/service/PartnerConfigService.java | 5 +++++ 1 file changed, 5 insertions(+) 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 bed1c30a..fa849c27 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -252,6 +252,11 @@ private void handlePartnerConfigBulkReplace(RoutingContext rc) { } 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"); From 2c85e2d765abf29164f8dd0157c39eaf24a3a8bd Mon Sep 17 00:00:00 2001 From: Release Workflow Date: Fri, 13 Feb 2026 05:14:13 +0000 Subject: [PATCH 23/32] [CI Pipeline] Released Snapshot version: 6.12.12-alpha-221-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index cea33e99..4b26c9c9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.uid2 uid2-admin - 6.12.11 + 6.12.12-alpha-221-SNAPSHOT UTF-8 From 5cb9e975d1cfbf2df23b25c2833151ba646b0ea9 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 17:07:25 +1100 Subject: [PATCH 24/32] return deleted config --- .../com/uid2/admin/vertx/service/PartnerConfigService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 fa849c27..e7edae3f 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -197,12 +197,13 @@ private void handlePartnerConfigDelete(RoutingContext rc) { return; } - // Remove + // 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("\"success\""); + .end(deletedConfig.encode()); } catch (Exception e) { rc.fail(500, e); } From 6d6f6f1416441bccae2ee8c78d8144c19ecdd79e Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 17:09:47 +1100 Subject: [PATCH 25/32] return bulk replaced config --- .../java/com/uid2/admin/vertx/service/PartnerConfigService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e7edae3f..6042d964 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -246,7 +246,7 @@ private void handlePartnerConfigBulkReplace(RoutingContext rc) { rc.response() .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .end("\"success\""); + .end(partners.encode()); } catch (Exception e) { rc.fail(500, e); } From bd0f3f613f8bec6ba89d5b18f5c91dfe73433cb1 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 17:13:48 +1100 Subject: [PATCH 26/32] update roles and move /delete to danger zoen --- .../admin/vertx/service/PartnerConfigService.java | 5 +++-- webroot/adm/partner-config.html | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) 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 6042d964..e2742554 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -58,16 +58,17 @@ public void setupRoutes(Router router) { this.handlePartnerConfigUpdate(ctx); } }, 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.MAINTAINER)); + }, 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.PRIVILEGED)); + }, new AuditParams(Collections.emptyList(), List.of("name")), Role.SUPER_USER)); } private void handlePartnerConfigList(RoutingContext rc) { diff --git a/webroot/adm/partner-config.html b/webroot/adm/partner-config.html index 362cf4a7..b66c9ac3 100644 --- a/webroot/adm/partner-config.html +++ b/webroot/adm/partner-config.html @@ -131,24 +131,24 @@

UID2 Env - Partner Config

} } } - }, + } + ], + danger: [ { id: 'deletePartnerConfig', title: 'Delete Partner Config', - role: 'maintainer', + role: 'privileged', description: 'This will remove the partner configuration.', inputs: [deletePartnerNameInput], apiCall: { method: 'DELETE', getUrl: (inputs) => `/api/partner_config/delete?partner_name=${encodeURIComponent(inputs.deletePartnerName)}` } - } - ], - danger: [ + }, { id: 'bulkReplacePartnerConfigs', title: 'Replace ALL Partner Configs', - role: 'privileged', + 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: [ From bac9f942d6a964c59532ed47a51676d4eea53f1f Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 13 Feb 2026 17:23:24 +1100 Subject: [PATCH 27/32] update tests for auth changes --- .../admin/vertx/PartnerConfigServiceTest.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java index 2c488227..0abd41ea 100644 --- a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java @@ -286,7 +286,7 @@ void updatePartnerConfigMissingRequiredFields(String body, Vertx vertx, VertxTes // DELETE endpoint tests @Test void deletePartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.MAINTAINER); + fakeAuth(Role.PRIVILEGED); JsonObject config1 = createPartnerConfig("partner1", "https://p1.com/webhook"); JsonObject config2 = createPartnerConfig("partner2", "https://p2.com/webhook"); @@ -305,7 +305,7 @@ void deletePartnerConfigSuccess(Vertx vertx, VertxTestContext testContext) { @Test void deletePartnerConfigNotFound(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.MAINTAINER); + fakeAuth(Role.PRIVILEGED); JsonObject config = createPartnerConfig("partner1", "https://example.com/webhook"); setPartnerConfigs(config); @@ -319,7 +319,7 @@ void deletePartnerConfigNotFound(Vertx vertx, VertxTestContext testContext) { @Test void deletePartnerConfigCaseInsensitive(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.MAINTAINER); + fakeAuth(Role.PRIVILEGED); JsonObject config = createPartnerConfig("Partner1", "https://example.com/webhook"); setPartnerConfigs(config); @@ -333,7 +333,7 @@ void deletePartnerConfigCaseInsensitive(Vertx vertx, VertxTestContext testContex @Test void deletePartnerConfigNoPartnerName(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.MAINTAINER); + fakeAuth(Role.PRIVILEGED); setPartnerConfigs(); delete(vertx, testContext, "api/partner_config/delete", response -> { @@ -346,7 +346,7 @@ void deletePartnerConfigNoPartnerName(Vertx vertx, VertxTestContext testContext) // BULK_REPLACE endpoint tests @Test void bulkReplacePartnerConfigsSuccess(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.PRIVILEGED); + fakeAuth(Role.SUPER_USER); JsonObject existingConfig = createPartnerConfig("old-partner", "https://old.com/webhook"); setPartnerConfigs(existingConfig); @@ -369,7 +369,7 @@ void bulkReplacePartnerConfigsSuccess(Vertx vertx, VertxTestContext testContext) @Test void bulkReplacePartnerConfigsEmptyArray(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.PRIVILEGED); + fakeAuth(Role.SUPER_USER); setPartnerConfigs(); JsonArray emptyConfigs = new JsonArray(); @@ -386,7 +386,7 @@ void bulkReplacePartnerConfigsEmptyArray(Vertx vertx, VertxTestContext testConte @Test void bulkReplacePartnerConfigsNullBody(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.PRIVILEGED); + fakeAuth(Role.SUPER_USER); setPartnerConfigs(); post(vertx, testContext, "api/partner_config/bulk_replace", "", response -> { @@ -398,7 +398,7 @@ void bulkReplacePartnerConfigsNullBody(Vertx vertx, VertxTestContext testContext @Test void bulkReplacePartnerConfigsInvalidConfig(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.PRIVILEGED); + fakeAuth(Role.SUPER_USER); setPartnerConfigs(); JsonArray configs = new JsonArray(); @@ -414,7 +414,7 @@ void bulkReplacePartnerConfigsInvalidConfig(Vertx vertx, VertxTestContext testCo @Test void bulkReplacePartnerConfigsDuplicateNames(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.PRIVILEGED); + fakeAuth(Role.SUPER_USER); setPartnerConfigs(); JsonArray configs = new JsonArray(); @@ -430,7 +430,7 @@ void bulkReplacePartnerConfigsDuplicateNames(Vertx vertx, VertxTestContext testC @Test void bulkReplacePartnerConfigsUnauthorized(Vertx vertx, VertxTestContext testContext) { - fakeAuth(Role.MAINTAINER); // Bulk replace requires PRIVILEGED + fakeAuth(Role.PRIVILEGED); // Bulk replace requires SUPER_USER JsonArray newConfigs = new JsonArray(); newConfigs.add(createPartnerConfig("partner1", "https://p1.com/webhook")); From ed05be692f39ccdbcd3b2b90ab105a99c0ad207d Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Mon, 16 Feb 2026 11:21:12 +1100 Subject: [PATCH 28/32] use input fields rather than raw JSON --- .../vertx/service/PartnerConfigService.java | 106 +++++++++- .../admin/vertx/PartnerConfigServiceTest.java | 195 +++++++++++++++++- webroot/adm/partner-config.html | 176 +++++++++++++--- 3 files changed, 430 insertions(+), 47 deletions(-) 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 e2742554..01900017 100644 --- a/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java +++ b/src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java @@ -145,35 +145,48 @@ private void handlePartnerConfigAdd(RoutingContext rc) { private void handlePartnerConfigUpdate(RoutingContext rc) { try { - JsonObject newConfig = rc.body().asJsonObject(); - if (newConfig == null) { + JsonObject partialConfig = rc.body().asJsonObject(); + if (partialConfig == null) { ResponseUtil.error(rc, 400, "Body must include Partner config"); return; } - // Validate required fields - if (!validatePartnerConfig(rc, newConfig)) { + String partnerName = partialConfig.getString("name"); + if (partnerName == null || partnerName.trim().isEmpty()) { + ResponseUtil.error(rc, 400, "Partner config 'name' is required"); return; } - String newPartnerName = newConfig.getString("name"); this.partnerConfigProvider.loadContent(); JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig()); - // Validate partner exists - int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, newPartnerName); + // Find existing partner config + int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, partnerName); if (existingPartnerIdx == -1) { - ResponseUtil.error(rc, 404, "Partner '" + newPartnerName + "' not found"); + ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found"); return; } - // Upload - allPartnerConfigs.set(existingPartnerIdx, newConfig); + 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(newConfig.encode()); + .end(mergedConfig.encode()); } catch (Exception e) { rc.fail(500, e); } @@ -286,6 +299,77 @@ private boolean validatePartnerConfig(RoutingContext rc, JsonObject config) { 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; } diff --git a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java index 0abd41ea..1f885b0f 100644 --- a/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/PartnerConfigServiceTest.java @@ -44,6 +44,17 @@ private JsonObject createPartnerConfig(String name, String url) { 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) { @@ -265,24 +276,194 @@ void updatePartnerConfigCaseInsensitive(Vertx vertx, VertxTestContext testContex }); } - @ParameterizedTest - @CsvSource(value = { - "''", - "'{\"name\":\"partner1\",\"url\":\"https://new.com\"}'" - }) - void updatePartnerConfigMissingRequiredFields(String body, Vertx vertx, VertxTestContext testContext) { + @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", body, response -> { + 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) { diff --git a/webroot/adm/partner-config.html b/webroot/adm/partner-config.html index b66c9ac3..71c3ecf2 100644 --- a/webroot/adm/partner-config.html +++ b/webroot/adm/partner-config.html @@ -32,20 +32,114 @@

UID2 Env - Partner Config

required: true }; - const addConfigInput = { - name: 'addConfigData', - label: 'Configuration Data', + // '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: 'Enter JSON configuration data (single partner object)...' + placeholder: 'action=dooptout,\nuid2=${ADVERTISING_ID},\ntimestamp=${OPTOUT_EPOCH}' }; - const updateConfigInput = { - name: 'updateConfigData', - label: 'Configuration Data', + 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: 'Enter JSON configuration data (single partner object)...' + placeholder: 'Authorization: Bearer token,\nX-Custom-Header: value' }; const bulkReplaceConfigInput = { @@ -88,22 +182,35 @@

UID2 Env - Partner Config

role: 'maintainer', description: 'This will add a new partner config.', inputs: [ - addConfigInput + addPartnerNameInput, + addUrlInput, + addMethodInput, + addQueryParamsInput, + addAdditionalHeadersInput, + addRetryCountInput, + addRetryBackoffMsInput ], apiCall: { method: 'POST', url: '/api/partner_config/add', getPayload: (inputs) => { - // Validate and parse JSON - try { - const config = JSON.parse(inputs.addConfigData); - if (!config.name) { - throw new Error('Partner config must include "name" field'); - } - return config; - } catch (e) { - throw new Error('Invalid JSON format: ' + e.message); + 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; } } }, @@ -111,24 +218,35 @@

UID2 Env - Partner Config

id: 'updatePartnerConfig', title: 'Update Partner Config', role: 'maintainer', - description: 'This will replace an existing partner config.', + description: 'This will update an existing partner config. Only fill in the fields you want to change.', inputs: [ - updateConfigInput + updatePartnerNameInput, + updateUrlInput, + updateMethodInput, + updateQueryParamsInput, + updateAdditionalHeadersInput, + updateRetryCountInput, + updateRetryBackoffMsInput ], apiCall: { method: 'PUT', url: '/api/partner_config/update', getPayload: (inputs) => { - // Validate and parse JSON - try { - const config = JSON.parse(inputs.updateConfigData); - if (!config.name) { - throw new Error('Partner config must include "name" field'); - } - return config; - } catch (e) { - throw new Error('Invalid JSON format: ' + e.message); + 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; } } } From feaf3f595ec36cdf7fd74ce5afbe2673af2ab0da Mon Sep 17 00:00:00 2001 From: Release Workflow Date: Mon, 16 Feb 2026 00:30:08 +0000 Subject: [PATCH 29/32] [CI Pipeline] Released Snapshot version: 6.12.13-alpha-222-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4b26c9c9..ddac0ff5 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.uid2 uid2-admin - 6.12.12-alpha-221-SNAPSHOT + 6.12.13-alpha-222-SNAPSHOT UTF-8 From 634c74537c501cb9515f05cb43653690006b1e75 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Mon, 16 Feb 2026 11:59:58 +1100 Subject: [PATCH 30/32] display error msg from response --- webroot/js/httpClient.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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); } From 8994ac94f6fd78731567c1c94336d436e75742c5 Mon Sep 17 00:00:00 2001 From: Release Workflow Date: Mon, 16 Feb 2026 01:04:41 +0000 Subject: [PATCH 31/32] [CI Pipeline] Released Snapshot version: 6.12.14-alpha-223-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ddac0ff5..4806932b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.uid2 uid2-admin - 6.12.13-alpha-222-SNAPSHOT + 6.12.14-alpha-223-SNAPSHOT UTF-8 From de4ede9349ea95f85c81d9585c0f09c44e8d8d91 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Mon, 16 Feb 2026 14:40:20 +1100 Subject: [PATCH 32/32] fix delete role in UI --- webroot/adm/partner-config.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webroot/adm/partner-config.html b/webroot/adm/partner-config.html index 71c3ecf2..53bb42e4 100644 --- a/webroot/adm/partner-config.html +++ b/webroot/adm/partner-config.html @@ -255,7 +255,7 @@

UID2 Env - Partner Config

{ id: 'deletePartnerConfig', title: 'Delete Partner Config', - role: 'privileged', + role: 'elevated', description: 'This will remove the partner configuration.', inputs: [deletePartnerNameInput], apiCall: {