diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..f2c493d
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,17 @@
+name: Deploy to Production
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: [self-hosted, host]
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 1
+
+ - name: Deploy (rolling zero-downtime)
+ run: bash scripts/deploy-bluegreen.sh
diff --git a/apix-common/src/main/java/org/botstandards/apix/common/OrgEventType.java b/apix-common/src/main/java/org/botstandards/apix/common/OrgEventType.java
new file mode 100644
index 0000000..60b5650
--- /dev/null
+++ b/apix-common/src/main/java/org/botstandards/apix/common/OrgEventType.java
@@ -0,0 +1,17 @@
+package org.botstandards.apix.common;
+
+public enum OrgEventType {
+ REGISTERED,
+ LEVEL_EARNED,
+ UPGRADE_REQUESTED,
+ VERIFICATION_FAILED,
+ TEMP_GRANTED,
+ TEMP_REVOKED,
+ TEMP_EXPIRED,
+ LEVEL_REVOKED,
+ KEY_ROTATED,
+ TAN_ISSUED,
+ DNS_ROTATION_INITIATED,
+ FRAUD_REPORTED,
+ FRAUD_LOCK_CLEARED
+}
diff --git a/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java b/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java
index ea5c858..c6f8c60 100644
--- a/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java
+++ b/apix-common/src/main/java/org/botstandards/apix/common/VerificationResult.java
@@ -3,5 +3,22 @@ package org.botstandards.apix.common;
public record VerificationResult(
OLevel oLevelAchieved,
String blockedAtStep,
- String message
-) {}
+ String message,
+ String detectedLei
+) {
+ public static VerificationResult success(OLevel level) {
+ return new VerificationResult(level, null, null, null);
+ }
+
+ public static VerificationResult success(OLevel level, String lei) {
+ return new VerificationResult(level, null, null, lei);
+ }
+
+ public static VerificationResult failure(OLevel partialLevel, String step, String message) {
+ return new VerificationResult(partialLevel, step, message, null);
+ }
+
+ public boolean succeeded() {
+ return blockedAtStep == null;
+ }
+}
diff --git a/apix-common/src/main/java/org/botstandards/apix/common/VerificationStatus.java b/apix-common/src/main/java/org/botstandards/apix/common/VerificationStatus.java
new file mode 100644
index 0000000..bedeb1d
--- /dev/null
+++ b/apix-common/src/main/java/org/botstandards/apix/common/VerificationStatus.java
@@ -0,0 +1,10 @@
+package org.botstandards.apix.common;
+
+public enum VerificationStatus {
+ PENDING,
+ VERIFYING,
+ ACHIEVED,
+ FAILED,
+ MANUAL_REVIEW,
+ SUSPENDED
+}
diff --git a/apix-registry/pom.xml b/apix-registry/pom.xml
index c59ed2c..86a142e 100644
--- a/apix-registry/pom.xml
+++ b/apix-registry/pom.xml
@@ -55,6 +55,12 @@
quarkus-logging-json
+
+
+ io.quarkus
+ quarkus-micrometer-registry-prometheus
+
+
io.quarkus
@@ -118,6 +124,63 @@
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+
+
+ add-integration-test-source
+ generate-test-sources
+ add-test-source
+
+
+ src/integration-test/java
+
+
+
+
+ add-integration-test-resource
+ generate-test-resources
+ add-test-resource
+
+
+
+ src/integration-test/resources
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IT.java
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+
+ integration-test
+ verify
+
+
+
+
+
+ **/*IT.java
+
+
+
${quarkus.platform.group-id}
quarkus-maven-plugin
diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberIT.java
similarity index 86%
rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java
rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberIT.java
index 8b482ca..ee837d8 100644
--- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberTest.java
+++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotProfileCucumberIT.java
@@ -8,10 +8,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Runs BDD scenarios for the IoT device connection profile feature.
- * Shares step definitions with IotTransitionCucumberTest via the same glue package.
+ * Shares step definitions with IotTransitionCucumberIT via the same glue package.
*/
@QuarkusTest
-public class IotProfileCucumberTest {
+public class IotProfileCucumberIT {
@Test
public void run() {
diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberTest.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberIT.java
similarity index 96%
rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberTest.java
rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberIT.java
index aa6f8fc..b366745 100644
--- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberTest.java
+++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionCucumberIT.java
@@ -14,7 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
* Cucumber engine, so junit-platform.properties tag filters do not apply here.
*/
@QuarkusTest
-public class IotTransitionCucumberTest {
+public class IotTransitionCucumberIT {
@Test
public void run() {
diff --git a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java
similarity index 94%
rename from apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java
rename to apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java
index 5273ed2..a3e5412 100644
--- a/apix-registry/src/test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java
+++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/IotTransitionSteps.java
@@ -745,6 +745,14 @@ public class IotTransitionSteps {
.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();
@@ -830,6 +838,26 @@ public class IotTransitionSteps {
.patch("/services/" + serviceIds.get(name));
}
+ @When("{string} sets its full IoT profile")
+ public void setsFullIotProfile(String name) {
+ actionPhase = true;
+ Map 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;
@@ -844,6 +872,13 @@ public class IotTransitionSteps {
"/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$")
@@ -872,4 +907,18 @@ public class IotTransitionSteps {
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));
+ }
}
diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingCucumberIT.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingCucumberIT.java
new file mode 100644
index 0000000..a69a67d
--- /dev/null
+++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingCucumberIT.java
@@ -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");
+ }
+}
diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java
new file mode 100644
index 0000000..8d5137f
--- /dev/null
+++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/OrgOnboardingSteps.java
@@ -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