ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s
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:
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+30
@@ -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");
|
||||
}
|
||||
}
|
||||
+924
@@ -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));
|
||||
}
|
||||
}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+918
@@ -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)));
|
||||
}
|
||||
}
|
||||
+32
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
@@ -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.
|
||||
+34
@@ -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"
|
||||
+35
@@ -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
|
||||
+50
@@ -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
|
||||
+46
@@ -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
|
||||
+45
@@ -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"
|
||||
+40
@@ -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"
|
||||
+87
@@ -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"
|
||||
+153
@@ -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
|
||||
+93
@@ -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"
|
||||
+50
@@ -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
|
||||
+63
@@ -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
|
||||
+57
@@ -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
|
||||
+48
@@ -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"
|
||||
+59
@@ -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_
|
||||
Reference in New Issue
Block a user