fix(registry): all 5 CucumberIT suites green — device nav + iotReady filter

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 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-15 20:11:18 +02:00
parent 8bc0ce1fb8
commit 134c8b3c6b
11 changed files with 210 additions and 83 deletions
@@ -453,6 +453,13 @@ public class IotTransitionSteps {
lastResponse = given().get("/services/" + currentServiceId + "/history"); 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 — lifecycle state assertions ────────────────────────────────────
@Then("the service stage is {string}") @Then("the service stage is {string}")
@@ -511,13 +518,6 @@ public class IotTransitionSteps {
.body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true")); .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}") @Then("the error message contains {string}")
public void theErrorMessageContains(String message) { public void theErrorMessageContains(String message) {
lastResponse.then().body("message", containsString(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$") @When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
public void getReplacementsIotReady() { public void getReplacementsIotReady() {
actionPhase = true; actionPhase = true;
lastResponse = given().get( lastResponse = given()
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true"); .queryParam("iotReady", "true")
.get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
} }
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$") @When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
@@ -34,6 +34,8 @@ public class SandboxSteps {
private String currentSandboxName; private String currentSandboxName;
/** Keys indexed by sandbox name for multi-sandbox scenarios. */ /** Keys indexed by sandbox name for multi-sandbox scenarios. */
private final Map<String, String> sandboxKeys = new HashMap<>(); private final Map<String, String> sandboxKeys = new HashMap<>();
/** UUIDs indexed by sandbox name — used for resource routing (name is display-only). */
private final Map<String, String> sandboxUuids = new HashMap<>();
// ── Given — sandbox creation ────────────────────────────────────────────── // ── Given — sandbox creation ──────────────────────────────────────────────
@@ -45,6 +47,7 @@ public class SandboxSteps {
@Given("a production service {string} with endpoint {string} is registered") @Given("a production service {string} with endpoint {string} is registered")
public void aProductionServiceIsRegistered(String serviceName, String endpoint) { public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry"); Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
payload.put("serviceStage", "PRODUCTION");
given() given()
.contentType(JSON) .contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY) .header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
@@ -58,13 +61,14 @@ public class SandboxSteps {
@Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}") @Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}")
public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) { public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) {
String key = resolveKey(sandboxName); String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability); Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
given() given()
.contentType(JSON) .contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.body(payload) .body(payload)
.when() .when()
.post("/sandbox/" + sandboxName + "/services") .post("/sandbox/" + uuid + "/services")
.then() .then()
.statusCode(201); .statusCode(201);
} }
@@ -73,6 +77,7 @@ public class SandboxSteps {
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability, public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
String extension, String sandboxName) { String extension, String sandboxName) {
String key = resolveKey(sandboxName); String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
int colon = extension.indexOf(':'); int colon = extension.indexOf(':');
Map<String, Object> extensions = colon > 0 Map<String, Object> extensions = colon > 0
? Map.of(extension.substring(0, colon), extension.substring(colon + 1)) ? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
@@ -84,19 +89,19 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.body(payload) .body(payload)
.when() .when()
.post("/sandbox/" + sandboxName + "/services") .post("/sandbox/" + uuid + "/services")
.then() .then()
.statusCode(201); .statusCode(201);
} }
@Given("the sandbox root for {string} has been viewed once") @Given("the sandbox root for {string} has been viewed once")
public void sandboxRootHasBeenViewedOnce(String sandboxName) { 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") @Given("the sandbox service list for {string} has been requested once")
public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) { 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}") @Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}")
@@ -112,6 +117,7 @@ public class SandboxSteps {
@Given("{int} services have been registered in sandbox {string}") @Given("{int} services have been registered in sandbox {string}")
public void nServicesRegisteredInSandbox(int count, String sandboxName) { 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++) { for (int i = 0; i < count; i++) {
Map<String, Object> payload = buildServicePayload( Map<String, Object> payload = buildServicePayload(
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test"); "CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
@@ -120,7 +126,7 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.body(payload) .body(payload)
.when() .when()
.post("/sandbox/" + sandboxName + "/services") .post("/sandbox/" + uuid + "/services")
.then() .then()
.statusCode(201); .statusCode(201);
} }
@@ -141,6 +147,7 @@ public class SandboxSteps {
currentSandboxKey = lastResponse.jsonPath().getString("apiKey"); currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
currentSandboxName = name; currentSandboxName = name;
sandboxKeys.put(name, currentSandboxKey); 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") @When("the sandbox root for {string} is requested")
public void sandboxRootRequested(String sandboxName) { 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 — service operations ─────────────────────────────────────────────
@When("GET /services is called without authentication") @When("GET \\/services is called without authentication")
public void getServicesWithoutAuth() { public void getServicesWithoutAuth() {
lastResponse = given().get("/services").andReturn(); 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") @When("the sandbox service list for {string} is requested")
public void sandboxServiceListRequested(String sandboxName) { 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") @When("a service is registered in sandbox {string} without an API key")
@@ -194,7 +211,7 @@ public class SandboxSteps {
.contentType(JSON) .contentType(JSON)
.body(payload) .body(payload)
.when() .when()
.post("/sandbox/" + sandboxName + "/services") .post("/sandbox/" + resolveUuid(sandboxName) + "/services")
.andReturn(); .andReturn();
} }
@@ -206,7 +223,7 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.body(payload) .body(payload)
.when() .when()
.post("/sandbox/" + sandboxName + "/services") .post("/sandbox/" + resolveUuid(sandboxName) + "/services")
.andReturn(); .andReturn();
} }
@@ -219,21 +236,38 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.body(payload) .body(payload)
.when() .when()
.post("/sandbox/" + sandboxName + "/services") .post("/sandbox/" + resolveUuid(sandboxName) + "/services")
.andReturn(); .andReturn();
} }
@When("sandbox {string} services are searched by capability {string}") @When("sandbox {string} services are searched by capability {string}")
public void sandboxServiceSearchCalled(String sandboxName, String capability) { public void sandboxServiceSearchCalled(String sandboxName, String capability) {
lastResponse = given() lastResponse = given()
.get("/sandbox/" + sandboxName + "/services?capability=" + capability) .get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability)
.andReturn(); .andReturn();
} }
@When("sandbox {string} services are searched by capability {string} and property {string}") @When("sandbox {string} services are searched by capability {string} and property {string}")
public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) { public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) {
lastResponse = given() 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(); .andReturn();
} }
@@ -242,16 +276,17 @@ public class SandboxSteps {
@When("the telemetry for {string} is requested with the sandbox API key") @When("the telemetry for {string} is requested with the sandbox API key")
public void telemetryRequestedWithKey(String sandboxName) { public void telemetryRequestedWithKey(String sandboxName) {
String key = resolveKey(sandboxName); String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
lastResponse = given() lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.when() .when()
.get("/sandbox/" + sandboxName + "/telemetry") .get("/sandbox/" + uuid + "/telemetry")
.andReturn(); .andReturn();
} }
@When("the telemetry for {string} is requested without an API key") @When("the telemetry for {string} is requested without an API key")
public void telemetryRequestedWithoutKey(String sandboxName) { 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}") @When("the telemetry for {string} is requested with API key {string}")
@@ -259,13 +294,13 @@ public class SandboxSteps {
lastResponse = given() lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.when() .when()
.get("/sandbox/" + sandboxName + "/telemetry") .get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry")
.andReturn(); .andReturn();
} }
// ── When — feedback ─────────────────────────────────────────────────────── // ── When — feedback ───────────────────────────────────────────────────────
@When("GET /sandbox/feedback-schema is called without authentication") @When("GET \\/sandbox\\/feedback-schema is called without authentication")
public void getFeedbackSchema() { public void getFeedbackSchema() {
lastResponse = given().get("/sandbox/feedback-schema").andReturn(); lastResponse = given().get("/sandbox/feedback-schema").andReturn();
} }
@@ -291,23 +326,24 @@ public class SandboxSteps {
.contentType(JSON) .contentType(JSON)
.body(Map.of("scores", Map.of())) .body(Map.of("scores", Map.of()))
.when() .when()
.post("/sandbox/" + sandboxName + "/feedback") .post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
.andReturn(); .andReturn();
} }
@When("the feedback aggregate for {string} is requested with the sandbox API key") @When("the feedback aggregate for {string} is requested with the sandbox API key")
public void feedbackAggregateWithKey(String sandboxName) { public void feedbackAggregateWithKey(String sandboxName) {
String key = resolveKey(sandboxName); String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
lastResponse = given() lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key) .header(SANDBOX_API_KEY_HEADER, key)
.when() .when()
.get("/sandbox/" + sandboxName + "/feedback") .get("/sandbox/" + uuid + "/feedback")
.andReturn(); .andReturn();
} }
@When("the feedback aggregate for {string} is requested without an API key") @When("the feedback aggregate for {string} is requested without an API key")
public void feedbackAggregateWithoutKey(String sandboxName) { public void feedbackAggregateWithoutKey(String sandboxName) {
lastResponse = given().get("/sandbox/" + sandboxName + "/feedback").andReturn(); lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/feedback").andReturn();
} }
// ── Then — HTTP status ──────────────────────────────────────────────────── // ── Then — HTTP status ────────────────────────────────────────────────────
@@ -339,6 +375,11 @@ public class SandboxSteps {
assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank(); 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}") @Then("the response contains _links.self ending with {string}")
public void responseContainsLinksSelfEndingWith(String suffix) { public void responseContainsLinksSelfEndingWith(String suffix) {
String href = lastResponse.jsonPath().getString("_links.self.href"); String href = lastResponse.jsonPath().getString("_links.self.href");
@@ -511,7 +552,9 @@ public class SandboxSteps {
assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201); 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); sandboxKeys.put(name, key);
sandboxUuids.put(name, uuid);
currentSandboxKey = key; currentSandboxKey = key;
currentSandboxName = name; currentSandboxName = name;
} }
@@ -522,6 +565,12 @@ public class SandboxSteps {
return key; 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<String, Integer> scores, String model, String provider) { private void submitFeedback(String sandboxName, Map<String, Integer> scores, String model, String provider) {
submitFeedbackResponse(sandboxName, scores, model, provider); submitFeedbackResponse(sandboxName, scores, model, provider);
} }
@@ -536,7 +585,7 @@ public class SandboxSteps {
.contentType(JSON) .contentType(JSON)
.body(body) .body(body)
.when() .when()
.post("/sandbox/" + sandboxName + "/feedback") .post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
.andReturn(); .andReturn();
} }
@@ -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.cucumber.core.cli.Main;
import io.quarkus.test.junit.QuarkusTest; 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. * Runs all device navigation BDD scenarios inside the Quarkus test context.
* *
* Uses its own glue package so step definitions do not conflict with * Uses two glue packages: bdd (for TestSetup/truncateTables) and devicebdd
* IotTransitionSteps or OrgOnboardingSteps. * (for DeviceNavigationSteps). The separation prevents DeviceNavigationSteps
* from being picked up by non-device CucumberITs that only scan --glue bdd.
*/ */
@QuarkusTest @QuarkusTest
public class DeviceCucumberIT { public class DeviceCucumberIT {
@@ -18,7 +19,7 @@ public class DeviceCucumberIT {
@Test @Test
public void run() { public void run() {
byte exitCode = Main.run( byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd.device", "--glue", "org.botstandards.apix.registry.devicebdd",
"--plugin", "pretty", "--plugin", "pretty",
"--plugin", "json:target/cucumber-report-devices.json", "--plugin", "json:target/cucumber-report-devices.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm", "--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
@@ -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.And;
import io.cucumber.java.en.Given; 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. * 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. * Cucumber creates a fresh instance per scenario instance fields are scenario-scoped.
*/ */
public class DeviceNavigationSteps { public class DeviceNavigationSteps {
@@ -97,27 +98,27 @@ public class DeviceNavigationSteps {
// When // When
@When("GET /devices is called with no query params") @When("GET \\/devices is called with no query params")
public void getDevicesRoot() { public void getDevicesRoot() {
lastResponse = given().get("/devices"); lastResponse = given().get("/devices");
} }
@When("GET / is called") @When("GET \\/ is called")
public void getRoot() { public void getRoot() {
lastResponse = given().get("/"); lastResponse = given().get("/");
} }
@When("GET /devices?capability={string} is called") @When("^GET /devices\\?capability=(.+) is called$")
public void getDevicesByCapability(String capability) { public void getDevicesByCapability(String capability) {
lastResponse = given().get("/devices?capability=" + 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) { public void getDevicesByDeviceClass(String deviceClass) {
lastResponse = given().get("/devices?deviceClass=" + 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) { public void getDevicesByProtocol(String protocol) {
lastResponse = given().get("/devices?protocol=" + protocol); lastResponse = given().get("/devices?protocol=" + protocol);
} }
@@ -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();
}
}
@@ -13,7 +13,7 @@ Feature: HATEOAS navigation and root key resolution
Scenario: Root resource with valid sandbox API key includes _links.sandbox 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" When the root resource is requested with the sandbox API key for "nav-demo"
Then the response code is 200 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 Scenario: Root resource with unknown API key omits _links.sandbox
When the root resource is requested with API key "apix_sb_unknownkey" 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.submitFeedback
And the response contains _links.feedbackSchema And the response contains _links.feedbackSchema
Scenario: Sandbox root for unknown name returns 404 Scenario: Sandbox root for unknown UUID returns 404
When the sandbox root for "does-not-exist" is requested When the sandbox root for UUID "00000000-0000-0000-0000-000000000000" is requested
Then the response code is 404 Then the response code is 404
@@ -7,7 +7,7 @@ Feature: Sandbox registration
And the response contains an API key with prefix "apix_sb_" And the response contains an API key with prefix "apix_sb_"
And the response contains tier "FREE" And the response contains tier "FREE"
And the response contains a non-null expiresAt 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 And the response contains _links.services
Scenario: Registration fails when name contains uppercase letters 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" When an agent registers a sandbox named "valid-name" with email "not-an-email"
Then the response code is 400 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 Given a sandbox named "duplicate-test" exists
When an agent registers a sandbox named "duplicate-test" with email "other@example.com" 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
@@ -6,7 +6,7 @@ Feature: Sandbox service isolation from production
Scenario: Service registered in sandbox does not appear in production list 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" 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 Then the response code is 200
And "https://sandbox.example.com" is not in the endpoint list 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 Then the response code is 200
And "https://ext-eu.example.com" is in the endpoint list And "https://ext-eu.example.com" is in the endpoint list
And "https://ext-us.example.com" is not 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
@@ -61,11 +61,16 @@ public class QueryNormalisationService {
public String canonicalForReplacements(MultivaluedMap<String, String> raw) { public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
Map<String, String> out = new TreeMap<>(); Map<String, String> out = new TreeMap<>();
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass"))); put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
put(out, "iotReady", normaliseIotReady(raw.getFirst("iotReady")));
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel"))); put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
put(out, "protocol", normaliseEnum(raw.getFirst("protocol"))); put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
return render(out); return render(out);
} }
String normaliseIotReady(String v) {
return "true".equalsIgnoreCase(v) ? "true" : null;
}
// ── Individual value normalisers — package-private for unit tests ────────── // ── Individual value normalisers — package-private for unit tests ──────────
String normaliseCapability(String v) { String normaliseCapability(String v) {
@@ -11,12 +11,15 @@ import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest; import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.ServiceResponse; import org.botstandards.apix.registry.dto.ServiceResponse;
import org.botstandards.apix.registry.dto.VersionHistoryEntry; 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.RegistryService;
import org.botstandards.apix.registry.service.SandboxService;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -30,6 +33,9 @@ public class ServiceResource {
@Inject @Inject
RegistryService registryService; RegistryService registryService;
@Inject
SandboxService sandboxService;
@Inject @Inject
MeterRegistry meters; 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") @Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
@QueryParam("stage") String stage, @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") @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<String> properties) { @QueryParam("property") List<String> properties,
@HeaderParam("X-Api-Key") String incomingKey) {
if (capability == null || capability.isBlank()) { if (capability == null || capability.isBlank()) {
throw new BadRequestException("capability query parameter is required"); 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) .map(ServiceResponse::from)
.toList(); .toList();
@@ -143,14 +152,15 @@ public class ServiceResource {
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol." "Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
) )
public Response getReplacements(@PathParam("id") UUID id, public Response getReplacements(@PathParam("id") UUID id,
@Context UriInfo uriInfo,
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED") @Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
@QueryParam("minOLevel") String minOLevel, @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).") @Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).")
@QueryParam("deviceClass") String deviceClass, @QueryParam("deviceClass") String deviceClass,
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).") @Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
@QueryParam("protocol") String protocol) { @QueryParam("protocol") String protocol) {
String iotReadyStr = uriInfo.getQueryParameters().getFirst("iotReady");
boolean iotReady = "true".equalsIgnoreCase(iotReadyStr);
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol); ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
return Response.ok(body) return Response.ok(body)
.header("Cache-Control", "public, max-age=60") .header("Cache-Control", "public, max-age=60")
@@ -127,18 +127,22 @@ public class RegistryService {
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public List<ServiceEntity> search(String capability, String stage, List<String> properties) { public List<ServiceEntity> search(String capability, String stage, List<String> properties,
String sandboxId) {
ServiceStage targetStage = stage != null ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase()) ? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.PRODUCTION; : sandboxId != null ? ServiceStage.DEVELOPMENT : ServiceStage.PRODUCTION;
List<String[]> props = parsePropertyFilters(properties); List<String[]> props = parsePropertyFilters(properties);
String scopeClause = sandboxId != null
? "AND s.sandbox_id = :sid"
: "AND s.sandbox_id IS NULL";
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder(
"SELECT s.* FROM services s " + "SELECT s.* FROM services s " +
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " + "WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
"AND s.registry_status = 'ACTIVE' " + "AND s.registry_status = 'ACTIVE' " +
"AND s.service_stage = :stage " + "AND s.service_stage = :stage " +
"AND s.sandbox_id IS NULL"); scopeClause);
for (int i = 0; i < props.size(); i++) { for (int i = 0; i < props.size(); i++) {
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i) sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
.append(" = :propValue").append(i); .append(" = :propValue").append(i);
@@ -147,6 +151,7 @@ public class RegistryService {
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class) Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
.setParameter("cap", capability) .setParameter("cap", capability)
.setParameter("stage", targetStage.name()); .setParameter("stage", targetStage.name());
if (sandboxId != null) q.setParameter("sid", sandboxId);
for (int i = 0; i < props.size(); i++) { for (int i = 0; i < props.size(); i++) {
q.setParameter("propKey" + i, props.get(i)[0]); q.setParameter("propKey" + i, props.get(i)[0]);
q.setParameter("propValue" + i, props.get(i)[1]); q.setParameter("propValue" + i, props.get(i)[1]);
@@ -167,7 +172,7 @@ public class RegistryService {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr, public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
Boolean iotReady, String deviceClass, String protocol) { boolean iotReady, String deviceClass, String protocol) {
ServiceEntity deprecated = requireById(deprecatedId); ServiceEntity deprecated = requireById(deprecatedId);
// Locked services that are not yet DECOMMISSIONED block replacement discovery // 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()); return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
} }
List<ServiceEntity> 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 " + "SELECT s.* FROM services s " +
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " + "INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id ");
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'", if (needsIotJoin) {
ServiceEntity.class) sql.append("INNER JOIN iot_profiles ip ON ip.service_id = s.id ");
.setParameter("depId", deprecatedId)
.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
Stream<ServiceEntity> stream = candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal());
if (Boolean.TRUE.equals(iotReady)) {
stream = stream.filter(c -> c.iotProfile != null);
} }
sql.append("WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'");
if (deviceClass != null && !deviceClass.isBlank()) { if (deviceClass != null && !deviceClass.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null sql.append(" AND ip.device_classes @> jsonb_build_array(:deviceClass)");
&& c.iotProfile.deviceClasses != null
&& c.iotProfile.deviceClasses.contains(deviceClass));
} }
if (protocol != null && !protocol.isBlank()) { if (protocol != null && !protocol.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null sql.append(" AND ip.protocols @> jsonb_build_array(:protocol)");
&& c.iotProfile.protocols != null
&& c.iotProfile.protocols.contains(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<ServiceEntity> candidates = q.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
return new ReplacementsResponse( return new ReplacementsResponse(
deprecated.locked, deprecated.locked,
deprecated.sunsetAt, deprecated.sunsetAt,
stream candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed()) .sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
.map(c -> new ReplacementsResponse.Candidate( .map(c -> new ReplacementsResponse.Candidate(
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage, c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,