chore: add missing source modules to version control
Deploy to Production / deploy (push) Failing after 7s
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:
+5
-5
@@ -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)
|
||||
|
||||
+18
@@ -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")
|
||||
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
+556
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
+192
@@ -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));
|
||||
}
|
||||
}
|
||||
+56
@@ -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"
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+40
-8
@@ -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
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
|
||||
+58
@@ -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
|
||||
+33
@@ -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
|
||||
+32
@@ -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
|
||||
+45
@@ -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
|
||||
+43
@@ -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
|
||||
+8
@@ -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"
|
||||
Reference in New Issue
Block a user