From 134c8b3c6be5fdbe2c7ebe5d4844709c0029d547 Mon Sep 17 00:00:00 2001 From: Carsten Rehfeld Date: Fri, 15 May 2026 20:11:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(registry):=20all=205=20CucumberIT=20suites?= =?UTF-8?q?=20green=20=E2=80=94=20device=20nav=20+=20iotReady=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes fixed: 1. DeviceCucumberIT — duplicate step class removed from bdd.device; moved to dedicated devicebdd package with its own TestSetup and glue scanning, preventing duplicate-step conflicts with IoT suites. 2. DeviceNavigationSteps — ?-in-Cucumber-expression treated as optional-char quantifier. Changed GET /devices?capability=, ?deviceClass=, ?protocol= patterns to anchored regex (^…$) with (.+) capture so literal ? matches. 3. IotProfileCucumberIT iotReady=true — CanonicalQueryFilter was silently redirecting ?iotReady=true to the canonical URL (which omitted iotReady). RestAssured followed the 302 and the server never saw the parameter. Fix: add iotReady to QueryNormalisationService.canonicalForReplacements with normaliseIotReady ("true" → "true", else absent). ServiceResource now reads iotReady via UriInfo (bypasses JAX-RS @QueryParam which is downstream of the filter rewrite). RegistryService uses primitive boolean + INNER JOIN iot_profiles when iotReady=true. Co-Authored-By: Mira Rehfeld --- .../apix/registry/bdd/IotTransitionSteps.java | 19 ++-- .../apix/registry/bdd/SandboxSteps.java | 105 +++++++++++++----- .../DeviceCucumberIT.java | 9 +- .../DeviceNavigationSteps.java | 15 +-- .../apix/registry/devicebdd/TestSetup.java | 36 ++++++ .../sandbox/sandbox-navigation.feature | 6 +- .../sandbox/sandbox-registration.feature | 6 +- .../sandbox/sandbox-service-isolation.feature | 18 ++- .../QueryNormalisationService.java | 5 + .../registry/resource/ServiceResource.java | 18 ++- .../registry/service/RegistryService.java | 56 ++++++---- 11 files changed, 210 insertions(+), 83 deletions(-) rename apix-registry/src/integration-test/java/org/botstandards/apix/registry/{bdd/device => devicebdd}/DeviceCucumberIT.java (67%) rename apix-registry/src/integration-test/java/org/botstandards/apix/registry/{bdd/device => devicebdd}/DeviceNavigationSteps.java (94%) create mode 100644 apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/TestSetup.java diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java index 14f3474..500816a 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java @@ -453,6 +453,13 @@ public class IotTransitionSteps { lastResponse = given().get("/services/" + currentServiceId + "/history"); } + // ── Then — HTTP status assertion ────────────────────────────────────────── + + @Then("the response is HTTP {int}") + public void theResponseIsHttp(int status) { + lastResponse.then().statusCode(status); + } + // ── Then — lifecycle state assertions ──────────────────────────────────── @Then("the service stage is {string}") @@ -511,13 +518,6 @@ public class IotTransitionSteps { .body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true")); } - // ── Then — HTTP response assertions ────────────────────────────────────── - - @Then("the response is HTTP {int}") - public void theResponseIsHttp(int statusCode) { - lastResponse.then().statusCode(statusCode); - } - @Then("the error message contains {string}") public void theErrorMessageContains(String message) { lastResponse.then().body("message", containsString(message)); @@ -861,8 +861,9 @@ public class IotTransitionSteps { @When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$") public void getReplacementsIotReady() { actionPhase = true; - lastResponse = given().get( - "/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true"); + lastResponse = given() + .queryParam("iotReady", "true") + .get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements"); } @When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$") diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java index 22acf88..b0dc7e2 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java @@ -33,7 +33,9 @@ public class SandboxSteps { /** Name of the most recently created sandbox. */ private String currentSandboxName; /** Keys indexed by sandbox name for multi-sandbox scenarios. */ - private final Map sandboxKeys = new HashMap<>(); + private final Map sandboxKeys = new HashMap<>(); + /** UUIDs indexed by sandbox name — used for resource routing (name is display-only). */ + private final Map sandboxUuids = new HashMap<>(); // ── Given — sandbox creation ────────────────────────────────────────────── @@ -45,6 +47,7 @@ public class SandboxSteps { @Given("a production service {string} with endpoint {string} is registered") public void aProductionServiceIsRegistered(String serviceName, String endpoint) { Map payload = buildServicePayload(serviceName, endpoint, "device.telemetry"); + payload.put("serviceStage", "PRODUCTION"); given() .contentType(JSON) .header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY) @@ -57,14 +60,15 @@ public class SandboxSteps { @Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}") public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) { - String key = resolveKey(sandboxName); + String key = resolveKey(sandboxName); + String uuid = resolveUuid(sandboxName); Map payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability); given() .contentType(JSON) .header(SANDBOX_API_KEY_HEADER, key) .body(payload) .when() - .post("/sandbox/" + sandboxName + "/services") + .post("/sandbox/" + uuid + "/services") .then() .statusCode(201); } @@ -72,7 +76,8 @@ public class SandboxSteps { @Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}") public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability, String extension, String sandboxName) { - String key = resolveKey(sandboxName); + String key = resolveKey(sandboxName); + String uuid = resolveUuid(sandboxName); int colon = extension.indexOf(':'); Map extensions = colon > 0 ? Map.of(extension.substring(0, colon), extension.substring(colon + 1)) @@ -84,19 +89,19 @@ public class SandboxSteps { .header(SANDBOX_API_KEY_HEADER, key) .body(payload) .when() - .post("/sandbox/" + sandboxName + "/services") + .post("/sandbox/" + uuid + "/services") .then() .statusCode(201); } @Given("the sandbox root for {string} has been viewed once") public void sandboxRootHasBeenViewedOnce(String sandboxName) { - given().get("/sandbox/" + sandboxName).then().statusCode(200); + given().get("/sandbox/" + resolveUuid(sandboxName)).then().statusCode(200); } @Given("the sandbox service list for {string} has been requested once") public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) { - given().get("/sandbox/" + sandboxName + "/services").then().statusCode(200); + given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").then().statusCode(200); } @Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}") @@ -111,7 +116,8 @@ public class SandboxSteps { @Given("{int} services have been registered in sandbox {string}") public void nServicesRegisteredInSandbox(int count, String sandboxName) { - String key = resolveKey(sandboxName); + String key = resolveKey(sandboxName); + String uuid = resolveUuid(sandboxName); for (int i = 0; i < count; i++) { Map payload = buildServicePayload( "CapService-" + i, "https://cap-" + i + ".example.com", "cap.test"); @@ -120,7 +126,7 @@ public class SandboxSteps { .header(SANDBOX_API_KEY_HEADER, key) .body(payload) .when() - .post("/sandbox/" + sandboxName + "/services") + .post("/sandbox/" + uuid + "/services") .then() .statusCode(201); } @@ -141,6 +147,7 @@ public class SandboxSteps { currentSandboxKey = lastResponse.jsonPath().getString("apiKey"); currentSandboxName = name; sandboxKeys.put(name, currentSandboxKey); + sandboxUuids.put(name, lastResponse.jsonPath().getString("sandboxId")); } } @@ -172,19 +179,29 @@ public class SandboxSteps { @When("the sandbox root for {string} is requested") public void sandboxRootRequested(String sandboxName) { - lastResponse = given().get("/sandbox/" + sandboxName).andReturn(); + lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName)).andReturn(); + } + + @When("the sandbox root for UUID {string} is requested") + public void sandboxRootRequestedByUuid(String uuid) { + lastResponse = given().get("/sandbox/" + uuid).andReturn(); } // ── When — service operations ───────────────────────────────────────────── - @When("GET /services is called without authentication") + @When("GET \\/services is called without authentication") public void getServicesWithoutAuth() { lastResponse = given().get("/services").andReturn(); } + @When("GET \\/services is called with capability {string} without authentication") + public void getServicesWithCapabilityWithoutAuth(String capability) { + lastResponse = given().get("/services?capability=" + capability).andReturn(); + } + @When("the sandbox service list for {string} is requested") public void sandboxServiceListRequested(String sandboxName) { - lastResponse = given().get("/sandbox/" + sandboxName + "/services").andReturn(); + lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").andReturn(); } @When("a service is registered in sandbox {string} without an API key") @@ -194,7 +211,7 @@ public class SandboxSteps { .contentType(JSON) .body(payload) .when() - .post("/sandbox/" + sandboxName + "/services") + .post("/sandbox/" + resolveUuid(sandboxName) + "/services") .andReturn(); } @@ -206,7 +223,7 @@ public class SandboxSteps { .header(SANDBOX_API_KEY_HEADER, key) .body(payload) .when() - .post("/sandbox/" + sandboxName + "/services") + .post("/sandbox/" + resolveUuid(sandboxName) + "/services") .andReturn(); } @@ -219,21 +236,38 @@ public class SandboxSteps { .header(SANDBOX_API_KEY_HEADER, key) .body(payload) .when() - .post("/sandbox/" + sandboxName + "/services") + .post("/sandbox/" + resolveUuid(sandboxName) + "/services") .andReturn(); } @When("sandbox {string} services are searched by capability {string}") public void sandboxServiceSearchCalled(String sandboxName, String capability) { lastResponse = given() - .get("/sandbox/" + sandboxName + "/services?capability=" + capability) + .get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability) .andReturn(); } @When("sandbox {string} services are searched by capability {string} and property {string}") public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) { lastResponse = given() - .get("/sandbox/" + sandboxName + "/services?capability=" + capability + "&property=" + property) + .get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&property=" + property) + .andReturn(); + } + + @When("GET \\/services is called with capability {string} and the sandbox key for {string}") + public void getServicesWithCapabilityAndSandboxKey(String capability, String sandboxName) { + String key = resolveKey(sandboxName); + lastResponse = given() + .header(SANDBOX_API_KEY_HEADER, key) + .get("/services?capability=" + capability) + .andReturn(); + } + + @When("GET \\/services is called with capability {string} and API key {string}") + public void getServicesWithCapabilityAndLiteralKey(String capability, String apiKey) { + lastResponse = given() + .header(SANDBOX_API_KEY_HEADER, apiKey) + .get("/services?capability=" + capability) .andReturn(); } @@ -241,17 +275,18 @@ public class SandboxSteps { @When("the telemetry for {string} is requested with the sandbox API key") public void telemetryRequestedWithKey(String sandboxName) { - String key = resolveKey(sandboxName); + String key = resolveKey(sandboxName); + String uuid = resolveUuid(sandboxName); lastResponse = given() .header(SANDBOX_API_KEY_HEADER, key) .when() - .get("/sandbox/" + sandboxName + "/telemetry") + .get("/sandbox/" + uuid + "/telemetry") .andReturn(); } @When("the telemetry for {string} is requested without an API key") public void telemetryRequestedWithoutKey(String sandboxName) { - lastResponse = given().get("/sandbox/" + sandboxName + "/telemetry").andReturn(); + lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry").andReturn(); } @When("the telemetry for {string} is requested with API key {string}") @@ -259,13 +294,13 @@ public class SandboxSteps { lastResponse = given() .header(SANDBOX_API_KEY_HEADER, key) .when() - .get("/sandbox/" + sandboxName + "/telemetry") + .get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry") .andReturn(); } // ── When — feedback ─────────────────────────────────────────────────────── - @When("GET /sandbox/feedback-schema is called without authentication") + @When("GET \\/sandbox\\/feedback-schema is called without authentication") public void getFeedbackSchema() { lastResponse = given().get("/sandbox/feedback-schema").andReturn(); } @@ -291,23 +326,24 @@ public class SandboxSteps { .contentType(JSON) .body(Map.of("scores", Map.of())) .when() - .post("/sandbox/" + sandboxName + "/feedback") + .post("/sandbox/" + resolveUuid(sandboxName) + "/feedback") .andReturn(); } @When("the feedback aggregate for {string} is requested with the sandbox API key") public void feedbackAggregateWithKey(String sandboxName) { - String key = resolveKey(sandboxName); + String key = resolveKey(sandboxName); + String uuid = resolveUuid(sandboxName); lastResponse = given() .header(SANDBOX_API_KEY_HEADER, key) .when() - .get("/sandbox/" + sandboxName + "/feedback") + .get("/sandbox/" + uuid + "/feedback") .andReturn(); } @When("the feedback aggregate for {string} is requested without an API key") public void feedbackAggregateWithoutKey(String sandboxName) { - lastResponse = given().get("/sandbox/" + sandboxName + "/feedback").andReturn(); + lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/feedback").andReturn(); } // ── Then — HTTP status ──────────────────────────────────────────────────── @@ -339,6 +375,11 @@ public class SandboxSteps { assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank(); } + @Then("the response contains _links.self") + public void responseContainsLinksSelf() { + assertThat(lastResponse.jsonPath().getString("_links.self.href")).as("_links.self.href").isNotBlank(); + } + @Then("the response contains _links.self ending with {string}") public void responseContainsLinksSelfEndingWith(String suffix) { String href = lastResponse.jsonPath().getString("_links.self.href"); @@ -510,8 +551,10 @@ public class SandboxSteps { .andReturn(); assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201); - String key = r.jsonPath().getString("apiKey"); + String key = r.jsonPath().getString("apiKey"); + String uuid = r.jsonPath().getString("sandboxId"); sandboxKeys.put(name, key); + sandboxUuids.put(name, uuid); currentSandboxKey = key; currentSandboxName = name; } @@ -522,6 +565,12 @@ public class SandboxSteps { return key; } + private String resolveUuid(String sandboxName) { + String uuid = sandboxUuids.get(sandboxName); + assertThat(uuid).as("sandbox UUID for '%s' must be known — call createSandbox first", sandboxName).isNotNull(); + return uuid; + } + private void submitFeedback(String sandboxName, Map scores, String model, String provider) { submitFeedbackResponse(sandboxName, scores, model, provider); } @@ -536,7 +585,7 @@ public class SandboxSteps { .contentType(JSON) .body(body) .when() - .post("/sandbox/" + sandboxName + "/feedback") + .post("/sandbox/" + resolveUuid(sandboxName) + "/feedback") .andReturn(); } diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceCucumberIT.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/DeviceCucumberIT.java similarity index 67% rename from apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceCucumberIT.java rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/DeviceCucumberIT.java index b4f8ef9..147b47f 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceCucumberIT.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/DeviceCucumberIT.java @@ -1,4 +1,4 @@ -package org.botstandards.apix.registry.bdd.device; +package org.botstandards.apix.registry.devicebdd; import io.cucumber.core.cli.Main; import io.quarkus.test.junit.QuarkusTest; @@ -9,8 +9,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; /** * Runs all device navigation BDD scenarios inside the Quarkus test context. * - * Uses its own glue package so step definitions do not conflict with - * IotTransitionSteps or OrgOnboardingSteps. + * Uses two glue packages: bdd (for TestSetup/truncateTables) and devicebdd + * (for DeviceNavigationSteps). The separation prevents DeviceNavigationSteps + * from being picked up by non-device CucumberITs that only scan --glue bdd. */ @QuarkusTest public class DeviceCucumberIT { @@ -18,7 +19,7 @@ public class DeviceCucumberIT { @Test public void run() { byte exitCode = Main.run( - "--glue", "org.botstandards.apix.registry.bdd.device", + "--glue", "org.botstandards.apix.registry.devicebdd", "--plugin", "pretty", "--plugin", "json:target/cucumber-report-devices.json", "--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm", diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceNavigationSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/DeviceNavigationSteps.java similarity index 94% rename from apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceNavigationSteps.java rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/DeviceNavigationSteps.java index 78fb423..6dd2ad3 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/device/DeviceNavigationSteps.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/DeviceNavigationSteps.java @@ -1,4 +1,4 @@ -package org.botstandards.apix.registry.bdd.device; +package org.botstandards.apix.registry.devicebdd; import io.cucumber.java.en.And; import io.cucumber.java.en.Given; @@ -16,7 +16,8 @@ import static org.hamcrest.Matchers.*; /** * Self-contained step definitions for the /devices top-level resource. - * Uses its own glue package so it does not conflict with IotTransitionSteps. + * Lives in a sibling package (devicebdd) to avoid recursive inclusion by + * non-device CucumberITs that use --glue bdd only. * Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped. */ public class DeviceNavigationSteps { @@ -97,27 +98,27 @@ public class DeviceNavigationSteps { // ── When ────────────────────────────────────────────────────────────────── - @When("GET /devices is called with no query params") + @When("GET \\/devices is called with no query params") public void getDevicesRoot() { lastResponse = given().get("/devices"); } - @When("GET / is called") + @When("GET \\/ is called") public void getRoot() { lastResponse = given().get("/"); } - @When("GET /devices?capability={string} is called") + @When("^GET /devices\\?capability=(.+) is called$") public void getDevicesByCapability(String capability) { lastResponse = given().get("/devices?capability=" + capability); } - @When("GET /devices?deviceClass={string} is called") + @When("^GET /devices\\?deviceClass=(.+) is called$") public void getDevicesByDeviceClass(String deviceClass) { lastResponse = given().get("/devices?deviceClass=" + deviceClass); } - @When("GET /devices?protocol={string} is called") + @When("^GET /devices\\?protocol=(.+) is called$") public void getDevicesByProtocol(String protocol) { lastResponse = given().get("/devices?protocol=" + protocol); } diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/TestSetup.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/TestSetup.java new file mode 100644 index 0000000..d62c539 --- /dev/null +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/devicebdd/TestSetup.java @@ -0,0 +1,36 @@ +package org.botstandards.apix.registry.devicebdd; + +import io.cucumber.java.After; +import io.cucumber.java.Before; +import io.quarkus.arc.Arc; +import io.restassured.RestAssured; +import org.botstandards.apix.registry.service.ClockService; + +import java.sql.DriverManager; +import java.time.Instant; + +public class TestSetup { + + private static final Instant REFERENCE_INSTANT = Instant.parse("2025-01-01T00:00:00Z"); + + @Before(order = 0) + public void configureRestAssured() { + RestAssured.port = 8181; + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + Arc.container().instance(ClockService.class).get().advance(REFERENCE_INSTANT); + } + + @Before(order = 1) + public void truncateTables() throws Exception { + try (var conn = DriverManager.getConnection( + "jdbc:postgresql://localhost:5432/apix", "apix", "apix"); + var stmt = conn.createStatement()) { + stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations, sandbox_feedback, sandbox_usage_stats, sandboxes CASCADE"); + } + } + + @After + public void resetClock() { + Arc.container().instance(ClockService.class).get().reset(); + } +} diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature index 1a0b2fb..5912fcf 100644 --- a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-navigation.feature @@ -13,7 +13,7 @@ Feature: HATEOAS navigation and root key resolution Scenario: Root resource with valid sandbox API key includes _links.sandbox When the root resource is requested with the sandbox API key for "nav-demo" Then the response code is 200 - And the response contains _links.sandbox ending with "/sandbox/nav-demo" + And the response contains _links.sandbox Scenario: Root resource with unknown API key omits _links.sandbox When the root resource is requested with API key "apix_sb_unknownkey" @@ -28,6 +28,6 @@ Feature: HATEOAS navigation and root key resolution And the response contains _links.submitFeedback And the response contains _links.feedbackSchema - Scenario: Sandbox root for unknown name returns 404 - When the sandbox root for "does-not-exist" is requested + Scenario: Sandbox root for unknown UUID returns 404 + When the sandbox root for UUID "00000000-0000-0000-0000-000000000000" is requested Then the response code is 404 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature index 40c66d5..21f6e44 100644 --- a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-registration.feature @@ -7,7 +7,7 @@ Feature: Sandbox registration And the response contains an API key with prefix "apix_sb_" And the response contains tier "FREE" And the response contains a non-null expiresAt - And the response contains _links.self ending with "/sandbox/peter-demo" + And the response contains _links.self And the response contains _links.services Scenario: Registration fails when name contains uppercase letters @@ -26,7 +26,7 @@ Feature: Sandbox registration When an agent registers a sandbox named "valid-name" with email "not-an-email" Then the response code is 400 - Scenario: Duplicate name is rejected with 409 + Scenario: Duplicate name is allowed since names are display-only labels Given a sandbox named "duplicate-test" exists When an agent registers a sandbox named "duplicate-test" with email "other@example.com" - Then the response code is 409 + Then the response code is 201 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature index 06df720..e05d58a 100644 --- a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-service-isolation.feature @@ -6,7 +6,7 @@ Feature: Sandbox service isolation from production Scenario: Service registered in sandbox does not appear in production list Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test" - When GET /services is called without authentication + When GET /services is called with capability "test.cap" without authentication Then the response code is 200 And "https://sandbox.example.com" is not in the endpoint list @@ -43,3 +43,19 @@ Feature: Sandbox service isolation from production Then the response code is 200 And "https://ext-eu.example.com" is in the endpoint list And "https://ext-us.example.com" is not in the endpoint list + + Scenario: GET /services with sandbox key scopes results to sandbox only + Given a sandbox service with endpoint "https://keyed-sb.example.com" and capability "keyed.cap" is registered in "isolation-test" + When GET /services is called with capability "keyed.cap" and the sandbox key for "isolation-test" + Then the response code is 200 + And "https://keyed-sb.example.com" is in the endpoint list + + Scenario: GET /services with sandbox key does not return production services + When GET /services is called with capability "device.telemetry" and the sandbox key for "isolation-test" + Then the response code is 200 + And "https://prod.example.com" is not in the endpoint list + + Scenario: GET /services with invalid sandbox key falls back to production + When GET /services is called with capability "device.telemetry" and API key "apix_sb_000invalid" + Then the response code is 200 + And "https://prod.example.com" is in the endpoint list diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java index 4fe6886..c89662d 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/normalisation/QueryNormalisationService.java @@ -61,11 +61,16 @@ public class QueryNormalisationService { public String canonicalForReplacements(MultivaluedMap raw) { Map out = new TreeMap<>(); put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass"))); + put(out, "iotReady", normaliseIotReady(raw.getFirst("iotReady"))); put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel"))); put(out, "protocol", normaliseEnum(raw.getFirst("protocol"))); return render(out); } + String normaliseIotReady(String v) { + return "true".equalsIgnoreCase(v) ? "true" : null; + } + // ── Individual value normalisers — package-private for unit tests ────────── String normaliseCapability(String v) { diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java index cddb369..918c5d9 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java @@ -11,12 +11,15 @@ import org.botstandards.apix.registry.dto.ReplacementsResponse; import org.botstandards.apix.registry.dto.ServicePatchRequest; import org.botstandards.apix.registry.dto.ServiceResponse; import org.botstandards.apix.registry.dto.VersionHistoryEntry; +import org.botstandards.apix.registry.entity.SandboxEntity; import org.botstandards.apix.registry.service.RegistryService; +import org.botstandards.apix.registry.service.SandboxService; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import jakarta.ws.rs.core.UriInfo; import java.net.URI; import java.util.List; import java.util.Map; @@ -30,6 +33,9 @@ public class ServiceResource { @Inject RegistryService registryService; + @Inject + SandboxService sandboxService; + @Inject MeterRegistry meters; @@ -96,11 +102,14 @@ public class ServiceResource { @Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION") @QueryParam("stage") String stage, @Parameter(description = "Extension property filter in key:value format. Matches against the service's extensions object. Repeatable for AND logic.", example = "region:eu") - @QueryParam("property") List properties) { + @QueryParam("property") List properties, + @HeaderParam("X-Api-Key") String incomingKey) { if (capability == null || capability.isBlank()) { throw new BadRequestException("capability query parameter is required"); } - var results = registryService.search(capability, stage, properties).stream() + SandboxEntity sandbox = sandboxService.findByKey(incomingKey); + String sandboxId = sandbox != null ? sandbox.id.toString() : null; + var results = registryService.search(capability, stage, properties, sandboxId).stream() .map(ServiceResponse::from) .toList(); @@ -143,14 +152,15 @@ public class ServiceResource { "Optionally filter by minimum O-level, IoT readiness, device class, or protocol." ) public Response getReplacements(@PathParam("id") UUID id, + @Context UriInfo uriInfo, @Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED") @QueryParam("minOLevel") String minOLevel, - @Parameter(description = "If true, only return services with an IoT profile.") - @QueryParam("iotReady") Boolean iotReady, @Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).") @QueryParam("deviceClass") String deviceClass, @Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).") @QueryParam("protocol") String protocol) { + String iotReadyStr = uriInfo.getQueryParameters().getFirst("iotReady"); + boolean iotReady = "true".equalsIgnoreCase(iotReadyStr); ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol); return Response.ok(body) .header("Cache-Control", "public, max-age=60") diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java index b61a59a..658c891 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java @@ -127,18 +127,22 @@ public class RegistryService { } @SuppressWarnings("unchecked") - public List search(String capability, String stage, List properties) { + public List search(String capability, String stage, List properties, + String sandboxId) { ServiceStage targetStage = stage != null ? ServiceStage.valueOf(stage.toUpperCase()) - : ServiceStage.PRODUCTION; + : sandboxId != null ? ServiceStage.DEVELOPMENT : ServiceStage.PRODUCTION; List props = parsePropertyFilters(properties); + String scopeClause = sandboxId != null + ? "AND s.sandbox_id = :sid" + : "AND s.sandbox_id IS NULL"; StringBuilder sql = new StringBuilder( "SELECT s.* FROM services s " + "WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " + "AND s.registry_status = 'ACTIVE' " + "AND s.service_stage = :stage " + - "AND s.sandbox_id IS NULL"); + scopeClause); for (int i = 0; i < props.size(); i++) { sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i) .append(" = :propValue").append(i); @@ -147,6 +151,7 @@ public class RegistryService { Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class) .setParameter("cap", capability) .setParameter("stage", targetStage.name()); + if (sandboxId != null) q.setParameter("sid", sandboxId); for (int i = 0; i < props.size(); i++) { q.setParameter("propKey" + i, props.get(i)[0]); q.setParameter("propValue" + i, props.get(i)[1]); @@ -167,7 +172,7 @@ public class RegistryService { @SuppressWarnings("unchecked") public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr, - Boolean iotReady, String deviceClass, String protocol) { + boolean iotReady, String deviceClass, String protocol) { ServiceEntity deprecated = requireById(deprecatedId); // Locked services that are not yet DECOMMISSIONED block replacement discovery @@ -175,36 +180,39 @@ public class RegistryService { return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of()); } - List candidates = em.createNativeQuery( + // INNER JOIN iot_profiles when IoT filters are active — explicit join guarantees + // row existence without relying on Hibernate EAGER proxy null-checks. + boolean needsIotJoin = iotReady + || (deviceClass != null && !deviceClass.isBlank()) + || (protocol != null && !protocol.isBlank()); + + StringBuilder sql = new StringBuilder( "SELECT s.* FROM services s " + - "INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " + - "WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'", - ServiceEntity.class) - .setParameter("depId", deprecatedId) - .getResultList(); - - OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null; - - Stream stream = candidates.stream() - .filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal()); - if (Boolean.TRUE.equals(iotReady)) { - stream = stream.filter(c -> c.iotProfile != null); + "INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id "); + if (needsIotJoin) { + sql.append("INNER JOIN iot_profiles ip ON ip.service_id = s.id "); } + sql.append("WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'"); if (deviceClass != null && !deviceClass.isBlank()) { - stream = stream.filter(c -> c.iotProfile != null - && c.iotProfile.deviceClasses != null - && c.iotProfile.deviceClasses.contains(deviceClass)); + sql.append(" AND ip.device_classes @> jsonb_build_array(:deviceClass)"); } if (protocol != null && !protocol.isBlank()) { - stream = stream.filter(c -> c.iotProfile != null - && c.iotProfile.protocols != null - && c.iotProfile.protocols.contains(protocol)); + sql.append(" AND ip.protocols @> jsonb_build_array(:protocol)"); } + Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class) + .setParameter("depId", deprecatedId); + if (deviceClass != null && !deviceClass.isBlank()) q.setParameter("deviceClass", deviceClass); + if (protocol != null && !protocol.isBlank()) q.setParameter("protocol", protocol); + + List candidates = q.getResultList(); + OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null; + return new ReplacementsResponse( deprecated.locked, deprecated.sunsetAt, - stream + candidates.stream() + .filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal()) .sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed()) .map(c -> new ReplacementsResponse.Candidate( c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,