ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s

- .gitea/workflows/deploy.yml — push-to-main triggers rolling deploy
- scripts/deploy-bluegreen.sh — a-stack then b-stack restart; Maven runs
  in Docker (no JDK needed on runner host); Caddy reload at end
- scripts/deploy-all.ps1 — emergency manual deploy from dev machine
- infra/docker-compose.yml — a/b pairs per service; wget health checks;
  Gitea service; Prometheus/Grafana/DB ports restricted to localhost
- infra/Caddyfile — dual upstreams with health-based routing
- infra/Dockerfile.* — one per service
- infra/prometheus.yml + grafana provisioning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 14:01:12 +02:00
parent 5156089152
commit 82f0ac6007
72 changed files with 4715 additions and 27 deletions
@@ -0,0 +1,27 @@
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;
/**
* Runs BDD scenarios for the IoT device connection profile feature.
* Shares step definitions with IotTransitionCucumberIT via the same glue package.
*/
@QuarkusTest
public class IotProfileCucumberIT {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-iot-profile.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/iot-profile"
);
assertEquals(0, exitCode, "One or more IoT profile Cucumber scenarios failed");
}
}
@@ -0,0 +1,30 @@
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;
/**
* Runs all IoT transition BDD scenarios inside the Quarkus test context.
*
* @QuarkusTest starts the server (DevServices PostgreSQL, port 8181).
* Main.run() invokes the Cucumber runtime directly — this bypasses the JUnit Platform
* Cucumber engine, so junit-platform.properties tag filters do not apply here.
*/
@QuarkusTest
public class IotTransitionCucumberIT {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/iot-transition"
);
assertEquals(0, exitCode, "One or more Cucumber scenarios failed — check test output for details");
}
}
@@ -0,0 +1,924 @@
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.quarkus.arc.Arc;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;
import org.botstandards.apix.registry.service.ClockService;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
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.*;
/**
* BDD step definitions for the IoT device cloud sunset transition feature group.
*
* Cucumber creates a fresh instance per scenario, so instance fields are scenario-scoped.
* The {@code actionPhase} flag separates Given (setup) from Then (assertion) for steps
* that are reused in both positions with identical text.
*/
public class IotTransitionSteps {
private static final String API_KEY_HEADER = "X-Api-Key";
private static final String API_KEY = "test-api-key";
// ── Per-scenario state ────────────────────────────────────────────────────
private final Map<String, UUID> serviceIds = new HashMap<>();
/** The "primary" service referred to as {id} or "the service" in step text. */
private UUID currentServiceId;
/** Most recent HTTP response from a When step. */
private Response lastResponse;
/** Collected responses for multi-call scenarios (cache, no-shared-headers). */
private final List<Response> capturedResponses = new ArrayList<>();
/**
* Flipped to true by the first @When step. Steps with identical Given/Then text
* switch from setup behaviour (PATCH) to assertion behaviour (GET + verify).
*/
private boolean actionPhase = false;
// ── Helpers ───────────────────────────────────────────────────────────────
private RequestSpecification asTemplateOwner() {
return given().contentType(JSON).header(API_KEY_HEADER, API_KEY);
}
private String defaultEndpointFor(String name) {
return "https://" + name.toLowerCase().replace(" ", "") + ".example";
}
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", defaultEndpointFor(name));
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;
}
/** POST /services; asserts 201; returns the new service ID. */
private UUID createService(Map<String, Object> payload) {
Response r = asTemplateOwner().body(payload).post("/services");
r.then().statusCode(201);
return UUID.fromString(r.jsonPath().getString("id"));
}
private void store(String name, UUID id) {
serviceIds.put(name, id);
currentServiceId = id;
}
private static String futureSunsetAt(int days) {
return Instant.now().plus(Duration.ofDays(days)).toString();
}
private static String pastSunsetAt(int days) {
return Instant.now().minus(Duration.ofDays(days)).toString();
}
// ── Given — service creation ──────────────────────────────────────────────
@Given("a registered service {string} with endpoint {string}")
public void aRegisteredServiceWithEndpoint(String name, String endpoint) {
Map<String, Object> p = basePayload(name);
p.put("endpoint", endpoint);
p.put("serviceStage", "PRODUCTION");
store(name, createService(p));
}
@Given("the service has capability {string}")
public void theServiceHasCapability(String capability) {
asTemplateOwner()
.body(Map.of("capabilities", List.of(capability)))
.patch("/services/" + currentServiceId)
.then().statusCode(200);
}
@Given("the service is in stage {string}")
public void theServiceIsInStage(String stage) {
asTemplateOwner()
.body(Map.of("serviceStage", stage))
.patch("/services/" + currentServiceId)
.then().statusCode(200);
}
/**
* Dual-use: before any When step (actionPhase=false) → PATCH to set the locked value;
* after a When step (actionPhase=true) → GET and verify the locked value.
*/
@Given("the service has locked set to {word}")
public void theServiceHasLockedSetTo(String locked) {
boolean val = Boolean.parseBoolean(locked);
if (!actionPhase) {
asTemplateOwner()
.body(Map.of("locked", val))
.patch("/services/" + currentServiceId)
.then().statusCode(200);
} else {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("locked", equalTo(val));
}
}
@Given("a deprecated service {string} with locked set to false")
public void aDeprecatedServiceLockedFalse(String name) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", "DEPRECATED");
p.put("locked", false);
p.put("sunsetAt", futureSunsetAt(90));
store(name, createService(p));
}
@Given("a deprecated service {string} with locked set to true")
public void aDeprecatedServiceLockedTrue(String name) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", "DEPRECATED");
p.put("locked", true);
p.put("sunsetAt", futureSunsetAt(90));
store(name, createService(p));
}
@Given("a deprecated service {string} with locked set to false and a sunset_date set")
public void aDeprecatedServiceLockedFalseWithSunsetDate(String name) {
aDeprecatedServiceLockedFalse(name);
}
@Given("a deprecated service {string} with a sunset_date {int} days from now")
public void aDeprecatedServiceWithSunsetDateDaysFromNow(String name, int days) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", "DEPRECATED");
p.put("locked", false);
p.put("sunsetAt", futureSunsetAt(days));
store(name, createService(p));
}
@Given("a registered service {string} in stage {string} with O-level {string}")
public void aRegisteredServiceInStageWithOLevel(String name, String stage, String oLevel) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", stage);
UUID id = createService(p);
store(name, id);
if (!"UNVERIFIED".equalsIgnoreCase(oLevel)) {
asTemplateOwner()
.patch("/services/" + id + "/olevel?level=" + oLevel)
.then().statusCode(200);
}
}
@Given("{string} in stage {string} with O-level {string} has declared compatibility")
public void serviceInStageWithOLevelHasDeclaredCompatibility(String name, String stage, String oLevel) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", stage);
UUID id = createService(p);
store(name, id);
if (!"UNVERIFIED".equalsIgnoreCase(oLevel)) {
asTemplateOwner()
.patch("/services/" + id + "/olevel?level=" + oLevel)
.then().statusCode(200);
}
UUID deprecatedId = serviceIds.get("SmartHub Cloud");
asTemplateOwner()
.body(Map.of("replacesServiceIds", List.of(deprecatedId.toString())))
.patch("/services/" + id)
.then().statusCode(200);
}
@Given("a service {string} in stage {string} with O-level {string}")
public void aServiceInStageWithOLevel(String name, String stage, String oLevel) {
aRegisteredServiceInStageWithOLevel(name, stage, oLevel);
}
@Given("a service {string} in stage {string} with locked set to true")
public void aServiceInStageLockedTrue(String name, String stage) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", stage);
p.put("locked", true);
if ("DEPRECATED".equals(stage)) {
p.put("sunsetAt", futureSunsetAt(90));
}
store(name, createService(p));
}
@Given("{string} has declared compatibility with {string}")
public void hasDeclaredCompatibilityWith(String provider, String deprecated) {
asTemplateOwner()
.body(Map.of("replacesServiceIds", List.of(serviceIds.get(deprecated).toString())))
.patch("/services/" + serviceIds.get(provider))
.then().statusCode(200);
}
@Given("the service is in stage {string} with a sunset_date set")
public void theServiceIsInStageWithSunsetDateSet(String stage) {
asTemplateOwner()
.body(Map.of(
"serviceStage", stage,
"sunsetAt", futureSunsetAt(90)
))
.patch("/services/" + currentServiceId)
.then().statusCode(200);
}
@Given("the sunset_date of {string} has passed")
public void theSunsetDateOfHasPassed(String name) {
currentServiceId = serviceIds.get(name);
// Set a valid future sunsetAt, then move the in-process clock to or past that
// 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);
asTemplateOwner()
.body(Map.of("sunsetAt", sunsetAt.toString()))
.patch("/services/" + currentServiceId)
.then().statusCode(200);
// Exclusive boundary: advance clock to sunsetAt — at that moment the sunset has arrived
Arc.container().instance(ClockService.class).get().advance(sunsetAt);
}
@Given("at least one replacement candidate registered")
public void atLeastOneReplacementCandidateRegistered() {
Map<String, Object> p = basePayload("TestReplacement");
p.put("serviceStage", "PRODUCTION");
UUID id = createService(p);
serviceIds.put("TestReplacement", id);
asTemplateOwner()
.body(Map.of("replacesServiceIds", List.of(serviceIds.get("SmartHub Cloud").toString())))
.patch("/services/" + id)
.then().statusCode(200);
}
@Given("a decommissioned service")
public void aDecommissionedService() {
Map<String, Object> p = basePayload("DecommissionedService");
p.put("serviceStage", "DECOMMISSIONED");
p.put("sunsetAt", pastSunsetAt(1));
UUID id = createService(p);
store("DecommissionedService", id);
}
@Given("a deprecated service {string} that reached DECOMMISSIONED without setting locked=false")
public void aDeprecatedServiceDecommissionedWithoutLockRelease(String name) {
Map<String, Object> p = basePayload(name);
p.put("serviceStage", "DECOMMISSIONED");
p.put("locked", true);
p.put("sunsetAt", pastSunsetAt(1));
store(name, createService(p));
}
@Given("{string} has completed the full lifecycle")
public void hasCompletedTheFullLifecycle(String name) {
currentServiceId = serviceIds.get(name);
// Background walks through PRODUCTION→DEPRECATED→locked=false; sunset has passed.
// Complete the lifecycle by decommissioning.
asTemplateOwner()
.body(Map.of("serviceStage", "DECOMMISSIONED"))
.patch("/services/" + currentServiceId)
.then().statusCode(200);
}
// ── When — template owner mutations ──────────────────────────────────────
@When("the template owner updates the service with sunset_date 90 days from now and stage {string}")
public void templateOwnerUpdatesSunsetDateAndStage(String stage) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of(
"serviceStage", stage,
"sunsetAt", futureSunsetAt(90)
))
.patch("/services/" + currentServiceId);
}
@When("the template owner sets locked to false")
public void templateOwnerSetsLockedFalse() {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("locked", false))
.patch("/services/" + currentServiceId);
}
@When("the template owner attempts to set locked to false without a sunset_date")
public void templateOwnerAttemptsToSetLockedFalseWithoutSunsetDate() {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("locked", false))
.patch("/services/" + currentServiceId);
}
@When("the template owner attempts to set sunset_date to yesterday")
public void templateOwnerAttemptsToSetSunsetDateToYesterday() {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("sunsetAt", pastSunsetAt(1)))
.patch("/services/" + currentServiceId);
}
@When("the template owner sets service_stage to {string}")
public void templateOwnerSetsServiceStage(String stage) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("serviceStage", stage))
.patch("/services/" + currentServiceId);
}
@When("the template owner attempts to set service_stage to {string}")
public void templateOwnerAttemptsToSetServiceStage(String stage) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("serviceStage", stage))
.patch("/services/" + currentServiceId);
}
// ── When — replacement declaration mutations ──────────────────────────────
@When("{string} declares replacesServiceIds containing the ID of {string}")
public void declareReplacement(String provider, String deprecated) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("replacesServiceIds", List.of(serviceIds.get(deprecated).toString())))
.patch("/services/" + serviceIds.get(provider));
}
@When("{string} declares replacesServiceIds containing the ID of {string} again")
public void declareReplacementAgain(String provider, String deprecated) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("replacesServiceIds", List.of(serviceIds.get(deprecated).toString())))
.patch("/services/" + serviceIds.get(provider));
}
@When("{string} removes {string} from its replacesServiceIds")
public void retractReplacement(String provider, String deprecated) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("replacesServiceIds", List.of()))
.patch("/services/" + serviceIds.get(provider));
}
// ── When — anonymous HTTP GET calls ──────────────────────────────────────
// Regex annotations because step text contains URI-template placeholders like
// {smartHubCloudId} that are not registered Cucumber parameter types.
@When("^GET /services/\\{smartHubCloudId\\} is called with no (?:authentication|Authorization) header$")
public void getSmartHubCloudStatusAnonymous() {
actionPhase = true;
lastResponse = given().get("/services/" + serviceIds.get("SmartHub Cloud"));
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements is called with no (?:authentication|Authorization) header$")
public void getSmartHubCloudReplacementsAnonymous() {
actionPhase = true;
lastResponse = given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?minOLevel=LEGAL_ENTITY_VERIFIED is called with no (?:authentication|Authorization) header$")
public void getReplacementsFilteredByMinOLevel() {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?minOLevel=LEGAL_ENTITY_VERIFIED");
}
@When("^GET /services/\\{lockedCloudId\\}/replacements is called with no (?:authentication|Authorization) header$")
public void getLockedCloudReplacementsAnonymous() {
actionPhase = true;
lastResponse = given().get("/services/" + serviceIds.get("LockedCloud") + "/replacements");
}
@When("^GET /services/\\{neverReleasedId\\}/replacements is called with no (?:authentication|Authorization) header$")
public void getNeverReleasedReplacementsAnonymous() {
actionPhase = true;
lastResponse = given().get("/services/" + serviceIds.get("NeverReleased") + "/replacements");
}
@When("^GET /services\\?capability=device\\.telemetry is called with no (?:authentication|Authorization) header$")
public void getServicesByCapabilityAnonymous() {
actionPhase = true;
lastResponse = given().get("/services?capability=device.telemetry");
}
@When("^GET /services\\?capability=device\\.telemetry&stage=deprecated is called$")
public void getServicesByCapabilityAndDeprecatedStage() {
actionPhase = true;
lastResponse = given().get("/services?capability=device.telemetry&stage=deprecated");
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements is called twice with no shared headers$")
public void getReplacementsTwiceNoSharedHeaders() {
actionPhase = true;
capturedResponses.clear();
String url = "/services/" + serviceIds.get("SmartHub Cloud") + "/replacements";
capturedResponses.add(given().get(url));
capturedResponses.add(given().get(url));
lastResponse = capturedResponses.get(1);
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements is called twice within the cache TTL$")
public void getReplacementsTwiceWithinCacheTtl() {
actionPhase = true;
capturedResponses.clear();
String url = "/services/" + serviceIds.get("SmartHub Cloud") + "/replacements";
capturedResponses.add(given().get(url));
capturedResponses.add(given().get(url));
lastResponse = capturedResponses.get(1);
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements is called$")
public void getSmartHubCloudReplacements() {
actionPhase = true;
lastResponse = given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
}
@When("^GET /services/\\{id\\} is called$")
public void getServiceById() {
actionPhase = true;
lastResponse = given().get("/services/" + currentServiceId);
}
@When("^GET /services/\\{id\\}/history is called$")
public void getServiceHistory() {
actionPhase = true;
lastResponse = given().get("/services/" + currentServiceId + "/history");
}
// ── Then — lifecycle state assertions ────────────────────────────────────
@Then("the service stage is {string}")
public void theServiceStageIs(String stage) {
lastResponse.then().statusCode(200).body("serviceStage", equalTo(stage));
}
@Then("the service has a sunset_date set")
public void theServiceHasSunsetDateSet() {
lastResponse.then().statusCode(200).body("sunsetAt", notNullValue());
}
@Then("a version history entry of type {string} exists for the service")
public void versionHistoryEntryExistsForService(String type) {
given().get("/services/" + currentServiceId + "/history")
.then().statusCode(200)
.body("type", hasItem(type));
}
@Then("a version history entry of type {string} exists for {string}")
public void versionHistoryEntryExistsForNamedService(String type, String name) {
given().get("/services/" + serviceIds.get(name) + "/history")
.then().statusCode(200)
.body("type", hasItem(type));
}
@Then("the service does not appear in default production search results for capability {string}")
public void serviceNotInDefaultProductionResults(String capability) {
String name = given().get("/services/" + currentServiceId)
.jsonPath().getString("name");
given().get("/services?capability=" + capability)
.then().statusCode(200)
.body("name", not(hasItem(name)));
}
@Then("the service appears in search results when stage filter is {string}")
public void serviceAppearsInResultsForStageFilter(String stage) {
String name = given().get("/services/" + currentServiceId)
.jsonPath().getString("name");
given().get("/services?capability=device.telemetry&stage=" + stage.toLowerCase())
.then().statusCode(200)
.body("name", hasItem(name));
}
@Then("^GET /services/\\{id\\}/replacements returns HTTP 200 with an empty list$")
public void replacementsEndpointReturnsHttp200EmptyList() {
given().get("/services/" + currentServiceId + "/replacements")
.then().statusCode(200)
.body("candidates", empty());
}
@Then("the version history entry contains the previous locked value true")
public void versionHistoryEntryContainsPreviousLockedValueTrue() {
given().get("/services/" + currentServiceId + "/history")
.then().statusCode(200)
.body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true"));
}
// ── Then — HTTP response assertions ──────────────────────────────────────
@Then("the response is HTTP {int}")
public void theResponseIsHttp(int statusCode) {
lastResponse.then().statusCode(statusCode);
}
@Then("the error message contains {string}")
public void theErrorMessageContains(String message) {
lastResponse.then().body("message", containsString(message));
}
@Then("the response body contains service_stage {string}")
public void responseBodyContainsServiceStage(String stage) {
lastResponse.then().statusCode(200).body("serviceStage", equalTo(stage));
}
@Then("the response body contains locked {word}")
public void responseBodyContainsLocked(String locked) {
lastResponse.then().statusCode(200).body("locked", equalTo(Boolean.parseBoolean(locked)));
}
@Then("the response body contains a sunset_date")
public void responseBodyContainsSunsetDate() {
lastResponse.then().statusCode(200).body("sunsetAt", notNullValue());
}
@Then("the response body contains an empty candidates list")
public void responseBodyContainsEmptyCandidatesList() {
lastResponse.then().statusCode(200).body("candidates", empty());
}
@Then("locked and sunset_date are present in the response body")
public void lockedAndSunsetDatePresentInResponseBody() {
lastResponse.then().statusCode(200)
.body("locked", notNullValue())
.body("sunsetAt", notNullValue());
}
// ── Then — replacement list assertions ───────────────────────────────────
@Then("the response contains {int} candidate(s)")
public void responseContainsCandidates(int count) {
lastResponse.then().statusCode(200).body("candidates.size()", equalTo(count));
}
@Then("the candidate is {string}")
public void theCandidateIs(String name) {
lastResponse.then().statusCode(200).body("candidates[0].name", equalTo(name));
}
@Then("{string} appears before {string} in the results")
public void appearsBeforeInResults(String first, String second) {
List<String> names = lastResponse.jsonPath().getList("candidates.name");
assertThat(names.indexOf(first))
.as("%s should appear before %s in results %s", first, second, names)
.isLessThan(names.indexOf(second));
}
@Then("the ordering is by O-level descending")
public void orderingIsByOLevelDescending() {
List<String> oLevels = lastResponse.jsonPath().getList("candidates.oLevel");
List<String> expected = new ArrayList<>(oLevels);
expected.sort(Comparator.reverseOrder());
assertThat(oLevels)
.as("Candidates should be ordered by O-level descending")
.isEqualTo(expected);
}
@Then("^GET /services/\\{smartHubCloudId\\}/replacements includes \"([^\"]+)\"$")
public void replacementsIncludes(String name) {
given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements")
.then().statusCode(200)
.body("candidates.name", hasItem(name));
}
@Then("^GET /services/\\{smartHubCloudId\\}/replacements returns (\\d+) candidates?$")
public void replacementsReturnsNCandidates(int count) {
lastResponse = given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
lastResponse.then().statusCode(200)
.body("candidates.size()", equalTo(count));
}
@Then("^GET /services/\\{smartHubCloudId\\}/replacements no longer includes \"([^\"]+)\"$")
public void replacementsNoLongerIncludes(String name) {
given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements")
.then().statusCode(200)
.body("candidates.name", not(hasItem(name)));
}
@Then("^GET /services/\\{smartHubCloudId\\}/replacements still returns exactly (\\d+) candidates?$")
public void replacementsStillReturnsExactlyCandidates(int count) {
given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements")
.then().statusCode(200)
.body("candidates.size()", equalTo(count));
}
@Then("the service_replacements table contains the declared pair")
public void serviceReplacementsTableContainsDeclaredPair() {
given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements")
.then().statusCode(200)
.body("candidates.size()", greaterThanOrEqualTo(1));
}
@Then("the service_replacements row for this pair is deleted")
public void serviceReplacementsRowDeleted() {
given().get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements")
.then().statusCode(200)
.body("candidates", empty());
}
// ── Then — capability search assertions ──────────────────────────────────
@Then("{string} is not in the results")
public void isNotInResults(String name) {
lastResponse.then().statusCode(200).body("name", not(hasItem(name)));
}
@Then("{string} is in the results")
public void isInResults(String name) {
lastResponse.then().statusCode(200).body("name", hasItem(name));
}
@Then("only services with stage {string} are returned")
public void onlyServicesWithStageReturned(String stage) {
lastResponse.then().statusCode(200).body("serviceStage", everyItem(equalTo(stage)));
}
@Then("each result has service_stage {string}")
public void eachResultHasServiceStage(String stage) {
lastResponse.then().statusCode(200).body("serviceStage", everyItem(equalTo(stage)));
}
// ── Then — decommissioning assertions ────────────────────────────────────
@Then("the service does not appear in any capability search results")
public void serviceNotInAnyCapabilitySearchResults() {
String name = given().get("/services/" + currentServiceId)
.jsonPath().getString("name");
given().get("/services?capability=device.telemetry")
.then().statusCode(200)
.body("name", not(hasItem(name)));
}
@Then("^GET /services/\\{id\\} returns HTTP 200 with the complete historical record$")
public void getServiceByIdReturnsHistoricalRecord() {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("serviceStage", notNullValue())
.body("name", notNullValue());
}
@Then("the full BSM payload is present in the response")
public void fullBsmPayloadPresentInResponse() {
lastResponse.then().statusCode(200)
.body("name", notNullValue())
.body("endpoint", notNullValue())
.body("serviceStage", notNullValue());
}
@Then("^all version history entries are accessible via GET /services/\\{id\\}/history$")
public void allVersionHistoryEntriesAccessibleViaHistory() {
given().get("/services/" + currentServiceId + "/history")
.then().statusCode(200)
.body("size()", greaterThanOrEqualTo(1));
}
@Then("replacement candidates are returned regardless of the stored locked value")
public void replacementCandidatesReturnedRegardlessOfLockedValue() {
lastResponse.then().statusCode(200).body("candidates", not(nullValue()));
}
@Then("the full replacement list is returned")
public void fullReplacementListReturned() {
lastResponse.then().statusCode(200).body("candidates.size()", greaterThanOrEqualTo(1));
}
// ── Then — version history timeline assertions ────────────────────────────
@Then("the history contains an entry of type {string}")
public void historyContainsEntryOfType(String type) {
lastResponse.then().statusCode(200).body("type", hasItem(type));
}
@Then("the history contains an entry of type {string} with new value {string}")
public void historyContainsEntryOfTypeWithNewValue(String type, String value) {
lastResponse.then().statusCode(200)
.body("find { it.type == '" + type + "' }.newValue", equalTo(value));
}
@Then("all entries are ordered chronologically ascending")
public void allEntriesOrderedChronologicallyAscending() {
List<String> timestamps = lastResponse.jsonPath().getList("createdAt");
List<String> sorted = new ArrayList<>(timestamps);
Collections.sort(sorted);
assertThat(timestamps)
.as("History entries should be ordered chronologically ascending")
.isEqualTo(sorted);
}
// ── Then — anonymity / cache assertions ──────────────────────────────────
@Then("both responses are identical in content")
public void bothResponsesIdenticalInContent() {
assertThat(capturedResponses).hasSize(2);
assertThat(capturedResponses.get(0).asString())
.as("Both anonymous responses must return identical content")
.isEqualTo(capturedResponses.get(1).asString());
}
@Then("neither response contains a Set-Cookie header")
public void neitherResponseContainsSetCookieHeader() {
for (Response r : capturedResponses) {
assertThat(r.getHeader("Set-Cookie"))
.as("Anonymous response must not set a cookie")
.isNull();
}
}
@Then("neither response contains a session reference")
public void neitherResponseContainsSessionReference() {
for (Response r : capturedResponses) {
assertThat(r.getHeader("Set-Cookie")).isNull();
assertThat(r.asString().toLowerCase()).doesNotContain("session");
}
}
@Then("the response headers contain no Set-Cookie")
public void responseHeadersContainNoSetCookie() {
assertThat(lastResponse.getHeader("Set-Cookie"))
.as("Response must not set a cookie")
.isNull();
}
@Then("the response headers contain a Server-Timing header")
public void responseHeadersContainServerTiming() {
assertThat(lastResponse.getHeader("Server-Timing"))
.as("Every response must carry a Server-Timing header with app;dur measured")
.isNotNull()
.containsIgnoringCase("app;dur=");
}
@Then("the response body contains no field that echoes client request details")
public void responseBodyContainsNoFieldEchoingClientDetails() {
String body = lastResponse.asString().toLowerCase();
assertThat(body).doesNotContain("user-agent");
assertThat(body).doesNotContain("x-forwarded-for");
assertThat(body).doesNotContain("remote-addr");
}
@Then("the response body contains no correlation ID tied to the caller")
public void responseBodyContainsNoCorrelationId() {
String body = lastResponse.asString().toLowerCase();
assertThat(body).doesNotContain("requestid");
assertThat(body).doesNotContain("correlationid");
assertThat(body).doesNotContain("traceid");
}
@Then("the second response is served from cache")
public void secondResponseServedFromCache() {
assertThat(capturedResponses).hasSize(2);
Response second = capturedResponses.get(1);
String cacheControl = second.getHeader("Cache-Control");
assertThat(cacheControl)
.as("Replacements response must carry Cache-Control: public to enable proxy caching")
.isNotNull()
.containsIgnoringCase("public");
}
@Then("the cache key does not incorporate any client-identifying header")
public void cacheKeyDoesNotIncorporateClientIdentifyingHeader() {
String vary = lastResponse.getHeader("Vary");
if (vary != null) {
assertThat(vary.toLowerCase())
.as("Vary header must not include client-identifying headers")
.doesNotContain("authorization")
.doesNotContain("cookie")
.doesNotContain("user-agent");
}
}
// ── IoT Profile — Given (setup) ──────────────────────────────────────────
@Given("{string} has IoT profile hubUrl {string} protocols {string} deviceClasses {string}")
public void hasIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
asTemplateOwner()
.body(Map.of("iotProfile", Map.of(
"hubUrl", hubUrl,
"protocols", List.of(protocols),
"deviceClasses", List.of(deviceClasses)
)))
.patch("/services/" + serviceIds.get(name))
.then().statusCode(200);
}
// ── IoT Profile — When ───────────────────────────────────────────────────
@When("{string} sets its IoT profile with hubUrl {string} protocols {string} deviceClasses {string}")
public void setsIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of(
"hubUrl", hubUrl,
"protocols", List.of(protocols),
"deviceClasses", List.of(deviceClasses)
)))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("{string} updates its IoT profile hubUrl to {string}")
public void updatesIotProfileHubUrl(String name, String hubUrl) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("{string} patches iotProfile with only hubUrl {string}")
public void patchesIotProfileWithOnlyHubUrl(String name, String hubUrl) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
.patch("/services/" + serviceIds.get(name));
}
@When("{string} sets its full IoT profile")
public void setsFullIotProfile(String name) {
actionPhase = true;
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("hubUrl", "wss://hub.openhub.io/v2");
profile.put("protocols", List.of("MQTT_5_0"));
profile.put("tlsMinVersion", "1.3");
profile.put("provisioningEndpoint", "https://onboard.openhub.io/v1/provision");
profile.put("credentialFormat", "JWT");
profile.put("deviceIdField", "serialNumber");
profile.put("deviceClasses", List.of("device.class.smart-home-hub"));
profile.put("minFirmwareVersion", "2.0.0");
profile.put("firmwareUpdateUrl", "https://firmware.openhub.io/update");
profile.put("estimatedMigrationMinutes", 5);
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", profile))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
public void getReplacementsIotReady() {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
public void getReplacementsFilteredByDeviceClass(String deviceClass) {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?deviceClass=" + deviceClass);
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?protocol=([^ ]+) is called with no (?:authentication|Authorization) header$")
public void getReplacementsFilteredByProtocol(String protocol) {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?protocol=" + protocol);
}
// ── IoT Profile — Then ───────────────────────────────────────────────────
@Then("^GET /services/\\{openHubId\\} includes an iotProfile$")
public void getOpenHubIncludesIotProfile() {
given().get("/services/" + serviceIds.get("OpenHub"))
.then().statusCode(200)
.body("iotProfile", notNullValue());
}
@Then("the iotProfile.hubUrl is {string}")
public void iotProfileHubUrlIs(String expected) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.hubUrl", equalTo(expected));
}
@Then("the iotProfile.protocols contains {string}")
public void iotProfileProtocolsContains(String protocol) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.protocols", hasItem(protocol));
}
@Then("the candidate {string} has an iotProfile with hubUrl {string}")
public void candidateHasIotProfileWithHubUrl(String name, String hubUrl) {
lastResponse.then().statusCode(200)
.body("candidates.find { it.name == '" + name + "' }.iotProfile.hubUrl", equalTo(hubUrl));
}
@Then("the iotProfile field {string} is {string}")
public void iotProfileFieldIs(String field, String value) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile." + field, equalTo(value));
}
@Then("the iotProfile.estimatedMigrationMinutes is {int}")
public void iotProfileEstimatedMigrationMinutes(int minutes) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.estimatedMigrationMinutes", equalTo(minutes));
}
}
@@ -0,0 +1,27 @@
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;
/**
* Runs all organisation onboarding BDD scenarios inside the Quarkus test context.
* WireMock (port 9999) is started by WireMockSetup via @BeforeAll/@AfterAll.
*/
@QuarkusTest
public class OrgOnboardingCucumberIT {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-org-onboarding.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/org-onboarding"
);
assertEquals(0, exitCode, "One or more org-onboarding Cucumber scenarios failed — check test output for details");
}
}
@@ -0,0 +1,918 @@
package org.botstandards.apix.registry.bdd;
import com.github.tomakehurst.wiremock.client.WireMock;
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import io.quarkus.arc.Arc;
import io.restassured.response.Response;
import org.botstandards.apix.registry.service.ClockService;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static io.restassured.RestAssured.given;
import static io.restassured.http.ContentType.JSON;
import static org.assertj.core.api.Assertions.assertThat;
/**
* BDD step definitions for organisation onboarding.
*
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
* WireMock is started once per test run by WireMockSetup via @BeforeAll.
*/
public class OrgOnboardingSteps {
private static final String ORG_API_KEY_HEADER = "X-Org-Api-Key";
private static final String ROTATION_SECRET_HEADER = "X-Org-Rotation-Secret";
private static final String ADMIN_API_KEY_HEADER = "X-Api-Key";
private static final String ADMIN_API_KEY = "test-api-key";
private static final String DEFAULT_JURISDICTION = "DE";
private static final String DEFAULT_ORG_TYPE = "GmbH";
// ── Per-scenario state ────────────────────────────────────────────────────
private UUID orgId;
private String apiKey;
private String rotationSecret;
private String dnsToken;
private String tan;
private String oldApiKey;
private Response lastResponse;
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Before(order = 2)
public void resetWireMock() {
if (WireMockSetup.wireMock != null && WireMockSetup.wireMock.isRunning()) {
WireMockSetup.wireMock.resetAll();
}
}
// ── Setup: registry state ─────────────────────────────────────────────────
@Given("the organisation registry is empty")
public void organisationRegistryIsEmpty() {
// Handled by TestSetup @Before(order=1) — TRUNCATE includes organizations table
}
@Given("an organisation is registered with target level {string} for domain {string}")
public void orgRegisteredWithTargetAndDomain(String targetLevel, String domain) {
registerOrg("Test Organisation", "test@" + domain, DEFAULT_JURISDICTION,
DEFAULT_ORG_TYPE, domain, targetLevel);
}
@Given("an organisation is registered with target level {string} for domain {string} with name {string}")
public void orgRegisteredWithTargetDomainAndName(String targetLevel, String domain, String name) {
registerOrg(name, "test@" + domain, DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, domain, targetLevel);
}
@Given("an organisation has earned O-level {string} with target {string}")
public void orgWithEarnedAndTarget(String earnedLevel, String targetLevel) {
registerOrg("Test Organisation", "test@example.com", DEFAULT_JURISDICTION,
DEFAULT_ORG_TYPE, "example.com", targetLevel);
// Use admin API to set the earned level directly (bypasses pipeline for test setup)
given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.body(Map.of("earnedOLevel", earnedLevel))
.when()
.patch("/organizations/" + orgId + "/earned-level")
.then()
.statusCode(200);
}
// ── Setup: WireMock DNS stubs ─────────────────────────────────────────────
@Given("the DoH TXT record {string} returns the org's dns token")
@Given("the DoH TXT record {string} now returns the org's dns token")
public void dohTxtReturnsToken(String name) {
assertThat(dnsToken).as("dns token must be known before stubbing").isNotNull();
stubDohTxt(name, "apix-token=" + dnsToken);
}
@Given("the DoH TXT record {string} returns no records")
public void dohTxtReturnsNoRecords(String name) {
stubDohTxtEmpty(name);
}
@Given("the DoH TXT record {string} returns {string}")
public void dohTxtReturnsValue(String name, String value) {
stubDohTxt(name, value);
}
@Given("the DoH TXT record {string} returns a valid DMARC policy")
public void dohTxtReturnsDmarc(String name) {
stubDohTxt(name, "v=DMARC1; p=none; rua=mailto:dmarc@example.com");
}
@Given("the DoH TXT record {string} returns a valid SPF record")
public void dohTxtReturnsSpf(String name) {
stubDohTxt(name, "v=spf1 include:_spf.example.com ~all");
}
@Given("the DoH MX record {string} returns at least one entry")
public void dohMxReturnsEntry(String domain) {
stubDohMx(domain, true);
}
@Given("the DoH MX record {string} returns no records")
public void dohMxReturnsNoRecords(String domain) {
stubDohMx(domain, false);
}
// ── Setup: WireMock GLEIF / OC stubs ─────────────────────────────────────
@Given("the GLEIF API returns an LEI {string} for name {string}")
public void gleifReturnsLei(String lei, String name) {
stubGleifFound(lei);
}
@Given("the GLEIF API returns no results for name {string}")
public void gleifReturnsEmpty(String name) {
stubGleifEmpty();
}
@Given("the OpenCorporates API returns a company match for name {string}")
public void ocReturnsMatch(String name) {
stubOcFound();
}
@Given("the OpenCorporates API returns no results for name {string}")
public void ocReturnsEmpty(String name) {
stubOcEmpty();
}
// ── Setup: WireMock security.txt stub ─────────────────────────────────────
@Given("the security.txt endpoint for {string} returns HTTP {int}")
public void securityTxtReturnsStatus(String domain, int status) {
stubSecurityTxt(domain, status);
}
// ── Setup: BSF admin actions used as Given ────────────────────────────────
@Given("a BSF admin grants a temporary level {string} expiring in {int} days")
public void adminGrantsTempLevelDays(String level, int days) {
doAdminGrantTemp(level, clockNow().plus(Duration.ofDays(days)));
}
@Given("a BSF admin grants a temporary level {string} expiring in {int} hours")
public void adminGrantsTempLevelHoursGiven(String level, int hours) {
doAdminGrantTemp(level, clockNow().plus(Duration.ofHours(hours)));
}
@Given("the owner has requested a TAN using the registered email")
public void ownerRequestsTanGiven() {
ownerRequestsTanWhen("test@example.com");
tan = lastResponse.jsonPath().getString("tan");
}
@Given("the owner has requested a TAN {int} times within the last 24 hours")
public void ownerRequestedTanNTimes(int count) {
for (int i = 0; i < count; i++) {
given()
.contentType(JSON)
.body(Map.of("email", "test@example.com"))
.when()
.post("/organizations/" + orgId + "/request-tan")
.then()
.statusCode(200);
}
}
// ── When: registration ────────────────────────────────────────────────────
@When("a service registers with target level {string}")
public void serviceRegistersWithTarget(String targetLevel) {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Organisation", "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", targetLevel))
.when()
.post("/organizations")
.andReturn();
if (lastResponse.statusCode() == 201) {
saveRegistrationState(lastResponse);
}
}
@When("a service registers without a registrant name")
public void registerWithoutName() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody(null, "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", "UNVERIFIED"))
.when()
.post("/organizations")
.andReturn();
}
@When("a service registers with an invalid email")
public void registerWithInvalidEmail() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Org", "not-an-email",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", "UNVERIFIED"))
.when()
.post("/organizations")
.andReturn();
}
@When("a service registers without a domain")
public void registerWithoutDomain() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Org", "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, null, "UNVERIFIED"))
.when()
.post("/organizations")
.andReturn();
}
@When("a service registers without a target O-level")
public void registerWithoutTarget() {
lastResponse = given()
.contentType(JSON)
.body(buildRegistrationBody("Test Org", "test@example.com",
DEFAULT_JURISDICTION, DEFAULT_ORG_TYPE, "example.com", null))
.when()
.post("/organizations")
.andReturn();
}
// ── When: verification ────────────────────────────────────────────────────
@When("the owner triggers verification")
@When("the owner triggers verification again")
public void ownerTriggersVerification() {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, apiKey)
.when()
.post("/organizations/" + orgId + "/verify")
.andReturn();
if (lastResponse.statusCode() == 200) {
updateStateFromOrgResponse(lastResponse);
}
}
@When("the owner triggers verification with an invalid api key")
public void ownerTriggersVerificationWithBadKey() {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, "wrong-key")
.when()
.post("/organizations/" + orgId + "/verify")
.andReturn();
}
// ── When: upgrade ─────────────────────────────────────────────────────────
@When("the owner requests an upgrade to target level {string}")
public void ownerRequestsUpgrade(String targetLevel) {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, apiKey)
.body(Map.of("targetOLevel", targetLevel))
.when()
.post("/organizations/" + orgId + "/request-upgrade")
.andReturn();
if (lastResponse.statusCode() == 200) {
updateStateFromOrgResponse(lastResponse);
}
}
@When("the owner requests an upgrade to target level {string} with an invalid api key")
public void ownerRequestsUpgradeWithBadKey(String targetLevel) {
lastResponse = given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, "wrong-key")
.body(Map.of("targetOLevel", targetLevel))
.when()
.post("/organizations/" + orgId + "/request-upgrade")
.andReturn();
}
// ── When: BSF admin actions ───────────────────────────────────────────────
// @Given step definitions above also match "When" and "And" keywords — no duplicates needed.
@Given("a BSF admin grants a temporary level {string} expiring in the past")
public void adminGrantsTempLevelInThePast(String level) {
doAdminGrantTemp(level, clockNow().minus(Duration.ofHours(1)));
}
@When("an unauthorised caller grants a temporary level {string}")
public void unauthorisedGrantsTempLevel(String level) {
Instant expiresAt = clockNow().plus(Duration.ofDays(1));
lastResponse = given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, "wrong-admin-key")
.body(Map.of(
"tempMaxOLevel", level,
"expiresAt", expiresAt.toString(),
"grantedBy", "hacker",
"reason", "unauthorized"))
.when()
.post("/organizations/" + orgId + "/temp-grant")
.andReturn();
}
@When("a BSF admin revokes the temporary grant")
public void adminRevokesTemp() {
lastResponse = given()
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.when()
.delete("/organizations/" + orgId + "/temp-grant")
.andReturn();
}
@When("a BSF admin assigns earned level {string}")
public void adminAssignsEarnedLevel(String level) {
lastResponse = given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.body(Map.of("earnedOLevel", level))
.when()
.patch("/organizations/" + orgId + "/earned-level")
.andReturn();
}
// ── DNS-challenge key rotation steps ─────────────────────────────────────
/** Stored per-scenario: the challenge token returned by /rotate-key-dns */
private String dnsRotationChallengeToken;
@When("the agent initiates DNS-challenge key rotation")
public void agentInitiatesDnsRotation() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, rotationSecret)
.when()
.post("/organizations/" + orgId + "/rotate-key-dns")
.andReturn();
if (lastResponse.statusCode() == 200) {
dnsRotationChallengeToken = lastResponse.jsonPath().getString("challengeToken");
}
}
@Given("the agent has initiated DNS-challenge key rotation")
public void agentHasInitiatedDnsRotation() {
agentInitiatesDnsRotation();
assertThat(lastResponse.statusCode()).as("DNS rotation initiation must succeed").isEqualTo(200);
}
@When("the agent initiates DNS-challenge key rotation with an invalid rotation secret")
public void agentInitiatesDnsRotationBadSecret() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, "wrong-rotation-secret")
.when()
.post("/organizations/" + orgId + "/rotate-key-dns")
.andReturn();
}
@Given("the agent has published the rotation challenge to dns")
public void agentHasPublishedRotationChallenge() {
assertThat(dnsRotationChallengeToken)
.as("challenge token must be known before stubbing DNS").isNotNull();
stubDohTxt("_apix-rotation.example.com", "apix-rotate=" + dnsRotationChallengeToken);
}
@Given("the DNS rotation challenge record is absent")
public void dnsRotationChallengeAbsent() {
stubDohTxtEmpty("_apix-rotation.example.com");
}
@Given("the DNS record {string} contains the wrong challenge token")
public void dnsRecordContainsWrongToken(String name) {
stubDohTxt(name, "apix-rotate=WRONG_TOKEN_VALUE");
}
@When("the agent confirms DNS-challenge key rotation")
public void agentConfirmsDnsRotation() {
oldApiKey = apiKey;
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, rotationSecret)
.when()
.post("/organizations/" + orgId + "/confirm-key-rotation-dns")
.andReturn();
if (lastResponse.statusCode() == 200) {
apiKey = lastResponse.jsonPath().getString("apiKey");
rotationSecret = lastResponse.jsonPath().getString("rotationSecret");
}
}
@When("the agent confirms DNS-challenge key rotation with an invalid rotation secret")
public void agentConfirmsDnsRotationBadSecret() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, "wrong-rotation-secret")
.when()
.post("/organizations/" + orgId + "/confirm-key-rotation-dns")
.andReturn();
}
@Then("the response contains the dns record name {string}")
public void responseContainsDnsRecordName(String expectedName) {
assertThat(lastResponse.jsonPath().getString("dnsRecord"))
.as("dnsRecord").isEqualTo(expectedName);
}
@Then("the response contains the challenge token and dns value format")
public void responseContainsChallengeTokenAndDnsValue() {
String token = lastResponse.jsonPath().getString("challengeToken");
String value = lastResponse.jsonPath().getString("dnsValue");
assertThat(token).as("challengeToken").isNotBlank();
assertThat(value).as("dnsValue").isEqualTo("apix-rotate=" + token);
dnsRotationChallengeToken = token;
}
@Then("the response contains an expiry timestamp")
public void responseContainsExpiryTimestamp() {
assertThat(lastResponse.jsonPath().getString("expiresAt"))
.as("expiresAt").isNotBlank();
}
// ── When: key rotation (2FA — step 1: rotation secret → TAN) ────────────────
@When("the owner initiates key rotation using the rotation secret")
public void ownerInitiatesKeyRotation() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, rotationSecret)
.when()
.post("/organizations/" + orgId + "/rotate-key")
.andReturn();
if (lastResponse.statusCode() == 200) {
tan = lastResponse.jsonPath().getString("tan");
}
}
@Given("the owner has initiated key rotation using the rotation secret")
public void ownerHasInitiatedKeyRotation() {
ownerInitiatesKeyRotation();
assertThat(lastResponse.statusCode()).as("rotation initiation must succeed").isEqualTo(200);
}
@When("the owner initiates key rotation using an invalid rotation secret")
public void ownerInitiatesKeyRotationWithBadSecret() {
lastResponse = given()
.contentType(JSON)
.header(ROTATION_SECRET_HEADER, "wrong-rotation-secret")
.when()
.post("/organizations/" + orgId + "/rotate-key")
.andReturn();
}
// ── When: key rotation step 2 — confirm with TAN ─────────────────────────
@When("the owner confirms key rotation with the TAN")
public void ownerConfirmsKeyRotation() {
oldApiKey = apiKey;
lastResponse = given()
.contentType(JSON)
.body(Map.of("tan", tan))
.when()
.post("/organizations/" + orgId + "/rotate-key-with-tan")
.andReturn();
if (lastResponse.statusCode() == 200) {
apiKey = lastResponse.jsonPath().getString("apiKey");
rotationSecret = lastResponse.jsonPath().getString("rotationSecret");
}
}
// ── When: assertion-only steps for key rotation ───────────────────────────
@Then("the response contains a key rotation warning with the org name")
public void responseContainsRotationWarning() {
String message = lastResponse.jsonPath().getString("message");
assertThat(message).as("rotation warning message").contains("Test Organisation");
}
@Then("a key rotation TAN has been sent")
public void keyRotationTanHasBeenSent() {
// In test mode, TAN is exposed in response body
String exposedTan = lastResponse.jsonPath().getString("tan");
assertThat(exposedTan).as("TAN must be present in test mode").isNotBlank();
tan = exposedTan;
}
// ── When: fraud report ────────────────────────────────────────────────────
@Given("the owner reports key rotation fraud using the registered email")
public void ownerReportsFraud() {
lastResponse = given()
.contentType(JSON)
.body(Map.of("email", "test@example.com"))
.when()
.post("/organizations/" + orgId + "/notify-key-rotation-fraud")
.andReturn();
}
@When("a caller reports key rotation fraud using {string}")
public void callerReportsFraud(String email) {
lastResponse = given()
.contentType(JSON)
.body(Map.of("email", email))
.when()
.post("/organizations/" + orgId + "/notify-key-rotation-fraud")
.andReturn();
}
@Then("the response confirms the fraud notification was received")
public void responseConfirmsFraud() {
String message = lastResponse.jsonPath().getString("message");
assertThat(message).as("fraud confirmation message").isNotBlank();
}
@When("a BSF admin clears the fraud lock")
public void adminClearsFraudLock() {
lastResponse = given()
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.when()
.delete("/organizations/" + orgId + "/fraud-lock")
.andReturn();
}
@Then("the org is no longer fraud-locked")
public void orgIsNotFraudLocked() {
given()
.when()
.get("/organizations/" + orgId)
.then()
.statusCode(200);
// fraudLocked field should be false — checked by attempting rotation in the next step
}
// ── When: TAN flow ────────────────────────────────────────────────────────
@When("the owner requests a TAN using the registered email")
public void ownerRequestsTanWhenRegistered() {
ownerRequestsTanWhen("test@example.com");
if (lastResponse.statusCode() == 200) {
tan = lastResponse.jsonPath().getString("tan");
}
}
@When("the owner requests a TAN using {string}")
public void ownerRequestsTanWhen(String email) {
lastResponse = given()
.contentType(JSON)
.body(Map.of("email", email))
.when()
.post("/organizations/" + orgId + "/request-tan")
.andReturn();
if (lastResponse.statusCode() == 200) {
tan = lastResponse.jsonPath().getString("tan");
}
}
@When("the owner uses the TAN to rotate keys")
public void ownerUseTanToRotate() {
lastResponse = given()
.contentType(JSON)
.body(Map.of("tan", tan))
.when()
.post("/organizations/" + orgId + "/rotate-key-with-tan")
.andReturn();
if (lastResponse.statusCode() == 200) {
oldApiKey = apiKey;
apiKey = lastResponse.jsonPath().getString("apiKey");
rotationSecret = lastResponse.jsonPath().getString("rotationSecret");
}
}
// ── When: time ────────────────────────────────────────────────────────────
@When("time advances by {int} hours")
public void timeAdvancesHours(int hours) {
ClockService clock = Arc.container().instance(ClockService.class).get();
clock.advance(clock.now().plus(Duration.ofHours(hours)));
}
@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: GET org ─────────────────────────────────────────────────────────
@When("the caller reads the organisation")
public void callerReadsOrg() {
lastResponse = given()
.when()
.get("/organizations/" + orgId)
.andReturn();
}
// ── Then: response assertions ─────────────────────────────────────────────
@Then("the response status is {int}")
public void responseStatusIs(int status) {
assertThat(lastResponse.statusCode())
.as("HTTP status")
.isEqualTo(status);
}
@Then("the response contains an organisation id")
public void responseContainsOrgId() {
assertThat(lastResponse.jsonPath().getString("id")).isNotBlank();
}
@Then("the response contains an api key with prefix {string}")
public void responseContainsApiKey(String prefix) {
assertThat(lastResponse.jsonPath().getString("apiKey"))
.as("apiKey").startsWith(prefix);
}
@Then("the response contains a rotation secret with prefix {string}")
public void responseContainsRotationSecret(String prefix) {
assertThat(lastResponse.jsonPath().getString("rotationSecret"))
.as("rotationSecret").startsWith(prefix);
}
@Then("the earned O-level is {string}")
public void earnedOLevelIs(String level) {
String actual = lastResponse.jsonPath().getString("earnedOLevel");
assertThat(actual).as("earnedOLevel").isEqualTo(level);
}
@Then("the effective O-level is {string}")
public void effectiveOLevelIs(String level) {
String actual = lastResponse.jsonPath().getString("effectiveOLevel");
assertThat(actual).as("effectiveOLevel").isEqualTo(level);
}
@Then("the verification status is {string}")
public void verificationStatusIs(String status) {
String actual = lastResponse.jsonPath().getString("verificationStatus");
assertThat(actual).as("verificationStatus").isEqualTo(status);
}
@Then("the verification step is {string}")
public void verificationStepIs(String step) {
String actual = lastResponse.jsonPath().getString("verificationStep");
assertThat(actual).as("verificationStep").isEqualTo(step);
}
@Then("a dns verification token is returned")
public void dnsTokenIsReturned() {
String token = lastResponse.jsonPath().getString("dnsVerificationToken");
assertThat(token).as("dnsVerificationToken").isNotBlank();
dnsToken = token; // save for subsequent WireMock stub setup
}
@Then("no dns verification token is returned")
public void noDnsTokenIsReturned() {
String token = lastResponse.jsonPath().getString("dnsVerificationToken");
assertThat(token).as("dnsVerificationToken should be null").isNull();
}
@Then("the detected LEI is {string}")
public void detectedLeiIs(String lei) {
assertThat(lastResponse.jsonPath().getString("lei"))
.as("lei").isEqualTo(lei);
}
@Then("a new api key with prefix {string} is returned")
public void newApiKeyWithPrefix(String prefix) {
assertThat(lastResponse.jsonPath().getString("apiKey"))
.as("new apiKey").startsWith(prefix);
}
@Then("a new rotation secret with prefix {string} is returned")
public void newRotationSecretWithPrefix(String prefix) {
assertThat(lastResponse.jsonPath().getString("rotationSecret"))
.as("new rotationSecret").startsWith(prefix);
}
@Then("the old api key is no longer valid")
public void oldApiKeyIsInvalid() {
assertThat(oldApiKey).as("oldApiKey must have been recorded").isNotNull();
given()
.contentType(JSON)
.header(ORG_API_KEY_HEADER, oldApiKey)
.when()
.post("/organizations/" + orgId + "/verify")
.then()
.statusCode(403);
}
@Then("the response message confirms TAN was sent")
public void responseMessageConfirmsTan() {
String msg = lastResponse.jsonPath().getString("message");
assertThat(msg).as("TAN confirmation message").isNotBlank();
}
// ── When / Then: audit log ────────────────────────────────────────────────
@When("the owner requests the audit log")
public void ownerRequestsAuditLog() {
lastResponse = given()
.header(ORG_API_KEY_HEADER, apiKey)
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@When("the owner requests the audit log with the new api key")
public void ownerRequestsAuditLogWithNewKey() {
lastResponse = given()
.header(ORG_API_KEY_HEADER, apiKey)
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@When("a BSF admin requests the audit log")
public void adminRequestsAuditLog() {
lastResponse = given()
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@When("an unauthenticated caller requests the audit log")
public void unauthenticatedRequestsAuditLog() {
lastResponse = given()
.when()
.get("/organizations/" + orgId + "/audit-log")
.andReturn();
}
@Then("the audit log contains at least {int} event(s)")
public void auditLogContainsAtLeast(int minCount) {
List<?> events = lastResponse.jsonPath().getList("$");
assertThat(events).as("audit log event count").hasSizeGreaterThanOrEqualTo(minCount);
}
@Then("the audit log contains a {string} event triggered by {string}")
public void auditLogContainsEventByTrigger(String eventType, String triggeredBy) {
List<Map<String, Object>> events = lastResponse.jsonPath().getList("$");
boolean found = events.stream().anyMatch(e ->
eventType.equals(e.get("eventType")) && triggeredBy.equals(e.get("triggeredBy")));
assertThat(found)
.as("audit log must contain a %s event triggered by %s", eventType, triggeredBy)
.isTrue();
}
@Then("the audit log contains a {string} event")
public void auditLogContainsEvent(String eventType) {
List<Map<String, Object>> events = lastResponse.jsonPath().getList("$");
boolean found = events.stream().anyMatch(e -> eventType.equals(e.get("eventType")));
assertThat(found)
.as("audit log must contain a %s event", eventType)
.isTrue();
}
@Then("the first audit event is {string}")
public void firstAuditEventIs(String eventType) {
List<Map<String, Object>> events = lastResponse.jsonPath().getList("$");
assertThat(events).as("audit log must not be empty").isNotEmpty();
assertThat(events.get(0).get("eventType"))
.as("first event type").isEqualTo(eventType);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void registerOrg(String name, String email, String jurisdiction,
String orgType, String domain, String targetLevel) {
Response r = given()
.contentType(JSON)
.body(buildRegistrationBody(name, email, jurisdiction, orgType, domain, targetLevel))
.when()
.post("/organizations")
.andReturn();
assertThat(r.statusCode()).as("registration must succeed").isEqualTo(201);
saveRegistrationState(r);
}
private void saveRegistrationState(Response r) {
orgId = UUID.fromString(r.jsonPath().getString("id"));
apiKey = r.jsonPath().getString("apiKey");
rotationSecret = r.jsonPath().getString("rotationSecret");
dnsToken = r.jsonPath().getString("dnsVerificationToken"); // may be null for UNVERIFIED
}
private void updateStateFromOrgResponse(Response r) {
String token = r.jsonPath().getString("dnsVerificationToken");
if (token != null) dnsToken = token;
}
private Map<String, Object> buildRegistrationBody(String name, String email,
String jurisdiction, String orgType,
String domain, String targetLevel) {
Map<String, Object> body = new HashMap<>();
if (name != null) body.put("registrantName", name);
if (email != null) body.put("registrantEmail", email);
if (jurisdiction != null) body.put("registrantJurisdiction", jurisdiction);
if (orgType != null) body.put("registrantOrgType", orgType);
if (domain != null) body.put("domain", domain);
if (targetLevel != null) body.put("targetOLevel", targetLevel);
return body;
}
private Instant clockNow() {
return Arc.container().instance(ClockService.class).get().now();
}
private void doAdminGrantTemp(String level, Instant expiresAt) {
lastResponse = given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
.body(Map.of(
"tempMaxOLevel", level,
"expiresAt", expiresAt.toString(),
"grantedBy", "test-admin",
"reason", "BDD test grant"))
.when()
.post("/organizations/" + orgId + "/temp-grant")
.andReturn();
}
// ── WireMock stub helpers ─────────────────────────────────────────────────
private void stubDohTxt(String name, String data) {
String escapedData = data.replace("\"", "\\\"");
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/dns-query"))
.withQueryParam("name", equalTo(name))
.withQueryParam("type", equalTo("TXT"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"Status\":0,\"Answer\":[{\"data\":\"\\\"" + escapedData + "\\\"\"}]}")));
}
private void stubDohTxtEmpty(String name) {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/dns-query"))
.withQueryParam("name", equalTo(name))
.withQueryParam("type", equalTo("TXT"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"Status\":0,\"Answer\":[]}")));
}
private void stubDohMx(String domain, boolean hasRecords) {
String body = hasRecords
? "{\"Status\":0,\"Answer\":[{\"data\":\"10 mail." + domain + "\"}]}"
: "{\"Status\":0,\"Answer\":[]}";
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/dns-query"))
.withQueryParam("name", equalTo(domain))
.withQueryParam("type", equalTo("MX"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(body)));
}
private void stubGleifFound(String lei) {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/gleif/api/v1/lei-records"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"data\":[{\"id\":\"" + lei + "\"}]}")));
}
private void stubGleifEmpty() {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/gleif/api/v1/lei-records"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"data\":[]}")));
}
private void stubOcFound() {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/opencorporates/v0.4/companies/search"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"results\":{\"companies\":[{\"company\":{\"name\":\"Test\"}}]}}")));
}
private void stubOcEmpty() {
WireMockSetup.wireMock.stubFor(WireMock.get(urlPathEqualTo("/opencorporates/v0.4/companies/search"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"results\":{\"companies\":[]}}")));
}
private void stubSecurityTxt(String domain, int status) {
WireMockSetup.wireMock.stubFor(WireMock.get(urlEqualTo("/security-txt/" + domain))
.willReturn(aResponse().withStatus(status)));
}
}
@@ -0,0 +1,32 @@
package org.botstandards.apix.registry.bdd;
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.quarkus.arc.Arc;
import io.restassured.RestAssured;
import org.botstandards.apix.registry.service.ClockService;
import java.sql.DriverManager;
public class TestSetup {
@Before(order = 0)
public void configureRestAssured() {
RestAssured.port = 8181;
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}
@Before(order = 1)
public void truncateTables() throws Exception {
try (var conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/apix", "apix", "apix");
var stmt = conn.createStatement()) {
stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations CASCADE");
}
}
@After
public void resetClock() {
Arc.container().instance(ClockService.class).get().reset();
}
}
@@ -0,0 +1,3 @@
// Superseded: clock control now goes through Arc.container() directly in TestSetup/@After
// and IotTransitionSteps, keeping the clock as a pure in-process concern.
// File kept to avoid breaking any IDE import caches; no JAX-RS registration.
@@ -0,0 +1,34 @@
package org.botstandards.apix.registry.bdd;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import io.cucumber.java.AfterAll;
import io.cucumber.java.BeforeAll;
/**
* Manages the WireMock server lifecycle across the entire BDD test run.
* All verifier HTTP clients (DoH, GLEIF, OC, security.txt) are pointed at
* localhost:9999 via test application.properties.
*
* Stubs are NOT configured here — each step definition registers its own stubs
* in Given steps and clears them in After hooks, keeping scenarios independent.
*/
public class WireMockSetup {
static WireMockServer wireMock;
@BeforeAll
public static void startWireMock() {
wireMock = new WireMockServer(WireMockConfiguration.options().port(9999));
wireMock.start();
}
@AfterAll
public static void stopWireMock() {
if (wireMock != null) wireMock.stop();
}
public static WireMockServer server() {
return wireMock;
}
}
@@ -0,0 +1,4 @@
# Write results one level up so the parent aggregates all modules in one report.
# Resolved relative to the module's working directory (Maven Surefire sets user.dir
# to the module basedir), so ../target/allure-results = apix-mvp/target/allure-results.
allure.results.directory=../target/allure-results
@@ -0,0 +1,24 @@
# ── Test datasource ───────────────────────────────────────────────────────────
# Points directly at the docker-compose db service (postgres:16-alpine on :5432).
# Start it with: docker-compose -f infra/docker-compose.yml up -d db
# Bypasses Testcontainers/DevServices so Docker socket issues don't block tests.
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/apix
quarkus.datasource.username=apix
quarkus.datasource.password=apix
# ── Test HTTP port ────────────────────────────────────────────────────────────
# Fixed port so RestAssured.port in TestSetup matches without reading system properties.
quarkus.http.test-port=8181
# ── Test API key ──────────────────────────────────────────────────────────────
apix.api-key=test-api-key
# ── Verification (WireMock on port 9999) ─────────────────────────────────────
apix.dns.doh-url=http://localhost:9999/dns-query
apix.gleif.api-url=http://localhost:9999/gleif/api/v1
apix.opencorporates.api-url=http://localhost:9999/opencorporates/v0.4
apix.hygiene.security-txt-url-template=http://localhost:9999/security-txt/{domain}
apix.verification.http-timeout-ms=3000
# ── TAN exposure for BDD automation ──────────────────────────────────────────
apix.org.tan.expose-in-response=true
@@ -0,0 +1,93 @@
Feature: IoT device connection profile
As a service provider targeting IoT device portability
I want to declare my device connection profile in the APIX registry
So that migrating IoT devices can discover how to connect to my service
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
And a registered service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
Scenario: Provider adds an IoT profile to their service
When "OpenHub" sets its IoT profile with hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile.hubUrl is "wss://hub.openhub.io/v2"
And the iotProfile.protocols contains "MQTT_5_0"
Scenario: IoT profile is included in replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called
Then the response is HTTP 200
And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2"
Scenario: iotReady=true excludes candidates without a profile
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?iotReady=true is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "CloudBridge"
Scenario: deviceClass filter narrows replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.industrial-sensor"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?deviceClass=device.class.smart-home-hub is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "OpenHub"
Scenario: IoT profile fields are partially updated on subsequent PATCH
Given "OpenHub" has IoT profile hubUrl "wss://old.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
When "OpenHub" updates its IoT profile hubUrl to "wss://new.openhub.io"
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile.hubUrl is "wss://new.openhub.io"
And the iotProfile.protocols contains "MQTT_5_0"
Scenario: protocol filter narrows replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "WEBSOCKET" deviceClasses "device.class.smart-home-hub"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?protocol=MQTT_5_0 is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "OpenHub"
Scenario: Full IoT profile round-trip persists all declared fields
When "OpenHub" sets its full IoT profile
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile field "hubUrl" is "wss://hub.openhub.io/v2"
And the iotProfile field "tlsMinVersion" is "1.3"
And the iotProfile field "provisioningEndpoint" is "https://onboard.openhub.io/v1/provision"
And the iotProfile field "credentialFormat" is "JWT"
And the iotProfile field "deviceIdField" is "serialNumber"
And the iotProfile field "minFirmwareVersion" is "2.0.0"
And the iotProfile field "firmwareUpdateUrl" is "https://firmware.openhub.io/update"
And the iotProfile.estimatedMigrationMinutes is 5
Scenario: Device reads IoT connection profile anonymously without leaving a trace
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2"
And the response headers contain no Set-Cookie
And the response headers contain a Server-Timing header
Scenario: Server-Timing header is present on replacement search responses
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response headers contain a Server-Timing header
Scenario: Missing required fields on first IoT profile PATCH returns 422
When "OpenHub" patches iotProfile with only hubUrl "wss://hub.openhub.io"
Then the response is HTTP 422
And the error message contains "iotProfile.protocols is required"
@@ -0,0 +1,35 @@
Feature: Device owner anonymity guarantee
As an IoT device owner
I must be able to use the full transition discovery process
Without APIX storing or inferring any artefact that identifies me or my device
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And at least one replacement candidate registered
Scenario: Status polling requires no authentication
When GET /services/{smartHubCloudId} is called with no Authorization header
Then the response is HTTP 200
And locked and sunset_date are present in the response body
Scenario: Replacement discovery requires no authentication
When GET /services/{smartHubCloudId}/replacements is called with no Authorization header
Then the response is HTTP 200
And the full replacement list is returned
Scenario: No session state is created during polling
When GET /services/{smartHubCloudId}/replacements is called twice with no shared headers
Then both responses are identical in content
And neither response contains a Set-Cookie header
And neither response contains a session reference
Scenario: Response contains no client-tracking artefacts
When GET /services/{smartHubCloudId}/replacements is called
Then the response headers contain no Set-Cookie
And the response body contains no field that echoes client request details
And the response body contains no correlation ID tied to the caller
Scenario: Polling endpoint is covered by the public cache
When GET /services/{smartHubCloudId}/replacements is called twice within the cache TTL
Then the second response is served from cache
And the cache key does not incorporate any client-identifying header
@@ -0,0 +1,50 @@
Feature: Service decommissioning and historical record preservation
As a template owner
I want to decommission a service after its sunset date
And as a device owner I must be able to access the historical record indefinitely
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service has locked set to true
And the service is in stage "DEPRECATED" with a sunset_date set
And the service has locked set to false
And a registered service "OpenHub" with endpoint "https://api.openhub.example"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And the sunset_date of "SmartHub Cloud" has passed
Scenario: Template owner decommissions the service
When the template owner sets service_stage to "DECOMMISSIONED"
Then the service does not appear in any capability search results
And GET /services/{id} returns HTTP 200 with the complete historical record
And the response body contains service_stage "DECOMMISSIONED"
And a version history entry of type "STAGE_CHANGED" exists for the service
Scenario: Decommissioned service with unreleased lock auto-releases for replacement discovery
Given a deprecated service "NeverReleased" that reached DECOMMISSIONED without setting locked=false
When GET /services/{neverReleasedId}/replacements is called with no authentication header
Then the response is HTTP 200
And replacement candidates are returned regardless of the stored locked value
Scenario: Historical record survives indefinitely
Given a decommissioned service
When GET /services/{id} is called
Then the response is HTTP 200
And the full BSM payload is present in the response
And all version history entries are accessible via GET /services/{id}/history
Scenario: Cannot decommission a service before its sunset date
Given a deprecated service "FutureCloud" with a sunset_date 30 days from now
When the template owner attempts to set service_stage to "DECOMMISSIONED"
Then the response is HTTP 422
And the error message contains "sunset_at has not passed"
Scenario: Full transition timeline is visible in version history
Given "SmartHub Cloud" has completed the full lifecycle
When GET /services/{id}/history is called
Then the history contains an entry of type "REGISTERED"
And the history contains an entry of type "SUNSET_DECLARED"
And the history contains an entry of type "LOCK_RELEASED"
And the history contains an entry of type "REPLACEMENT_DECLARED"
And the history contains an entry of type "STAGE_CHANGED" with new value "DECOMMISSIONED"
And all entries are ordered chronologically ascending
@@ -0,0 +1,46 @@
Feature: Replacement provider declares compatibility
As a replacement service provider
I want to declare that my service covers a deprecated template
So that IoT device owners can discover me as a migration target
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
Scenario: Replacement provider declares compatibility with a deprecated service
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements includes "OpenHub"
And a version history entry of type "REPLACEMENT_DECLARED" exists for "SmartHub Cloud"
And the service_replacements table contains the declared pair
Scenario: Declaration against a non-deprecated service is rejected
Given a service "ActiveCloud" in stage "PRODUCTION" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "ActiveCloud"
Then the response is HTTP 422
And the error message contains "target service is not deprecated"
Scenario: Declaration against a locked deprecated service is rejected
Given a service "LockedCloud" in stage "DEPRECATED" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "LockedCloud"
Then the response is HTTP 422
And the error message contains "target service lock has not been released"
Scenario: Multiple replacement providers for the same deprecated service
Given a service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
And "CloudBridge" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements returns 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
And the ordering is by O-level descending
Scenario: Replacement provider retracts their compatibility declaration
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" removes "SmartHub Cloud" from its replacesServiceIds
Then GET /services/{smartHubCloudId}/replacements no longer includes "OpenHub"
And the service_replacements row for this pair is deleted
Scenario: Duplicate declaration is idempotent
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud" again
Then the response is HTTP 200
And GET /services/{smartHubCloudId}/replacements still returns exactly 1 candidate
@@ -0,0 +1,45 @@
Feature: Device owner discovers replacement services
As an IoT device owner
I want to discover compatible replacement services by polling APIX
Without revealing my identity or device details
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false and a sunset_date set
And "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED" has declared compatibility
And "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED" has declared compatibility
Scenario: Device polls service status without authentication
When GET /services/{smartHubCloudId} is called with no authentication header
Then the response is HTTP 200
And the response body contains service_stage "DEPRECATED"
And the response body contains locked false
And the response body contains a sunset_date
Scenario: Device discovers replacement candidates without authentication
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response contains 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
Scenario: Device filters candidates by minimum O-level
When GET /services/{smartHubCloudId}/replacements?minOLevel=LEGAL_ENTITY_VERIFIED is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "CloudBridge"
Scenario: Device polls a still-locked deprecated service
Given a deprecated service "LockedCloud" with locked set to true
When GET /services/{lockedCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response body contains an empty candidates list
And the response body contains locked true
Scenario: Default production search excludes deprecated services
When GET /services?capability=device.telemetry is called with no authentication header
Then "SmartHub Cloud" is not in the results
And only services with stage "PRODUCTION" are returned
Scenario: Explicit deprecated filter includes deprecated services
When GET /services?capability=device.telemetry&stage=deprecated is called
Then "SmartHub Cloud" is in the results
And each result has service_stage "DEPRECATED"
@@ -0,0 +1,40 @@
Feature: Sunset declaration and lock release
As a template owner
I want to declare a sunset date and release the device lock
So that IoT device owners can prepare for migration in a predictable window
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service is in stage "PRODUCTION"
And the service has locked set to true
Scenario: Template owner declares sunset date
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service stage is "DEPRECATED"
And the service has a sunset_date set
And a version history entry of type "SUNSET_DECLARED" exists for the service
And the service does not appear in default production search results for capability "device.telemetry"
And the service appears in search results when stage filter is "deprecated"
Scenario: Sunset declaration preserves the device lock
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service has locked set to true
And GET /services/{id}/replacements returns HTTP 200 with an empty list
Scenario: Template owner releases the device lock after sunset is declared
Given the service is in stage "DEPRECATED" with a sunset_date set
When the template owner sets locked to false
Then the service has locked set to false
And a version history entry of type "LOCK_RELEASED" exists for the service
And the version history entry contains the previous locked value true
Scenario: Lock cannot be released without a prior sunset declaration
When the template owner attempts to set locked to false without a sunset_date
Then the response is HTTP 422
And the error message contains "sunset_at required before lock release"
Scenario: Sunset date cannot be set in the past
When the template owner attempts to set sunset_date to yesterday
Then the response is HTTP 422
And the error message contains "sunset_at must be a future moment"
@@ -0,0 +1,87 @@
Feature: Organisation audit log
# Immutable, append-only trail of all events affecting an organisation.
# Two authorised views: org owner (transparency) and BSF admin (yearly reporting).
# Unauthenticated callers are rejected.
Background:
Given the organisation registry is empty
# ── Access control ────────────────────────────────────────────────────────────
Scenario: Org owner can read their own audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests the audit log
Then the response status is 200
And the audit log contains at least 1 event
Scenario: BSF admin can read any org's audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When a BSF admin requests the audit log
Then the response status is 200
And the audit log contains at least 1 event
Scenario: Unauthenticated caller cannot read the audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When an unauthenticated caller requests the audit log
Then the response status is 403
# ── Registration events ────────────────────────────────────────────────────────
Scenario: Audit log records registration
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests the audit log
Then the audit log contains a "REGISTERED" event triggered by "SYSTEM"
# ── Key rotation events — TAN path ────────────────────────────────────────────
Scenario: TAN issuance is recorded in audit log
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 the owner requests the audit log
Then the audit log contains a "TAN_ISSUED" event triggered by "SYSTEM"
Scenario: TAN-based key rotation completion is recorded in audit log
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 the owner confirms key rotation with the TAN
And the owner requests the audit log with the new api key
Then the audit log contains a "KEY_ROTATED" event triggered by "OWNER"
# ── Key rotation events — DNS path ────────────────────────────────────────────
Scenario: DNS challenge initiation is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
When the owner requests the audit log
Then the audit log contains a "DNS_ROTATION_INITIATED" event triggered by "SYSTEM"
Scenario: DNS-challenge key rotation completion is recorded in audit log
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 the agent confirms DNS-challenge key rotation
And the owner requests the audit log with the new api key
Then the audit log contains a "KEY_ROTATED" event triggered by "OWNER"
# ── Fraud lock events ─────────────────────────────────────────────────────────
Scenario: Fraud report is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the owner requests the audit log
Then the audit log contains a "FRAUD_REPORTED" event triggered by "OWNER"
Scenario: Fraud lock clearance is recorded in audit log
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When a BSF admin clears the fraud lock
And a BSF admin requests the audit log
Then the audit log contains a "FRAUD_LOCK_CLEARED" event triggered by "BSF_ADMIN"
# ── Event ordering ─────────────────────────────────────────────────────────────
Scenario: Audit log returns events newest first
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 the owner requests the audit log
Then the first audit event is "TAN_ISSUED"
@@ -0,0 +1,153 @@
Feature: BSF admin actions — temp grants, revocation, TAN-based key rotation
Background:
Given the organisation registry is empty
# ── Temporary O-level grants ──────────────────────────────────────────────────
Scenario: BSF admin grants a temporary elevated O-level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in 30 days
Then the response status is 200
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
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
And the caller reads the organisation
Then the effective O-level is "IDENTITY_VERIFIED"
Scenario: BSF admin revokes an active temporary grant
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 30 days
When a BSF admin revokes the temporary grant
Then the response status is 200
And the effective O-level is "IDENTITY_VERIFIED"
Scenario: Revoking a non-existent temporary grant returns 422
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin revokes the temporary grant
Then the response status is 422
Scenario: Temp grant with expiry in the past is rejected
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When a BSF admin grants a temporary level "OPERATIONALLY_VERIFIED" expiring in the past
Then the response status is 422
Scenario: Temp grant request with wrong admin key is rejected
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When an unauthorised caller grants a temporary level "OPERATIONALLY_VERIFIED"
Then the response status is 403
# ── Admin: manual O-level assignment ─────────────────────────────────────────
Scenario: BSF admin assigns earned level manually (O-4 post review)
Given an organisation is registered with target level "OPERATIONALLY_VERIFIED" for domain "example.com"
When a BSF admin assigns earned level "OPERATIONALLY_VERIFIED"
Then the response status is 200
And the earned O-level is "OPERATIONALLY_VERIFIED"
And the verification status is "ACHIEVED"
# ── Self-service key rotation (2FA: rotation secret + email TAN) ────────────────
Scenario: Owner rotates keys — rotation secret triggers TAN, TAN completes rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner initiates key rotation using the rotation secret
Then the response status is 200
And the response contains a key rotation warning with the org name
And a key rotation TAN has been sent
When 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
And the old api key is no longer valid
Scenario: Key rotation initiation with wrong rotation secret is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
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
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
And the owner confirms key rotation with the TAN
Then the response status is 422
# ── Fraud report ─────────────────────────────────────────────────────────────
Scenario: Legitimate owner reports a key rotation they did not initiate
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner reports key rotation fraud using the registered email
Then the response status is 200
And the response confirms the fraud notification was received
Scenario: Fraud report with unrecognised email returns generic confirmation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When a caller reports key rotation fraud using "unknown@other.com"
Then the response status is 200
And the response confirms the fraud notification was received
Scenario: Fraud report cancels the pending TAN and locks the org against further rotation
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 the owner reports key rotation fraud using the registered email
Then the response status is 200
And the response confirms the fraud notification was received
When the owner confirms key rotation with the TAN
Then the response status is 422
Scenario: After fraud lock, rotation secret cannot initiate a new rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the owner initiates key rotation using the rotation secret
Then the response status is 422
Scenario: BSF admin clears fraud lock after investigation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When a BSF admin clears the fraud lock
Then the response status is 200
And the org is no longer fraud-locked
When the owner initiates key rotation using the rotation secret
Then the response status is 200
# ── Emergency TAN-based key rotation ─────────────────────────────────────────
Scenario: Owner requests a TAN and uses it to rotate keys
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests a TAN using the registered email
Then the response status is 200
And the response message confirms TAN was sent
When 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: TAN request with unrecognised email returns generic message
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests a TAN using "unknown@other.com"
Then the response status is 200
And the response message confirms TAN was sent
Scenario: TAN is rejected after its 5-minute validity window
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
And the owner uses the TAN to rotate keys
Then the response status is 422
Scenario: TAN request rate limit — more than 3 requests within 24 hours is rejected
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 the owner requests a TAN using the registered email
Then the response status is 422
Scenario: TAN request counter resets after 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
And the owner requests a TAN using the registered email
Then the response status is 200
@@ -0,0 +1,93 @@
Feature: DNS-challenge key rotation — bot-friendly ACME DNS-01 pattern
# Primary automated rotation path for agents with DNS API access.
# Two factors: rotation secret (proves legitimate requester) + DNS TXT control
# (proves domain ownership). No email parsing, no human inbox dependency.
#
# DNS record to publish: _apix-rotation.{domain} TXT "apix-rotate={challengeToken}"
# Window: 15 minutes (accommodates DNS API propagation + polling cycle)
# The challenge token is intentionally public it has no value without the rotation secret.
Background:
Given the organisation registry is empty
# ── Happy path ──────────────────────────────────────────────────────────────
Scenario: Agent initiates DNS-challenge rotation and confirms after publishing DNS record
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent initiates DNS-challenge key rotation
Then the response status is 200
And the response contains the dns record name "_apix-rotation.example.com"
And the response contains the challenge token and dns value format
And the response contains an expiry timestamp
Given the agent has published the rotation challenge to dns
When 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
And the old api key is no longer valid
# ── Failure paths ────────────────────────────────────────────────────────────
Scenario: Confirm fails when DNS record has not been published
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the DNS rotation challenge record is absent
When the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Confirm fails when challenge token does not match DNS record
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the agent has initiated DNS-challenge key rotation
And the DNS record "_apix-rotation.example.com" contains the wrong challenge token
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
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
And the agent confirms DNS-challenge key rotation
Then the response status is 422
Scenario: Initiate fails with wrong rotation secret
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent initiates DNS-challenge key rotation with an invalid rotation secret
Then the response status is 403
Scenario: Confirm fails with wrong rotation secret
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 the agent confirms DNS-challenge key rotation with an invalid rotation secret
Then the response status is 403
Scenario: Confirm without a prior initiate is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the agent confirms DNS-challenge key rotation
Then the response status is 422
# ── Fraud lock interaction ────────────────────────────────────────────────────
Scenario: Fraud-locked org cannot initiate DNS-challenge rotation
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the owner reports key rotation fraud using the registered email
When the agent initiates DNS-challenge key rotation
Then the response status is 422
Scenario: Fraud-locked org cannot confirm DNS-challenge rotation
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
And the owner reports key rotation fraud using the registered email
When the agent confirms DNS-challenge key rotation
Then the response status is 422
# ── Isolation between paths ───────────────────────────────────────────────────
Scenario: DNS challenge and email TAN are independent — each has its own pending state
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 the agent initiates DNS-challenge key rotation
Then the response status is 200
And the response contains the dns record name "_apix-rotation.example.com"
@@ -0,0 +1,50 @@
Feature: Organisation O-level transitions — owner-initiated upgrades and re-verification
Background:
Given the organisation registry is empty
Scenario: Owner upgrades target level from O-0 to O-1 and triggers verification successfully
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
And the DoH MX record "example.com" returns at least one entry
When the owner requests an upgrade to target level "IDENTITY_VERIFIED"
Then the response status is 200
And the verification status is "PENDING"
And a dns verification token is returned
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Owner cannot downgrade target level below earned level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When the owner requests an upgrade to target level "UNVERIFIED"
Then the response status is 422
Scenario: Owner cannot set target level equal to earned level
Given an organisation has earned O-level "IDENTITY_VERIFIED" with target "IDENTITY_VERIFIED"
When the owner requests an upgrade to target level "IDENTITY_VERIFIED"
Then the response status is 422
Scenario: Re-verification after previous failure resets the failure state
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the verification status is "FAILED"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Upgrade to O-4 sets status to MANUAL_REVIEW
Given an organisation has earned O-level "HYGIENE_VERIFIED" with target "HYGIENE_VERIFIED"
When the owner requests an upgrade to target level "OPERATIONALLY_VERIFIED"
Then the response status is 200
And the verification status is "MANUAL_REVIEW"
Scenario: Requesting upgrade with wrong API key is rejected
Given an organisation is registered with target level "UNVERIFIED" for domain "example.com"
When the owner requests an upgrade to target level "IDENTITY_VERIFIED" with an invalid api key
Then the response status is 403
@@ -0,0 +1,63 @@
Feature: Organisation registration
Background:
Given the organisation registry is empty
Scenario: Register with target O-0 (unverified) is immediately achieved
When a service registers with target level "UNVERIFIED"
Then the response status is 201
And the response contains an organisation id
And the response contains an api key with prefix "apix_org_"
And the response contains a rotation secret with prefix "apix_rot_"
And the earned O-level is "UNVERIFIED"
And the verification status is "ACHIEVED"
And no dns verification token is returned
Scenario: Register with target O-1 (identity verified) returns pending with DNS token
When a service registers with target level "IDENTITY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-2 (legal entity verified) returns pending with DNS token
When a service registers with target level "LEGAL_ENTITY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-3 (hygiene verified) returns pending with DNS token
When a service registers with target level "HYGIENE_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "PENDING"
And a dns verification token is returned
Scenario: Register with target O-4 (operationally verified) goes to manual review
When a service registers with target level "OPERATIONALLY_VERIFIED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "MANUAL_REVIEW"
Scenario: Register with target O-5 (audited) goes to manual review
When a service registers with target level "AUDITED"
Then the response status is 201
And the earned O-level is "UNVERIFIED"
And the verification status is "MANUAL_REVIEW"
Scenario: Registration fails when registrant name is missing
When a service registers without a registrant name
Then the response status is 400
Scenario: Registration fails when email is invalid
When a service registers with an invalid email
Then the response status is 400
Scenario: Registration fails when domain is missing
When a service registers without a domain
Then the response status is 400
Scenario: Registration fails when target O-level is missing
When a service registers without a target O-level
Then the response status is 400
@@ -0,0 +1,57 @@
Feature: Organisation verification — O-1 Identity Verified (DNS)
Background:
Given the organisation registry is empty
Scenario: Verification succeeds when DNS TXT and MX records are present
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Verification fails when DNS TXT record is absent
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
Scenario: Verification fails when DNS TXT token does not match
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns "wrong-token"
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
Scenario: Verification fails when MX record is absent
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns no records
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_MX"
Scenario: Verify is idempotent when status is already ACHIEVED
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
And the owner triggers verification again
Then the response status is 200
And the verification status is "ACHIEVED"
Scenario: Verification with wrong API key is rejected
Given an organisation is registered with target level "IDENTITY_VERIFIED" for domain "example.com"
When the owner triggers verification with an invalid api key
Then the response status is 403
@@ -0,0 +1,48 @@
Feature: Organisation verification — O-2 Legal Entity Verified (GLEIF / OpenCorporates)
Background:
Given the organisation registry is empty
Scenario: Verification succeeds via GLEIF when LEI is found for registrant name
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "ACHIEVED"
And the detected LEI is "529900HNOAA1KXQJUQ27"
Scenario: Verification falls back to OpenCorporates when GLEIF returns no results
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Local Gmbh"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns no results for name "Local Gmbh"
And the OpenCorporates API returns a company match for name "Local Gmbh"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Verification fails when neither GLEIF nor OpenCorporates finds the company
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Ghost Inc"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns no results for name "Ghost Inc"
And the OpenCorporates API returns no results for name "Ghost Inc"
When the owner triggers verification
Then the response status is 200
And the earned O-level is "IDENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "OPENCORPORATES"
Scenario: Verification fails at O-1 DNS step and does not reach GLEIF
Given an organisation is registered with target level "LEGAL_ENTITY_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns no records
And the DoH MX record "example.com" returns at least one entry
When the owner triggers verification
Then the response status is 200
And the earned O-level is "UNVERIFIED"
And the verification status is "FAILED"
And the verification step is "DNS_TXT"
@@ -0,0 +1,59 @@
Feature: Organisation verification — O-3 Hygiene Verified (security.txt, DMARC, SPF)
Background:
Given the organisation registry is empty
Scenario: Hygiene verification succeeds when all three hygiene checks pass
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "HYGIENE_VERIFIED"
And the verification status is "ACHIEVED"
Scenario: Hygiene verification fails when security.txt is missing
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 404
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "SECURITY_TXT"
Scenario: Hygiene verification fails when DMARC record is absent
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns no records
And the DoH TXT record "example.com" returns a valid SPF record
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "DMARC"
Scenario: Hygiene verification fails when SPF record is absent
Given an organisation is registered with target level "HYGIENE_VERIFIED" for domain "example.com" with name "Acme Corp"
And the DoH TXT record "_apix-verification.example.com" returns the org's dns token
And the DoH MX record "example.com" returns at least one entry
And the GLEIF API returns an LEI "529900HNOAA1KXQJUQ27" for name "Acme Corp"
And the security.txt endpoint for "example.com" returns HTTP 200
And the DoH TXT record "_dmarc.example.com" returns a valid DMARC policy
And the DoH TXT record "example.com" returns no records
When the owner triggers verification
Then the response status is 200
And the earned O-level is "LEGAL_ENTITY_VERIFIED"
And the verification status is "FAILED"
And the verification step is "SPF"
@@ -0,0 +1,8 @@
# Prevent the Cucumber engine from discovering feature files on its own.
# Features are provided only by IotTransitionCucumberIT via @SelectClasspathResource,
# which ensures Cucumber runs within the @QuarkusTest context (Quarkus server started).
# Prevent standalone Cucumber execution. No feature file carries this tag, so the
# Cucumber engine discovers scenarios but filters them to zero when running on its own.
# Cucumber is run explicitly by IotTransitionCucumberIT via Main.run(), which does
# not apply this JUnit Platform filter.
cucumber.filter.tags=@_disabled_standalone_run_