chore: add missing source modules to version control
Deploy to Production / deploy (push) Failing after 7s

apix-demo, apix-portal/src, apix-spider/src, apix-registry/src,
apix-common/src were never staged. Without them the CI build has no
source to compile and the Docker images cannot be produced.

Also adds docs/ (infrastructure notes) missed in prior commits.

Co-Authored-By: Mira <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 15:49:03 +02:00
parent a9b3354bde
commit 46f32c2df2
87 changed files with 6657 additions and 34 deletions
@@ -81,12 +81,12 @@ public class IotTransitionSteps {
currentServiceId = id;
}
private static String futureSunsetAt(int days) {
return Instant.now().plus(Duration.ofDays(days)).toString();
private String futureSunsetAt(int days) {
return Arc.container().instance(ClockService.class).get().now().plus(Duration.ofDays(days)).toString();
}
private static String pastSunsetAt(int days) {
return Instant.now().minus(Duration.ofDays(days)).toString();
private String pastSunsetAt(int days) {
return Arc.container().instance(ClockService.class).get().now().minus(Duration.ofDays(days)).toString();
}
// ── Given — service creation ──────────────────────────────────────────────
@@ -239,7 +239,7 @@ public class IotTransitionSteps {
// moment so the decommission validation ("sunset_at has not passed") succeeds.
// Truncate to micros: Postgres timestamptz stores at microsecond precision and
// may round sub-microsecond values, causing clock != stored sunsetAt.
Instant sunsetAt = Instant.now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MICROS);
Instant sunsetAt = Arc.container().instance(ClockService.class).get().now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MICROS);
asTemplateOwner()
.body(Map.of("sunsetAt", sunsetAt.toString()))
.patch("/services/" + currentServiceId)
@@ -607,12 +607,30 @@ public class OrgOnboardingSteps {
clock.advance(clock.now().plus(Duration.ofHours(hours)));
}
@When("time advances by {int} hours and {long} nanosecond(s)")
public void timeAdvancesHoursAndNanoseconds(int hours, long nanoseconds) {
ClockService clock = Arc.container().instance(ClockService.class).get();
clock.advance(clock.now().plus(Duration.ofHours(hours)).plusNanos(nanoseconds));
}
@When("time advances by {int} minutes")
public void timeAdvancesMinutes(int minutes) {
ClockService clock = Arc.container().instance(ClockService.class).get();
clock.advance(clock.now().plus(Duration.ofMinutes(minutes)));
}
@When("time advances by 1 nanosecond short of {int} hours")
public void timeAdvancesHoursMinusOneNano(int hours) {
ClockService clock = Arc.container().instance(ClockService.class).get();
clock.advance(clock.now().plus(Duration.ofHours(hours)).minusNanos(1));
}
@When("time advances by 1 nanosecond short of {int} minutes")
public void timeAdvancesMinutesMinusOneNano(int minutes) {
ClockService clock = Arc.container().instance(ClockService.class).get();
clock.advance(clock.now().plus(Duration.ofMinutes(minutes)).minusNanos(1));
}
// ── When: GET org ─────────────────────────────────────────────────────────
@When("the caller reads the organisation")
@@ -0,0 +1,23 @@
package org.botstandards.apix.registry.bdd;
import io.cucumber.core.cli.Main;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@QuarkusTest
public class SandboxCucumberIT {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-sandbox.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/sandbox"
);
assertEquals(0, exitCode, "One or more sandbox Cucumber scenarios failed — check test output for details");
}
}
@@ -0,0 +1,556 @@
package org.botstandards.apix.registry.bdd;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import io.restassured.response.Response;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static io.restassured.http.ContentType.JSON;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.*;
/**
* BDD step definitions for sandbox self-service features.
*
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
*/
public class SandboxSteps {
private static final String SANDBOX_API_KEY_HEADER = "X-Api-Key";
private static final String ADMIN_API_KEY_HEADER = "X-Api-Key";
private static final String ADMIN_API_KEY = "test-api-key";
// ── Per-scenario state ────────────────────────────────────────────────────
private Response lastResponse;
/** API key for the most recently created/resolved sandbox. */
private String currentSandboxKey;
/** Name of the most recently created sandbox. */
private String currentSandboxName;
/** Keys indexed by sandbox name for multi-sandbox scenarios. */
private final Map<String, String> sandboxKeys = new HashMap<>();
// ── Given — sandbox creation ──────────────────────────────────────────────
@Given("a sandbox named {string} exists")
public void aSandboxNamedExists(String name) {
createSandbox(name, "test+" + name + "@example.com");
}
@Given("a production service {string} with endpoint {string} is registered")
public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.body(payload)
.when()
.post("/services")
.then()
.statusCode(201);
}
@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);
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
given()
.contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.then()
.statusCode(201);
}
@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);
int colon = extension.indexOf(':');
Map<String, Object> extensions = colon > 0
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
: Map.of();
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
payload.put("extensions", extensions);
given()
.contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/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("the sandbox service list for {string} has been requested once")
public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) {
given().get("/sandbox/" + sandboxName + "/services").then().statusCode(200);
}
@Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}")
public void feedbackSubmittedTwoScores(String sandboxName, String dim1, int score1, String dim2, int score2) {
submitFeedback(sandboxName, Map.of(dim1, score1, dim2, score2), null, null);
}
@Given("feedback has been submitted to {string} with scores {word}={int} and model {string} provider {string}")
public void feedbackSubmittedWithModel(String sandboxName, String dim, int score, String model, String provider) {
submitFeedback(sandboxName, Map.of(dim, score), model, provider);
}
@Given("{int} services have been registered in sandbox {string}")
public void nServicesRegisteredInSandbox(int count, String sandboxName) {
String key = resolveKey(sandboxName);
for (int i = 0; i < count; i++) {
Map<String, Object> payload = buildServicePayload(
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
given()
.contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.then()
.statusCode(201);
}
}
// ── When — registration ───────────────────────────────────────────────────
@When("an agent registers a sandbox named {string} with email {string}")
public void agentRegistersSandbox(String name, String email) {
lastResponse = given()
.contentType(JSON)
.body(Map.of("name", name, "contactEmail", email))
.when()
.post("/sandbox/register")
.andReturn();
if (lastResponse.statusCode() == 201) {
currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
currentSandboxName = name;
sandboxKeys.put(name, currentSandboxKey);
}
}
// ── When — navigation ─────────────────────────────────────────────────────
@When("the root resource is requested without an API key")
public void rootRequestedWithoutKey() {
lastResponse = given().get("/").andReturn();
}
@When("the root resource is requested with the sandbox API key for {string}")
public void rootRequestedWithSandboxKey(String sandboxName) {
String key = resolveKey(sandboxName);
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/")
.andReturn();
}
@When("the root resource is requested with API key {string}")
public void rootRequestedWithLiteralKey(String key) {
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/")
.andReturn();
}
@When("the sandbox root for {string} is requested")
public void sandboxRootRequested(String sandboxName) {
lastResponse = given().get("/sandbox/" + sandboxName).andReturn();
}
// ── When — service operations ─────────────────────────────────────────────
@When("GET /services is called without authentication")
public void getServicesWithoutAuth() {
lastResponse = given().get("/services").andReturn();
}
@When("the sandbox service list for {string} is requested")
public void sandboxServiceListRequested(String sandboxName) {
lastResponse = given().get("/sandbox/" + sandboxName + "/services").andReturn();
}
@When("a service is registered in sandbox {string} without an API key")
public void serviceRegisteredInSandboxWithoutKey(String sandboxName) {
Map<String, Object> payload = buildServicePayload("NoKeyService", "https://nokey.example.com", "test.cap");
lastResponse = given()
.contentType(JSON)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.andReturn();
}
@When("a service is registered in sandbox {string} with API key {string}")
public void serviceRegisteredInSandboxWithLiteralKey(String sandboxName, String key) {
Map<String, Object> payload = buildServicePayload("WrongKeyService", "https://wrongkey.example.com", "test.cap");
lastResponse = given()
.contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.andReturn();
}
@When("a service is registered in sandbox {string} with the sandbox API key")
public void serviceRegisteredInSandboxWithKey(String sandboxName) {
String key = resolveKey(sandboxName);
Map<String, Object> payload = buildServicePayload("ExtraService", "https://extra.example.com", "cap.test");
lastResponse = given()
.contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + 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)
.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)
.andReturn();
}
// ── When — telemetry ──────────────────────────────────────────────────────
@When("the telemetry for {string} is requested with the sandbox API key")
public void telemetryRequestedWithKey(String sandboxName) {
String key = resolveKey(sandboxName);
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/sandbox/" + sandboxName + "/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();
}
@When("the telemetry for {string} is requested with API key {string}")
public void telemetryRequestedWithLiteralKey(String sandboxName, String key) {
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/sandbox/" + sandboxName + "/telemetry")
.andReturn();
}
// ── When — feedback ───────────────────────────────────────────────────────
@When("GET /sandbox/feedback-schema is called without authentication")
public void getFeedbackSchema() {
lastResponse = given().get("/sandbox/feedback-schema").andReturn();
}
@When("feedback is submitted to {string} with scores {word}={int} {word}={int}")
public void feedbackSubmittedWhenTwoScores(String sandboxName, String dim1, int score1, String dim2, int score2) {
lastResponse = submitFeedbackResponse(sandboxName, Map.of(dim1, score1, dim2, score2), null, null);
}
@When("feedback is submitted to {string} with scores {word}={int} and model {string} provider {string}")
public void feedbackSubmittedWhenWithModel(String sandboxName, String dim, int score, String model, String provider) {
lastResponse = submitFeedbackResponse(sandboxName, Map.of(dim, score), model, provider);
}
@When("feedback is submitted to {string} with scores {word}={int}")
public void feedbackSubmittedWhenOneScore(String sandboxName, String dim, int score) {
lastResponse = submitFeedbackResponse(sandboxName, Map.of(dim, score), null, null);
}
@When("feedback is submitted to {string} with empty scores")
public void feedbackSubmittedWithEmptyScores(String sandboxName) {
lastResponse = given()
.contentType(JSON)
.body(Map.of("scores", Map.of()))
.when()
.post("/sandbox/" + 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);
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/sandbox/" + sandboxName + "/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();
}
// ── Then — HTTP status ────────────────────────────────────────────────────
@Then("the response code is {int}")
public void responseCodeIs(int status) {
assertThat(lastResponse.statusCode()).as("HTTP status").isEqualTo(status);
}
// ── Then — registration assertions ────────────────────────────────────────
@Then("the response contains a sandbox id")
public void responseContainsSandboxId() {
assertThat(lastResponse.jsonPath().getString("sandboxId")).isNotBlank();
}
@Then("the response contains an API key with prefix {string}")
public void responseContainsApiKeyWithPrefix(String prefix) {
assertThat(lastResponse.jsonPath().getString("apiKey")).startsWith(prefix);
}
@Then("the response contains tier {string}")
public void responseContainsTier(String tier) {
assertThat(lastResponse.jsonPath().getString("tier")).isEqualTo(tier);
}
@Then("the response contains a non-null expiresAt")
public void responseContainsNonNullExpiresAt() {
assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank();
}
@Then("the response contains _links.self ending with {string}")
public void responseContainsLinksSelfEndingWith(String suffix) {
String href = lastResponse.jsonPath().getString("_links.self.href");
assertThat(href).as("_links.self.href").endsWith(suffix);
}
@Then("the response contains _links.services")
public void responseContainsLinksServices() {
assertThat(lastResponse.jsonPath().getString("_links.services.href")).isNotBlank();
}
// ── Then — navigation assertions ──────────────────────────────────────────
@Then("the response contains _links.registerSandbox")
public void responseContainsLinksRegisterSandbox() {
assertThat(lastResponse.jsonPath().getString("_links.registerSandbox.href")).isNotBlank();
}
@Then("the response contains _links.feedbackSchema")
public void responseContainsLinksFeedbackSchema() {
assertThat(lastResponse.jsonPath().getString("_links.feedbackSchema.href")).isNotBlank();
}
@Then("the response does not contain _links.sandbox")
public void responseDoesNotContainLinksSandbox() {
assertThat(lastResponse.jsonPath().getString("_links.sandbox")).isNull();
}
@Then("the response contains _links.sandbox ending with {string}")
public void responseContainsLinksSandboxEndingWith(String suffix) {
String href = lastResponse.jsonPath().getString("_links.sandbox.href");
assertThat(href).as("_links.sandbox.href").endsWith(suffix);
}
@Then("the response contains sandbox name {string}")
public void responseContainsSandboxName(String name) {
assertThat(lastResponse.jsonPath().getString("name")).isEqualTo(name);
}
@Then("the response contains _links.submitFeedback")
public void responseContainsLinksSubmitFeedback() {
assertThat(lastResponse.jsonPath().getString("_links.submitFeedback.href")).isNotBlank();
}
// ── Then — service isolation assertions ───────────────────────────────────
@Then("{string} is not in the endpoint list")
public void isNotInEndpointList(String endpoint) {
List<String> endpoints = lastResponse.jsonPath().getList("endpoint");
assertThat(endpoints).as("endpoint list").doesNotContain(endpoint);
}
@Then("{string} is in the endpoint list")
public void isInEndpointList(String endpoint) {
List<String> endpoints = lastResponse.jsonPath().getList("endpoint");
assertThat(endpoints).as("endpoint list").contains(endpoint);
}
// ── Then — telemetry assertions ───────────────────────────────────────────
@Then("the usage map is empty or contains only zero counts")
public void usageMapIsEmptyOrZero() {
Map<String, Object> usage = lastResponse.jsonPath().getMap("usage");
if (usage != null) {
usage.values().forEach(v ->
assertThat(((Number) v).longValue()).as("usage count").isZero());
}
}
@Then("the usage counter {string} is at least {int}")
public void usageCounterIsAtLeast(String eventType, int minimum) {
Integer count = lastResponse.jsonPath().getInt("usage." + eventType);
assertThat(count).as("usage." + eventType).isGreaterThanOrEqualTo(minimum);
}
@Then("the telemetry contains tier {string}")
public void telemetryContainsTier(String tier) {
assertThat(lastResponse.jsonPath().getString("tier")).isEqualTo(tier);
}
@Then("the telemetry contains ratePerMinute {int}")
public void telemetryContainsRatePerMinute(int rate) {
assertThat(lastResponse.jsonPath().getInt("ratePerMinute")).isEqualTo(rate);
}
@Then("the telemetry contains maxServices {int}")
public void telemetryContainsMaxServices(int max) {
assertThat(lastResponse.jsonPath().getInt("maxServices")).isEqualTo(max);
}
@Then("the telemetry contains maxOrgs {int}")
public void telemetryContainsMaxOrgs(int max) {
assertThat(lastResponse.jsonPath().getInt("maxOrgs")).isEqualTo(max);
}
// ── Then — feedback assertions ────────────────────────────────────────────
@Then("the schema contains at least {int} dimensions")
public void schemaContainsAtLeastDimensions(int min) {
List<?> dims = lastResponse.jsonPath().getList("dimensions");
assertThat(dims).as("dimensions").hasSizeGreaterThanOrEqualTo(min);
}
@Then("the schema contains dimension key {string}")
public void schemaContainsDimensionKey(String key) {
List<String> keys = lastResponse.jsonPath().getList("dimensions.key");
assertThat(keys).as("dimension keys").contains(key);
}
@Then("the schema scale minimum is {int} and maximum is {int}")
public void schemaScaleMinMax(int min, int max) {
assertThat(lastResponse.jsonPath().getInt("scale.min")).isEqualTo(min);
assertThat(lastResponse.jsonPath().getInt("scale.max")).isEqualTo(max);
}
@Then("the response message is {string}")
public void responseMessageIs(String expected) {
assertThat(lastResponse.jsonPath().getString("message")).isEqualTo(expected);
}
@Then("the response contains _links.schema")
public void responseContainsLinksSchema() {
assertThat(lastResponse.jsonPath().getString("_links.schema")).isNotBlank();
}
@Then("the response contains _links.sandbox")
public void responseContainsLinksSandbox() {
assertThat(lastResponse.jsonPath().getString("_links.sandbox")).isNotBlank();
}
@Then("the total submissions is {int}")
public void totalSubmissionsIs(int expected) {
assertThat(lastResponse.jsonPath().getInt("totalSubmissions")).isEqualTo(expected);
}
@Then("the dimension {string} has average {double}")
public void dimensionHasAverage(String key, double expected) {
List<Map<String, Object>> scores = lastResponse.jsonPath().getList("scores");
double actual = scores.stream()
.filter(s -> key.equals(s.get("key")))
.mapToDouble(s -> ((Number) s.get("average")).doubleValue())
.findFirst()
.orElseThrow(() -> new AssertionError("Dimension '" + key + "' not found in aggregate response"));
assertThat(actual).as("average for " + key).isEqualTo(expected);
}
@Then("the submissionsByProvider contains {string} with count {int}")
public void submissionsByProviderContains(String provider, int count) {
Integer actual = lastResponse.jsonPath().getInt("submissionsByProvider." + provider);
assertThat(actual).as("submissionsByProvider." + provider).isEqualTo(count);
}
// ── Then — error assertions ───────────────────────────────────────────────
@Then("the sandbox error contains {string}")
public void sandboxErrorContains(String text) {
assertThat(lastResponse.jsonPath().getString("message"))
.as("error message").contains(text);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void createSandbox(String name, String email) {
Response r = given()
.contentType(JSON)
.body(Map.of("name", name, "contactEmail", email))
.when()
.post("/sandbox/register")
.andReturn();
assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201);
String key = r.jsonPath().getString("apiKey");
sandboxKeys.put(name, key);
currentSandboxKey = key;
currentSandboxName = name;
}
private String resolveKey(String sandboxName) {
String key = sandboxKeys.get(sandboxName);
assertThat(key).as("sandbox key for '%s' must be known — call createSandbox first", sandboxName).isNotNull();
return key;
}
private void submitFeedback(String sandboxName, Map<String, Integer> scores, String model, String provider) {
submitFeedbackResponse(sandboxName, scores, model, provider);
}
private Response submitFeedbackResponse(String sandboxName, Map<String, Integer> scores, String model, String provider) {
Map<String, Object> body = new HashMap<>();
body.put("scores", scores);
if (model != null) body.put("modelIdentifier", model);
if (provider != null) body.put("modelProvider", provider);
return given()
.contentType(JSON)
.body(body)
.when()
.post("/sandbox/" + sandboxName + "/feedback")
.andReturn();
}
private Map<String, Object> buildServicePayload(String name, String endpoint, String capability) {
Map<String, Object> p = new HashMap<>();
p.put("name", name);
p.put("description", name + " test service");
p.put("endpoint", endpoint);
p.put("capabilities", List.of(capability));
p.put("registrantEmail", "test@example.com");
p.put("registrantName", "Test Org");
p.put("registrantJurisdiction", "DE");
p.put("registrantOrgType", "COMMERCIAL");
p.put("bsmVersion", "1.0");
return p;
}
}
@@ -7,13 +7,17 @@ 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)
@@ -21,7 +25,7 @@ public class TestSetup {
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 CASCADE");
stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations, sandbox_feedback, sandbox_usage_stats, sandboxes CASCADE");
}
}
@@ -0,0 +1,29 @@
package org.botstandards.apix.registry.bdd.device;
import io.cucumber.core.cli.Main;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
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.
*/
@QuarkusTest
public class DeviceCucumberIT {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd.device",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-devices.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/devices"
);
assertEquals(0, exitCode, "One or more device Cucumber scenarios failed — check test output");
}
}
@@ -0,0 +1,192 @@
package org.botstandards.apix.registry.bdd.device;
import io.cucumber.java.en.And;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;
import java.util.*;
import static io.restassured.RestAssured.given;
import static io.restassured.http.ContentType.JSON;
import static org.assertj.core.api.Assertions.assertThat;
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.
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
*/
public class DeviceNavigationSteps {
private static final String API_KEY_HEADER = "X-Api-Key";
private static final String API_KEY = "test-api-key";
private final Map<String, UUID> serviceIds = new HashMap<>();
private Response lastResponse;
// ── Helpers ───────────────────────────────────────────────────────────────
private RequestSpecification asOwner() {
return given().contentType(JSON).header(API_KEY_HEADER, API_KEY);
}
private Map<String, Object> basePayload(String name) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("name", name);
p.put("description", name + " test service");
p.put("endpoint", "https://" + name.toLowerCase().replace(" ", "") + ".example");
p.put("capabilities", List.of("device.telemetry"));
p.put("registrantEmail", "test@example.com");
p.put("registrantName", "Test Org");
p.put("registrantJurisdiction", "DE");
p.put("registrantOrgType", "COMMERCIAL");
p.put("bsmVersion", "1.0");
return p;
}
private UUID registerService(Map<String, Object> payload) {
Response r = asOwner().body(payload).post("/services");
r.then().statusCode(201);
return UUID.fromString(r.jsonPath().getString("id"));
}
// ── Given — service creation ──────────────────────────────────────────────
@Given("a production IoT service {string} with deviceClass {string} and protocol {string}")
public void aProductionIotService(String name, String deviceClass, String protocol) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", "PRODUCTION");
UUID id = registerService(p);
serviceIds.put(name, id);
asOwner()
.body(Map.of("iotProfile", Map.of(
"hubUrl", "wss://" + name.toLowerCase().replace(" ", "") + ".example/hub",
"protocols", List.of(protocol),
"deviceClasses", List.of(deviceClass)
)))
.patch("/services/" + id)
.then().statusCode(200);
}
@Given("a production service {string} with no IoT profile")
public void aProductionServiceWithNoIotProfile(String name) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", "PRODUCTION");
serviceIds.put(name, registerService(p));
}
@Given("a deprecated device service {string} with locked set to false")
public void aDeprecatedDeviceServiceLockedFalse(String name) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", "DEPRECATED");
p.put("locked", false);
p.put("sunsetAt", java.time.Instant.now().plus(java.time.Duration.ofDays(90)).toString());
serviceIds.put(name, registerService(p));
}
@Given("{string} has declared device compatibility with {string}")
public void hasDeclaredDeviceCompatibilityWith(String provider, String deprecated) {
asOwner()
.body(Map.of("replacesServiceIds", List.of(serviceIds.get(deprecated).toString())))
.patch("/services/" + serviceIds.get(provider))
.then().statusCode(200);
}
// ── When ──────────────────────────────────────────────────────────────────
@When("GET /devices is called with no query params")
public void getDevicesRoot() {
lastResponse = given().get("/devices");
}
@When("GET / is called")
public void getRoot() {
lastResponse = given().get("/");
}
@When("GET /devices?capability={string} is called")
public void getDevicesByCapability(String capability) {
lastResponse = given().get("/devices?capability=" + capability);
}
@When("GET /devices?deviceClass={string} is called")
public void getDevicesByDeviceClass(String deviceClass) {
lastResponse = given().get("/devices?deviceClass=" + deviceClass);
}
@When("GET /devices?protocol={string} is called")
public void getDevicesByProtocol(String protocol) {
lastResponse = given().get("/devices?protocol=" + protocol);
}
@When("^GET /devices/\\{smartHubServiceId\\} is called$")
public void getDeviceBySmartHubServiceId() {
lastResponse = given().get("/devices/" + serviceIds.get("SmartHub Service"));
}
@When("^GET /devices/\\{plainApiId\\} is called$")
public void getDeviceByPlainApiId() {
lastResponse = given().get("/devices/" + serviceIds.get("PlainApi"));
}
@When("^GET /devices/\\{oldHubId\\}/replacements is called$")
public void getDeviceReplacementsForOldHub() {
lastResponse = given().get("/devices/" + serviceIds.get("OldHub") + "/replacements");
}
// ── Then ──────────────────────────────────────────────────────────────────
@Then("the response is HTTP {int}")
public void theResponseIsHttp(int status) {
lastResponse.then().statusCode(status);
}
@Then("the device root _links.self href ends with {string}")
public void deviceRootLinksSelfHrefEndsWith(String suffix) {
lastResponse.then().statusCode(200)
.body("_links.self.href", endsWith(suffix));
}
@Then("the device root _links.search is templated")
public void deviceRootLinksSearchIsTemplated() {
lastResponse.then().statusCode(200)
.body("_links.search.templated", equalTo(true))
.body("_links.search.href", containsString("{?"));
}
@Then("the device root _links.replacement is templated")
public void deviceRootLinksReplacementIsTemplated() {
lastResponse.then().statusCode(200)
.body("_links.replacement.templated", equalTo(true))
.body("_links.replacement.href", containsString("{"));
}
@Then("the root _links contains a {string} entry")
public void rootLinksContainsEntry(String key) {
lastResponse.then().statusCode(200)
.body("_links." + key, notNullValue());
}
@Then("{string} is in the device results")
public void isInDeviceResults(String name) {
lastResponse.then().statusCode(200).body("name", hasItem(name));
}
@Then("{string} is not in the device results")
public void isNotInDeviceResults(String name) {
lastResponse.then().statusCode(200).body("name", not(hasItem(name)));
}
@Then("the response body contains an iotProfile")
public void responseBodyContainsIotProfile() {
lastResponse.then().statusCode(200).body("iotProfile", notNullValue());
}
@Then("the replacement candidates contain {string}")
public void replacementCandidatesContain(String name) {
lastResponse.then().statusCode(200).body("candidates.name", hasItem(name));
}
}
@@ -0,0 +1,56 @@
Feature: Device registry — dedicated entry point
As an IoT device agent
I want a dedicated /devices entry point with device-specific navigation
So that I can discover compatible replacement services without touching the agent service world
Background:
Given a production IoT service "SmartHub Service" with deviceClass "device.class.smart-home-hub" and protocol "MQTT_5_0"
And a production IoT service "SensorBridge" with deviceClass "device.class.industrial-sensor" and protocol "WEBSOCKET"
And a production service "PlainApi" with no IoT profile
Scenario: GET /devices returns a navigation document with templated links
When GET /devices is called with no query params
Then the response is HTTP 200
And the device root _links.self href ends with "/devices"
And the device root _links.search is templated
And the device root _links.replacement is templated
Scenario: Registry root exposes a devices link
When GET / is called
Then the response is HTTP 200
And the root _links contains a "devices" entry
Scenario: Device search by capability returns only IoT-ready services
When GET /devices?capability=device.telemetry is called
Then the response is HTTP 200
And "SmartHub Service" is in the device results
And "SensorBridge" is in the device results
And "PlainApi" is not in the device results
Scenario: Device search by deviceClass narrows results
When GET /devices?deviceClass=device.class.smart-home-hub is called
Then the response is HTTP 200
And "SmartHub Service" is in the device results
And "SensorBridge" is not in the device results
Scenario: Device search by protocol narrows results
When GET /devices?protocol=MQTT_5_0 is called
Then the response is HTTP 200
And "SmartHub Service" is in the device results
And "SensorBridge" is not in the device results
Scenario: GET /devices/{id} returns the device service with its IoT profile
When GET /devices/{smartHubServiceId} is called
Then the response is HTTP 200
And the response body contains an iotProfile
Scenario: GET /devices/{id} returns 404 for a service without an IoT profile
When GET /devices/{plainApiId} is called
Then the response is HTTP 404
Scenario: Device replacement discovery at /devices/{id}/replacements
Given a deprecated device service "OldHub" with locked set to false
And "SmartHub Service" has declared device compatibility with "OldHub"
When GET /devices/{oldHubId}/replacements is called
Then the response is HTTP 200
And the replacement candidates contain "SmartHub Service"
@@ -82,6 +82,7 @@ Feature: Organisation audit log
Scenario: Audit log returns events newest first
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When time advances by 2 minutes
And the owner has initiated key rotation using the rotation secret
When the owner requests the audit log
And the owner requests the audit log
Then the first audit event is "TAN_ISSUED"
@@ -12,10 +12,17 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation
And the effective O-level is "OPERATIONALLY_VERIFIED"
And the earned O-level is "IDENTITY_VERIFIED"
Scenario: Effective O-level drops back to earned level after temp grant expires
Scenario: Effective O-level still active 1 nanosecond before the temp grant expires
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
And a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 2 hours
When time advances by 3 hours
When time advances by 1 nanosecond short of 2 hours
And the caller reads the organisation
Then the effective O-level is "OPERATIONALLY_VERIFIED"
Scenario: Effective O-level drops to earned level at exactly 2 hours
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
And a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 2 hours
When time advances by 2 hours
And the caller reads the organisation
Then the effective O-level is "IDENTITY_VERIFIED"
@@ -69,10 +76,19 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation
When the owner initiates key rotation using an invalid rotation secret
Then the response status is 403
Scenario: Key rotation TAN expires before confirmation — rotation is rejected
Scenario: Key rotation TAN still valid 1 nanosecond before the 5-minute expiry
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has initiated key rotation using the rotation secret
When time advances by 6 minutes
When time advances by 1 nanosecond short of 5 minutes
And the owner confirms key rotation with the TAN
Then the response status is 200
And a new api key with prefix "apix_org_" is returned
And a new rotation secret with prefix "apix_rot_" is returned
Scenario: Key rotation TAN expires at exactly 5 minutes
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has initiated key rotation using the rotation secret
When time advances by 5 minutes
And the owner confirms key rotation with the TAN
Then the response status is 422
@@ -132,10 +148,19 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation
Then the response status is 200
And the response message confirms TAN was sent
Scenario: TAN is rejected after its 5-minute validity window
Scenario: Emergency TAN still valid 1 nanosecond before the 5-minute expiry
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN using the registered email
When time advances by 6 minutes
When time advances by 1 nanosecond short of 5 minutes
And the owner uses the TAN to rotate keys
Then the response status is 200
And a new api key with prefix "apix_org_" is returned
And a new rotation secret with prefix "apix_rot_" is returned
Scenario: Emergency TAN expires at exactly 5 minutes
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN using the registered email
When time advances by 5 minutes
And the owner uses the TAN to rotate keys
Then the response status is 422
@@ -145,9 +170,16 @@ Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation
When the owner requests a TAN using the registered email
Then the response status is 422
Scenario: TAN request counter resets after 24 hours
Scenario: TAN request counter is still active at exactly 24 hours
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN 3 times within the last 24 hours
When time advances by 25 hours
When time advances by 24 hours
And the owner requests a TAN using the registered email
Then the response status is 422
Scenario: TAN request counter resets at 24 hours and 1 nanosecond
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner has requested a TAN 3 times within the last 24 hours
When time advances by 24 hours and 1 nanosecond
And the owner requests a TAN using the registered email
Then the response status is 200
@@ -42,11 +42,21 @@ Feature: DNS-challenge key rotation — bot-friendly ACME DNS-01 pattern
When the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Confirm fails when the 15-minute challenge window has expired
Scenario: DNS challenge window still active 1 nanosecond before the 15-minute expiry
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the agent has published the rotation challenge to dns
When time advances by 16 minutes
When time advances by 1 nanosecond short of 15 minutes
And the agent confirms DNS-challenge key rotation
Then the response status is 200
And a new api key with prefix "apix_org_" is returned
And a new rotation secret with prefix "apix_rot_" is returned
Scenario: Confirm fails at exactly the 15-minute challenge window expiry
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the agent has published the rotation challenge to dns
When time advances by 15 minutes
And the agent confirms DNS-challenge key rotation
Then the response status is 422
@@ -0,0 +1,58 @@
Feature: Agent experience feedback
Background:
Given a sandbox named "feedback-demo" exists
Scenario: Feedback schema is globally discoverable without authentication
When GET /sandbox/feedback-schema is called without authentication
Then the response code is 200
And the schema contains at least 9 dimensions
And the schema contains dimension key "hateoas_navigation"
And the schema contains dimension key "liveness_signal_accuracy"
And the schema contains dimension key "error_message_quality"
And the schema contains dimension key "extension_property_coverage"
And the schema scale minimum is 0 and maximum is 10
Scenario: Submit valid feedback returns 202
When feedback is submitted to "feedback-demo" with scores hateoas_navigation=8 discovery_accuracy=7
Then the response code is 202
And the response message is "Feedback recorded. Thank you."
And the response contains _links.schema
And the response contains _links.sandbox
Scenario: Submit feedback with model identity
When feedback is submitted to "feedback-demo" with scores hateoas_navigation=9 and model "claude-sonnet-4-6" provider "anthropic"
Then the response code is 202
Scenario: Submit feedback with unknown dimension keys is accepted but ignored
When feedback is submitted to "feedback-demo" with scores unknown_key=5
Then the response code is 422
Scenario: Submit feedback with score out of range returns 422
When feedback is submitted to "feedback-demo" with scores hateoas_navigation=11
Then the response code is 422
Scenario: Submit feedback with empty scores returns 400
When feedback is submitted to "feedback-demo" with empty scores
Then the response code is 400
Scenario: Aggregate feedback requires sandbox API key
When the feedback aggregate for "feedback-demo" is requested without an API key
Then the response code is 401
Scenario: Aggregate feedback shows averages per dimension
Given feedback has been submitted to "feedback-demo" with scores hateoas_navigation=6 discovery_accuracy=8
And feedback has been submitted to "feedback-demo" with scores hateoas_navigation=10 discovery_accuracy=4
When the feedback aggregate for "feedback-demo" is requested with the sandbox API key
Then the response code is 200
And the total submissions is 2
And the dimension "hateoas_navigation" has average 8.0
And the dimension "discovery_accuracy" has average 6.0
Scenario: Aggregate includes provider breakdown
Given feedback has been submitted to "feedback-demo" with scores hateoas_navigation=7 and model "claude-sonnet-4-6" provider "anthropic"
And feedback has been submitted to "feedback-demo" with scores hateoas_navigation=5 and model "gpt-4o" provider "openai"
When the feedback aggregate for "feedback-demo" is requested with the sandbox API key
Then the response code is 200
And the submissionsByProvider contains "anthropic" with count 1
And the submissionsByProvider contains "openai" with count 1
@@ -0,0 +1,33 @@
Feature: HATEOAS navigation and root key resolution
Background:
Given a sandbox named "nav-demo" exists
Scenario: Root resource without API key omits _links.sandbox
When the root resource is requested without an API key
Then the response code is 200
And the response contains _links.registerSandbox
And the response contains _links.feedbackSchema
And the response does not contain _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"
Then the response code is 200
And the response contains _links.sandbox ending with "/sandbox/nav-demo"
Scenario: Root resource with unknown API key omits _links.sandbox
When the root resource is requested with API key "apix_sb_unknownkey"
Then the response code is 200
And the response does not contain _links.sandbox
Scenario: Sandbox root endpoint returns sandbox metadata
When the sandbox root for "nav-demo" is requested
Then the response code is 200
And the response contains sandbox name "nav-demo"
And the response contains _links.services
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
Then the response code is 404
@@ -0,0 +1,32 @@
Feature: Sandbox registration
Scenario: Create a sandbox and receive an API key exactly once
When an agent registers a sandbox named "peter-demo" with email "peter@openclaw.io"
Then the response code is 201
And the response contains a sandbox id
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.services
Scenario: Registration fails when name contains uppercase letters
When an agent registers a sandbox named "Peter-Demo" with email "peter@openclaw.io"
Then the response code is 400
Scenario: Registration fails when name is too short
When an agent registers a sandbox named "ab" with email "peter@openclaw.io"
Then the response code is 400
Scenario: Registration fails when name starts with a hyphen
When an agent registers a sandbox named "-demo" with email "peter@openclaw.io"
Then the response code is 400
Scenario: Registration fails when email is invalid
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
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
@@ -0,0 +1,45 @@
Feature: Sandbox service isolation from production
Background:
Given a sandbox named "isolation-test" exists
And a production service "ProdService" with endpoint "https://prod.example.com" is registered
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
Then the response code is 200
And "https://sandbox.example.com" is not in the endpoint list
Scenario: Production service does not appear in sandbox service list
Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test"
When the sandbox service list for "isolation-test" is requested
Then the response code is 200
And "https://prod.example.com" is not in the endpoint list
Scenario: Service registration in sandbox requires the sandbox API key
When a service is registered in sandbox "isolation-test" without an API key
Then the response code is 401
Scenario: Service registration in sandbox with wrong key returns 401
When a service is registered in sandbox "isolation-test" with API key "apix_sb_wrongkey"
Then the response code is 401
Scenario: Sandbox search is isolated from production results
Given a sandbox service with endpoint "https://sb-search.example.com" and capability "search.cap" is registered in "isolation-test"
When sandbox "isolation-test" services are searched by capability "search.cap"
Then the response code is 200
And "https://sb-search.example.com" is in the endpoint list
Scenario: Sandbox search does not return production services
Given a production service "ProdSearchService" with endpoint "https://prod-search.example.com" is registered
When sandbox "isolation-test" services are searched by capability "device.telemetry"
Then the response code is 200
And "https://prod-search.example.com" is not in the endpoint list
Scenario: Sandbox services can be registered with extension properties and queried by them
Given a sandbox service with endpoint "https://ext-eu.example.com" capability "data.processing" and extension "region:eu" is registered in "isolation-test"
And a sandbox service with endpoint "https://ext-us.example.com" capability "data.processing" and extension "region:us" is registered in "isolation-test"
When sandbox "isolation-test" services are searched by capability "data.processing" and property "region:eu"
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
@@ -0,0 +1,43 @@
Feature: Sandbox telemetry
Background:
Given a sandbox named "telemetry-demo" exists
Scenario: Telemetry starts empty before any activity
When the telemetry for "telemetry-demo" is requested with the sandbox API key
Then the response code is 200
And the usage map is empty or contains only zero counts
Scenario: Viewing the sandbox root increments the SANDBOX_VIEWED counter
Given the sandbox root for "telemetry-demo" has been viewed once
When the telemetry for "telemetry-demo" is requested with the sandbox API key
Then the response code is 200
And the usage counter "SANDBOX_VIEWED" is at least 1
Scenario: Registering a service increments the SERVICE_REGISTERED counter
Given a sandbox service with endpoint "https://tel.example.com" and capability "tel.cap" is registered in "telemetry-demo"
When the telemetry for "telemetry-demo" is requested with the sandbox API key
Then the response code is 200
And the usage counter "SERVICE_REGISTERED" is at least 1
Scenario: Listing services increments the SERVICE_LISTED counter
Given the sandbox service list for "telemetry-demo" has been requested once
When the telemetry for "telemetry-demo" is requested with the sandbox API key
Then the response code is 200
And the usage counter "SERVICE_LISTED" is at least 1
Scenario: Telemetry requires the sandbox API key
When the telemetry for "telemetry-demo" is requested without an API key
Then the response code is 401
Scenario: Telemetry with wrong key returns 401
When the telemetry for "telemetry-demo" is requested with API key "apix_sb_wrongkey"
Then the response code is 401
Scenario: Telemetry response includes tier metadata
When the telemetry for "telemetry-demo" is requested with the sandbox API key
Then the response code is 200
And the telemetry contains tier "FREE"
And the telemetry contains ratePerMinute 60
And the telemetry contains maxServices 10
And the telemetry contains maxOrgs 3
@@ -0,0 +1,8 @@
Feature: Sandbox tier caps
Scenario: FREE sandbox allows up to 10 services
Given a sandbox named "cap-test" exists
And 10 services have been registered in sandbox "cap-test"
When a service is registered in sandbox "cap-test" with the sandbox API key
Then the response code is 429
And the sandbox error contains "Service limit reached"