Implement apix-registry with IoT sunset/decommission lifecycle and full BDD suite

- REST API: register, patch, O-level, replacements, history, search endpoints
- IoT lifecycle validations: future sunset, lock-before-release, sunset-passed-before-decommission
- DB schema: Liquibase changesets 001–008 (services, versions, replacements, sunset-at column)
- @ColumnTransformer(write="?::jsonb") on bsm_payload fields to avoid JDBC varchar→jsonb rejection
- Jandex plugin on apix-common + quarkus.index-dependency so @NotBlank validators resolve at runtime
- quarkus-logging-json extension added; quarkus.log.console.json=false is now a recognised key
- Fix requireSunsetBeforeLockRelease: Boolean.TRUE.equals instead of !Boolean.FALSE.equals (null guard)
- BDD suite: 27 scenarios / 213 steps across 5 feature files (sunset-lock, decommission, replacement, discovery, anonymity)
- Test infrastructure: JDBC TRUNCATE in @Before for DB isolation, Arc.container() for clock control — no test endpoints in production code
- sunsetAt truncated to microseconds in BDD steps to match Postgres timestamptz precision
- Cucumber step fixes: singular/plural candidate(s), lastResponse propagation in replacementsReturnsNCandidates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-08 09:13:26 +02:00
commit b2a16a8be7
71 changed files with 5480 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.botstandards</groupId>
<artifactId>apix-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>apix-registry</artifactId>
<name>APIX :: Registry</name>
<description>REST API service. Owns the database schema (Liquibase). Port 8180.</description>
<dependencies>
<dependency>
<groupId>org.botstandards</groupId>
<artifactId>apix-common</artifactId>
</dependency>
<dependency>
<groupId>org.botstandards</groupId>
<artifactId>apix-verification</artifactId>
</dependency>
<!-- REST -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<!-- Persistence -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase</artifactId>
</dependency>
<!-- Logging -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-logging-json</artifactId>
</dependency>
<!-- Validation / Security / Health -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock</artifactId>
<version>3.5.4</version>
<scope>test</scope>
</dependency>
<!-- AssertJ — explicit because Quarkus BOM does not export it as a transitive -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!-- Cucumber BDD -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency>
<!-- Allure reporting -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-cucumber7-jvm</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Used by setup-dev.sh: mvn liquibase:update -pl apix-registry -Dliquibase.url=... -->
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<configuration>
<changeLogFile>src/main/resources/db/changelog/db.changelog-master.xml</changeLogFile>
</configuration>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,25 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.ServiceStage;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ReplacementsResponse(
Boolean locked,
Instant sunsetAt,
List<Candidate> candidates
) {
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Candidate(
UUID id,
String name,
String endpoint,
OLevel oLevel,
ServiceStage serviceStage
) {}
}
@@ -0,0 +1,34 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.OrgType;
import org.botstandards.apix.common.ServiceStage;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ServicePatchRequest(
String name,
String description,
String endpoint,
List<String> capabilities,
String registrantEmail,
String registrantName,
String registrantJurisdiction,
OrgType registrantOrgType,
String registrantLei,
String openApiSpecUrl,
String mcpSpecUrl,
String policyUrl,
String securityContactUrl,
BsmPayload.Pricing pricing,
String bsmVersion,
ServiceStage serviceStage,
Boolean locked,
Instant sunsetAt,
String migrationGuideUrl,
List<UUID> replacesServiceIds
) {}
@@ -0,0 +1,71 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.entity.ServiceEntity;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ServiceResponse(
UUID id,
String name,
String description,
String endpoint,
List<String> capabilities,
String registrantEmail,
String registrantName,
String registrantJurisdiction,
OrgType registrantOrgType,
String registrantLei,
String openApiSpecUrl,
String mcpSpecUrl,
String policyUrl,
String securityContactUrl,
BsmPayload.Pricing pricing,
String bsmVersion,
OLevel oLevel,
LivenessStatus livenessStatus,
ServiceStage serviceStage,
RegistryStatus registryStatus,
Boolean locked,
Instant sunsetAt,
String migrationGuideUrl,
List<UUID> replacesServiceIds,
Instant registeredAt,
Instant lastUpdatedAt
) {
public static ServiceResponse from(ServiceEntity e) {
BsmPayload b = e.bsmPayload;
return new ServiceResponse(
e.id,
b.name(),
b.description(),
b.endpoint(),
b.capabilities(),
b.registrantEmail(),
b.registrantName(),
b.registrantJurisdiction(),
e.registrantOrgType,
b.registrantLei(),
b.openApiSpecUrl(),
b.mcpSpecUrl(),
b.policyUrl(),
b.securityContactUrl(),
b.pricing(),
b.bsmVersion(),
e.olevel,
e.livenessStatus,
e.serviceStage,
e.registryStatus,
e.locked,
e.sunsetAt,
e.migrationGuideUrl,
b.replacesServiceIds(),
e.registeredAt,
e.lastUpdatedAt
);
}
}
@@ -0,0 +1,14 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record VersionHistoryEntry(
UUID id,
String type,
String previousValue,
String newValue,
String createdAt
) {}
@@ -0,0 +1,64 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
import org.hibernate.annotations.ColumnTransformer;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "services")
public class ServiceEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "endpoint_url", nullable = false, unique = true)
public String endpointUrl;
@Convert(converter = BsmPayloadConverter.class)
@Column(name = "bsm_payload", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public BsmPayload bsmPayload;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public OLevel olevel = OLevel.UNVERIFIED;
@Enumerated(EnumType.STRING)
@Column(name = "liveness_status", nullable = false)
public LivenessStatus livenessStatus = LivenessStatus.PENDING;
@Column(name = "registered_at", nullable = false)
public Instant registeredAt;
@Enumerated(EnumType.STRING)
@Column(name = "registrant_org_type", nullable = false)
public OrgType registrantOrgType = OrgType.INDIVIDUAL;
@Enumerated(EnumType.STRING)
@Column(name = "service_stage", nullable = false)
public ServiceStage serviceStage = ServiceStage.DEVELOPMENT;
@Enumerated(EnumType.STRING)
@Column(name = "registry_status", nullable = false)
public RegistryStatus registryStatus = RegistryStatus.ACTIVE;
@Column(nullable = false)
public int version = 1;
@Column(name = "last_updated_at")
public Instant lastUpdatedAt;
@Column(name = "locked")
public Boolean locked;
@Column(name = "sunset_at")
public Instant sunsetAt;
@Column(name = "migration_guide_url")
public String migrationGuideUrl;
}
@@ -0,0 +1,27 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "service_replacements")
public class ServiceReplacementEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "deprecated_service_id", nullable = false)
public UUID deprecatedServiceId;
@Column(name = "replacement_service_id", nullable = false)
public UUID replacementServiceId;
@Column(name = "declared_at", nullable = false)
public Instant declaredAt;
@Column(name = "compatibility_notes")
public String compatibilityNotes;
}
@@ -0,0 +1,55 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
import org.hibernate.annotations.ColumnTransformer;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "service_versions")
public class ServiceVersionEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "service_id", nullable = false)
public UUID serviceId;
@Column(nullable = false)
public int version;
@Column(name = "recorded_at", nullable = false)
public Instant recordedAt;
@Enumerated(EnumType.STRING)
@Column(name = "change_type", nullable = false)
public ChangeType changeType;
@Convert(converter = BsmPayloadConverter.class)
@Column(name = "bsm_payload", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public BsmPayload bsmPayload;
@Enumerated(EnumType.STRING)
@Column(name = "registrant_org_type", nullable = false)
public OrgType registrantOrgType;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public OLevel olevel;
@Enumerated(EnumType.STRING)
@Column(name = "service_stage", nullable = false)
public ServiceStage serviceStage;
@Enumerated(EnumType.STRING)
@Column(name = "registry_status", nullable = false)
public RegistryStatus registryStatus;
@Column(name = "note")
public String note;
}
@@ -0,0 +1,38 @@
package org.botstandards.apix.registry.persistence;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import org.botstandards.apix.common.BsmPayload;
@Converter(autoApply = true)
public class BsmPayloadConverter implements AttributeConverter<BsmPayload, String> {
private static final JsonMapper MAPPER = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
@Override
public String convertToDatabaseColumn(BsmPayload payload) {
if (payload == null) return null;
try {
return MAPPER.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot serialize BsmPayload", e);
}
}
@Override
public BsmPayload convertToEntityAttribute(String json) {
if (json == null) return null;
try {
return MAPPER.readValue(json, BsmPayload.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot deserialize BsmPayload", e);
}
}
}
@@ -0,0 +1,101 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.ServiceResponse;
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
import org.botstandards.apix.registry.service.RegistryService;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Path("/services")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ServiceResource {
@Inject
RegistryService registryService;
@ConfigProperty(name = "apix.api-key")
String apiKey;
@POST
public Response register(@Valid BsmPayload payload, @HeaderParam("X-Api-Key") String key) {
requireKey(key);
var service = registryService.register(payload);
return Response.created(URI.create("/services/" + service.id))
.entity(Map.of("id", service.id.toString()))
.build();
}
@GET
@Path("/{id}")
public ServiceResponse getById(@PathParam("id") UUID id) {
return ServiceResponse.from(registryService.requireById(id));
}
@PATCH
@Path("/{id}")
public ServiceResponse patch(@PathParam("id") UUID id,
ServicePatchRequest req,
@HeaderParam("X-Api-Key") String key) {
requireKey(key);
return ServiceResponse.from(registryService.patch(id, req));
}
@GET
public List<ServiceResponse> search(@QueryParam("capability") String capability,
@QueryParam("stage") String stage) {
if (capability == null || capability.isBlank()) {
throw new BadRequestException("capability query parameter is required");
}
return registryService.search(capability, stage).stream()
.map(ServiceResponse::from)
.toList();
}
@PATCH
@Path("/{id}/olevel")
public ServiceResponse setOLevel(@PathParam("id") UUID id,
@QueryParam("level") String level,
@HeaderParam("X-Api-Key") String key) {
requireKey(key);
OLevel oLevel = OLevel.valueOf(level.toUpperCase());
return ServiceResponse.from(registryService.setOLevel(id, oLevel));
}
@GET
@Path("/{id}/replacements")
public Response getReplacements(@PathParam("id") UUID id,
@QueryParam("minOLevel") String minOLevel) {
ReplacementsResponse body = registryService.getReplacements(id, minOLevel);
return Response.ok(body)
.header("Cache-Control", "public, max-age=60")
.build();
}
@GET
@Path("/{id}/history")
public List<VersionHistoryEntry> getHistory(@PathParam("id") UUID id) {
return registryService.getHistory(id);
}
private void requireKey(String provided) {
if (!apiKey.equals(provided)) {
throw new NotAuthorizedException(
Response.status(401)
.entity(Map.of("message", "Invalid or missing API key"))
.build());
}
}
}
@@ -0,0 +1,23 @@
package org.botstandards.apix.registry.service;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.Instant;
@ApplicationScoped
public class ClockService {
private volatile Instant override = null;
public Instant now() {
return override != null ? override : Instant.now();
}
public void advance(Instant instant) {
this.override = instant;
}
public void reset() {
this.override = null;
}
}
@@ -0,0 +1,340 @@
package org.botstandards.apix.registry.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
import org.botstandards.apix.registry.entity.ServiceEntity;
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
import java.time.Instant;
import java.util.*;
@ApplicationScoped
public class RegistryService {
@Inject
EntityManager em;
@Inject
ClockService clockService;
@Transactional
public ServiceEntity register(BsmPayload payload) {
long existing = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM services WHERE endpoint_url = :url")
.setParameter("url", payload.endpoint())
.getSingleResult()).longValue();
if (existing > 0) {
throw new WebApplicationException(
Response.status(409)
.entity(Map.of("message", "A service with this endpoint is already registered"))
.build());
}
Instant now = Instant.now();
ServiceEntity service = new ServiceEntity();
service.id = UUID.randomUUID();
service.endpointUrl = payload.endpoint();
service.bsmPayload = payload;
service.olevel = OLevel.UNVERIFIED;
service.livenessStatus = LivenessStatus.PENDING;
service.registeredAt = now;
service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL;
service.serviceStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
service.registryStatus = RegistryStatus.ACTIVE;
service.version = 1;
service.locked = payload.locked();
service.sunsetAt = payload.sunsetAt();
service.migrationGuideUrl = payload.migrationGuideUrl();
em.persist(service);
em.persist(snapshot(service, ChangeType.REGISTERED, now));
if (payload.replacesServiceIds() != null) {
for (UUID deprecatedId : payload.replacesServiceIds()) {
upsertReplacement(deprecatedId, service.id, now);
}
}
return service;
}
public ServiceEntity requireById(UUID id) {
ServiceEntity e = em.find(ServiceEntity.class, id);
if (e == null) throw new NotFoundException("Service not found: " + id);
return e;
}
@Transactional
public ServiceEntity setOLevel(UUID id, OLevel level) {
ServiceEntity e = requireById(id);
e.olevel = level;
e.lastUpdatedAt = Instant.now();
return e;
}
@Transactional
public ServiceEntity patch(UUID id, ServicePatchRequest req) {
ServiceEntity service = requireById(id);
// ── IoT validations ───────────────────────────────────────────────────
Instant now = clockService.now();
requireFutureSunset(req.sunsetAt(), now);
requireSunsetBeforeLockRelease(req.locked(), service.locked,
req.sunsetAt() != null ? req.sunsetAt() : service.sunsetAt);
requireSunsetPassedBeforeDecommission(req.serviceStage(), service.sunsetAt, now);
if (req.replacesServiceIds() != null) {
for (UUID deprecatedId : req.replacesServiceIds()) {
validateReplacementTarget(deprecatedId);
}
}
ServiceStage stageBefore = service.serviceStage;
Boolean lockedBefore = service.locked;
applyPatch(service, req);
ChangeType changeType = detectChangeType(stageBefore, lockedBefore, req);
service.version++;
service.lastUpdatedAt = Instant.now();
em.persist(snapshot(service, changeType, service.lastUpdatedAt));
if (req.replacesServiceIds() != null) {
syncReplacements(service.id, req.replacesServiceIds(), service.lastUpdatedAt);
}
return service;
}
@SuppressWarnings("unchecked")
public List<ServiceEntity> search(String capability, String stage) {
ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.PRODUCTION;
return em.createNativeQuery(
"SELECT s.* FROM services s " +
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
"AND s.registry_status = 'ACTIVE' " +
"AND s.service_stage = :stage",
ServiceEntity.class)
.setParameter("cap", capability)
.setParameter("stage", targetStage.name())
.getResultList();
}
@SuppressWarnings("unchecked")
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr) {
ServiceEntity deprecated = requireById(deprecatedId);
// Locked services that are not yet DECOMMISSIONED block replacement discovery
if (Boolean.TRUE.equals(deprecated.locked) && deprecated.serviceStage != ServiceStage.DECOMMISSIONED) {
return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
}
List<ServiceEntity> candidates = em.createNativeQuery(
"SELECT s.* FROM services s " +
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
ServiceEntity.class)
.setParameter("depId", deprecatedId)
.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
return new ReplacementsResponse(
deprecated.locked,
deprecated.sunsetAt,
candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
.map(c -> new ReplacementsResponse.Candidate(
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage))
.toList()
);
}
public List<VersionHistoryEntry> getHistory(UUID id) {
requireById(id);
List<ServiceVersionEntity> versions = em.createQuery(
"FROM ServiceVersionEntity v WHERE v.serviceId = :id ORDER BY v.version ASC",
ServiceVersionEntity.class)
.setParameter("id", id)
.getResultList();
List<VersionHistoryEntry> result = new ArrayList<>();
for (int i = 0; i < versions.size(); i++) {
result.add(toHistoryEntry(versions.get(i), i > 0 ? versions.get(i - 1) : null));
}
return result;
}
// ── Package-private static helpers — unit-testable without DB ─────────────
static ChangeType detectChangeType(ServiceStage stageBefore, Boolean lockedBefore, ServicePatchRequest req) {
if (req.locked() != null && Boolean.FALSE.equals(req.locked()) && Boolean.TRUE.equals(lockedBefore)) {
return ChangeType.LOCK_RELEASED;
}
if (req.serviceStage() != null && req.serviceStage() != stageBefore) {
if (req.sunsetAt() != null && req.serviceStage() == ServiceStage.DEPRECATED) {
return ChangeType.SUNSET_DECLARED;
}
return ChangeType.STAGE_CHANGED;
}
if (req.replacesServiceIds() != null) {
return ChangeType.REPLACEMENT_DECLARED;
}
return ChangeType.BSM_UPDATED;
}
// sunsetAt must be strictly after now — exclusive boundary means now==sunsetAt is already "past"
static void requireFutureSunset(Instant sunsetAt, Instant now) {
if (sunsetAt != null && !sunsetAt.isAfter(now)) {
throw unprocessable("sunset_at must be a future moment");
}
}
static void requireSunsetBeforeLockRelease(Boolean newLocked, Boolean existingLocked, Instant effectiveSunsetAt) {
if (Boolean.FALSE.equals(newLocked) && Boolean.TRUE.equals(existingLocked) && effectiveSunsetAt == null) {
throw unprocessable("sunset_at required before lock release");
}
}
// Exclusive boundary: now >= sunsetAt means the sunset moment has arrived
static void requireSunsetPassedBeforeDecommission(ServiceStage newStage, Instant sunsetAt, Instant now) {
if (newStage == ServiceStage.DECOMMISSIONED) {
if (sunsetAt == null || now.isBefore(sunsetAt)) {
throw unprocessable("sunset_at has not passed");
}
}
}
// ── Private helpers ───────────────────────────────────────────────────────
private void validateReplacementTarget(UUID deprecatedId) {
ServiceEntity target = em.find(ServiceEntity.class, deprecatedId);
if (target == null) throw unprocessable("target service not found");
if (target.serviceStage != ServiceStage.DEPRECATED && target.serviceStage != ServiceStage.DECOMMISSIONED) {
throw unprocessable("target service is not deprecated");
}
if (target.serviceStage == ServiceStage.DEPRECATED && Boolean.TRUE.equals(target.locked)) {
throw unprocessable("target service lock has not been released");
}
}
private void applyPatch(ServiceEntity e, ServicePatchRequest r) {
BsmPayload old = e.bsmPayload;
e.bsmPayload = new BsmPayload(
r.name() != null ? r.name() : old.name(),
r.description() != null ? r.description() : old.description(),
r.endpoint() != null ? r.endpoint() : old.endpoint(),
r.capabilities() != null ? r.capabilities() : old.capabilities(),
r.registrantEmail() != null ? r.registrantEmail() : old.registrantEmail(),
r.registrantName() != null ? r.registrantName() : old.registrantName(),
r.registrantJurisdiction() != null ? r.registrantJurisdiction() : old.registrantJurisdiction(),
r.registrantOrgType() != null ? r.registrantOrgType() : old.registrantOrgType(),
r.registrantLei() != null ? r.registrantLei() : old.registrantLei(),
r.openApiSpecUrl() != null ? r.openApiSpecUrl() : old.openApiSpecUrl(),
r.mcpSpecUrl() != null ? r.mcpSpecUrl() : old.mcpSpecUrl(),
r.policyUrl() != null ? r.policyUrl() : old.policyUrl(),
r.securityContactUrl() != null ? r.securityContactUrl() : old.securityContactUrl(),
r.pricing() != null ? r.pricing() : old.pricing(),
r.bsmVersion() != null ? r.bsmVersion() : old.bsmVersion(),
r.serviceStage() != null ? r.serviceStage() : old.serviceStage(),
r.locked() != null ? r.locked() : old.locked(),
r.sunsetAt() != null ? r.sunsetAt() : old.sunsetAt(),
r.migrationGuideUrl() != null ? r.migrationGuideUrl() : old.migrationGuideUrl(),
r.replacesServiceIds() != null ? r.replacesServiceIds() : old.replacesServiceIds()
);
if (r.endpoint() != null) e.endpointUrl = r.endpoint();
if (r.registrantOrgType() != null) e.registrantOrgType = r.registrantOrgType();
if (r.serviceStage() != null) e.serviceStage = r.serviceStage();
if (r.locked() != null) e.locked = r.locked();
if (r.sunsetAt() != null) e.sunsetAt = r.sunsetAt();
if (r.migrationGuideUrl() != null) e.migrationGuideUrl = r.migrationGuideUrl();
}
private ServiceVersionEntity snapshot(ServiceEntity e, ChangeType changeType, Instant at) {
ServiceVersionEntity v = new ServiceVersionEntity();
v.id = UUID.randomUUID();
v.serviceId = e.id;
v.version = e.version;
v.recordedAt = at;
v.changeType = changeType;
v.bsmPayload = e.bsmPayload;
v.registrantOrgType = e.registrantOrgType;
v.olevel = e.olevel;
v.serviceStage = e.serviceStage;
v.registryStatus = e.registryStatus;
return v;
}
private void syncReplacements(UUID providerId, List<UUID> deprecatedIds, Instant now) {
em.createNativeQuery("DELETE FROM service_replacements WHERE replacement_service_id = :id")
.setParameter("id", providerId)
.executeUpdate();
for (UUID deprecatedId : deprecatedIds) {
upsertReplacement(deprecatedId, providerId, now);
}
}
private void upsertReplacement(UUID deprecatedId, UUID replacementId, Instant now) {
long count = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM service_replacements " +
"WHERE deprecated_service_id = :dep AND replacement_service_id = :rep")
.setParameter("dep", deprecatedId)
.setParameter("rep", replacementId)
.getSingleResult()).longValue();
if (count == 0) {
// Record event in the deprecated service's timeline
ServiceEntity deprecated = em.find(ServiceEntity.class, deprecatedId);
if (deprecated != null) {
deprecated.version++;
deprecated.lastUpdatedAt = now;
em.persist(snapshot(deprecated, ChangeType.REPLACEMENT_DECLARED, now));
}
ServiceReplacementEntity r = new ServiceReplacementEntity();
r.id = UUID.randomUUID();
r.deprecatedServiceId = deprecatedId;
r.replacementServiceId = replacementId;
r.declaredAt = now;
em.persist(r);
}
}
private VersionHistoryEntry toHistoryEntry(ServiceVersionEntity cur, ServiceVersionEntity prev) {
String previousValue = null;
String newValue = null;
switch (cur.changeType) {
case STAGE_CHANGED, SUNSET_DECLARED -> {
previousValue = prev != null ? prev.serviceStage.name() : null;
newValue = cur.serviceStage.name();
}
case LOCK_RELEASED -> {
previousValue = prev != null && prev.bsmPayload.locked() != null
? prev.bsmPayload.locked().toString() : null;
newValue = cur.bsmPayload.locked() != null ? cur.bsmPayload.locked().toString() : null;
}
default -> {}
}
return new VersionHistoryEntry(cur.id, cur.changeType.name(), previousValue, newValue,
cur.recordedAt.toString());
}
private static WebApplicationException unprocessable(String message) {
return new WebApplicationException(
Response.status(422).entity(Map.of("message", message)).build());
}
}
@@ -0,0 +1,35 @@
# ── Jandex — include apix-common in the index so bean validation constraints work ──
quarkus.index-dependency.apix-common.group-id=org.botstandards
quarkus.index-dependency.apix-common.artifact-id=apix-common
# ── Datasource ────────────────────────────────────────────────────────────────
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=${QUARKUS_DATASOURCE_JDBC_URL:jdbc:postgresql://localhost:5432/apix}
quarkus.datasource.username=${QUARKUS_DATASOURCE_USERNAME:apix}
quarkus.datasource.password=${QUARKUS_DATASOURCE_PASSWORD:apix}
# ── ORM ───────────────────────────────────────────────────────────────────────
# Liquibase owns schema creation; Hibernate must not touch DDL
quarkus.hibernate-orm.database.generation=none
# ── Liquibase ─────────────────────────────────────────────────────────────────
quarkus.liquibase.migrate-at-start=true
quarkus.liquibase.change-log=db/changelog/db.changelog-master.xml
# ── HTTP ──────────────────────────────────────────────────────────────────────
quarkus.http.port=8180
# ── Security — API key for write endpoints ───────────────────────────────────
apix.api-key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
# ── Verification ──────────────────────────────────────────────────────────────
apix.gleif.api-url=${GLEIF_API_URL:https://api.gleif.org/api/v1}
apix.opencorporates.api-key=${OPENCORPORATES_API_KEY:}
apix.sanctions.cache-path=${SANCTIONS_CACHE_PATH:./sanctions-cache}
# ── Logging ───────────────────────────────────────────────────────────────────
quarkus.log.level=${LOG_LEVEL:DEBUG}
quarkus.log.console.json=false
# ── Health ────────────────────────────────────────────────────────────────────
quarkus.smallrye-health.root-path=/q/health
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="001" author="apix">
<createTable tableName="services">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="endpoint_url" type="text">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_services_endpoint_url"/>
</column>
<!-- Full BSM document stored as JSONB for flexible querying -->
<column name="bsm_payload" type="jsonb">
<constraints nullable="false"/>
</column>
<column name="olevel" type="varchar(50)" defaultValue="UNVERIFIED">
<constraints nullable="false"/>
</column>
<column name="slevel" type="varchar(50)"/>
<column name="liveness_status" type="varchar(50)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="registered_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- GIN index on bsm_payload for capability search (@> operator) -->
<sql>CREATE INDEX idx_services_bsm_payload_gin ON services USING gin (bsm_payload);</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="002" author="apix">
<addColumn tableName="services">
<column name="verification_status" type="varchar(50)"/>
<column name="olevel_checked_at" type="timestamptz"/>
<!-- null = not yet screened; false = hit; true = clear -->
<column name="sanctions_cleared" type="boolean"/>
<column name="gleif_lei" type="text"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="003" author="apix">
<addColumn tableName="services">
<column name="last_checked_at" type="timestamptz"/>
<column name="uptime_30d_percent" type="numeric(5,2)"/>
<column name="avg_response_ms" type="integer"/>
<column name="consecutive_failures" type="integer" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="004" author="apix">
<addColumn tableName="services">
<!-- Registrant type — top-level column, not buried in JSONB, for direct filtering -->
<column name="registrant_org_type" type="varchar(50)" defaultValue="INDIVIDUAL">
<constraints nullable="false"/>
</column>
<!-- Registrant-declared lifecycle stage — controls search visibility -->
<column name="service_stage" type="varchar(50)" defaultValue="DEVELOPMENT">
<constraints nullable="false"/>
</column>
<!-- BSF-controlled administrative state — always applied before stage filter -->
<column name="registry_status" type="varchar(50)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<!-- Monotonically increasing per-service counter; links to service_versions -->
<column name="version" type="integer" defaultValueNumeric="1">
<constraints nullable="false"/>
</column>
<!-- Timestamp of last non-liveness update (liveness writes go to 003 columns) -->
<column name="last_updated_at" type="timestamptz"/>
</addColumn>
<!-- service_stage is the primary search filter dimension after capability -->
<createIndex tableName="services" indexName="idx_services_service_stage">
<column name="service_stage"/>
</createIndex>
<!-- registry_status is applied to every public query; index keeps it cheap -->
<createIndex tableName="services" indexName="idx_services_registry_status">
<column name="registry_status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="005" author="apix">
<!-- Append-only snapshot table. Rows are never updated or deleted.
Each row captures the complete service state at the moment of a meaningful change.
Diffs are computed from adjacent versions at read time, not stored. -->
<createTable tableName="service_versions">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="service_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_sv_service_id"
references="services(id)"/>
</column>
<column name="version" type="integer">
<constraints nullable="false"/>
</column>
<column name="recorded_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<!-- REGISTERED | BSM_UPDATED | ORG_TYPE_CHANGED | OLEVEL_CHANGED |
STAGE_CHANGED | OWNERSHIP_TRANSFERRED | REGISTRY_STATUS_CHANGED -->
<column name="change_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<!-- Full BSM at this version — enables complete before/after comparison -->
<column name="bsm_payload" type="jsonb">
<constraints nullable="false"/>
</column>
<column name="registrant_org_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="olevel" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="service_stage" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="registry_status" type="varchar(50)">
<constraints nullable="false"/>
</column>
<!-- Optional human-readable context for the change, e.g.
"Incorporated as GmbH; transitioning from individual registration" -->
<column name="note" type="text"/>
</createTable>
<addUniqueConstraint
tableName="service_versions"
columnNames="service_id, version"
constraintName="uq_sv_service_version"/>
<!-- Primary access pattern: all versions for a given service, ordered by version -->
<createIndex tableName="service_versions" indexName="idx_sv_service_id">
<column name="service_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="006" author="apix">
<addColumn tableName="services">
<!-- null = lock concept not applicable (non-IoT service)
true = device locked to this template owner; migration blocked
false = lock released; device owner may migrate freely
Default null so existing records are not incorrectly treated as locked. -->
<column name="locked" type="boolean"/>
<!-- ISO date when the service goes permanently offline.
Set together with service_stage = DEPRECATED. -->
<column name="sunset_date" type="date"/>
<!-- Provider-hosted migration documentation URL. -->
<column name="migration_guide_url" type="text"/>
</addColumn>
<!-- Targeted index: only rows with a sunset date (small, DEPRECATED subset) -->
<sql>CREATE INDEX idx_services_sunset_date ON services (sunset_date)
WHERE sunset_date IS NOT NULL;</sql>
<!-- Targeted index: only rows where locked is explicitly set -->
<sql>CREATE INDEX idx_services_locked ON services (locked)
WHERE locked IS NOT NULL;</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="007" author="apix">
<!-- Declared compatibility index.
Replacement providers list deprecated service IDs in BSM.replacesServiceIds[].
The registry extracts those declarations here for efficient lookup.
These rows are derived from BSM content and re-synced on every BSM update —
never edit directly. -->
<createTable tableName="service_replacements">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- The deprecated service being replaced -->
<column name="deprecated_service_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_sr_deprecated"
references="services(id)"/>
</column>
<!-- The service declaring it can replace the deprecated one -->
<column name="replacement_service_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_sr_replacement"
references="services(id)"/>
</column>
<column name="declared_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<!-- Optional human-readable compatibility notes from the replacement provider -->
<column name="compatibility_notes" type="text"/>
</createTable>
<!-- A replacement service can declare compatibility with each deprecated service once -->
<addUniqueConstraint
tableName="service_replacements"
columnNames="deprecated_service_id, replacement_service_id"
constraintName="uq_sr_deprecated_replacement"/>
<!-- Primary query path: GET /services/{deprecatedId}/replacements -->
<createIndex tableName="service_replacements" indexName="idx_sr_deprecated_service_id">
<column name="deprecated_service_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="008" author="apix">
<!-- Drop old date-only index before renaming the column -->
<sql>DROP INDEX IF EXISTS idx_services_sunset_date;</sql>
<!-- Rename and retype: date → timestamptz (exclusive boundary, UTC).
Existing date values are cast to midnight UTC of that date. -->
<sql>ALTER TABLE services RENAME COLUMN sunset_date TO sunset_at;</sql>
<sql>ALTER TABLE services
ALTER COLUMN sunset_at TYPE timestamptz
USING sunset_at::timestamptz;</sql>
<!-- Recreate targeted index under new column name -->
<sql>CREATE INDEX idx_services_sunset_at ON services (sunset_at)
WHERE sunset_at IS NOT NULL;</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<include file="changes/001-initial-schema.xml" relativeToChangelogFile="true"/>
<include file="changes/002-verification-columns.xml" relativeToChangelogFile="true"/>
<include file="changes/003-liveness-metrics.xml" relativeToChangelogFile="true"/>
<include file="changes/004-org-stage-status.xml" relativeToChangelogFile="true"/>
<include file="changes/005-version-history.xml" relativeToChangelogFile="true"/>
<include file="changes/006-sunset-lock.xml" relativeToChangelogFile="true"/>
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -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 IotTransitionCucumberTest {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/iot-transition"
);
assertEquals(0, exitCode, "One or more Cucumber scenarios failed — check test output for details");
}
}
@@ -0,0 +1,786 @@
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 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");
}
}
}
@@ -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 CASCADE");
}
}
@After
public void resetClock() {
Arc.container().instance(ClockService.class).get().reset();
}
}
@@ -0,0 +1,3 @@
// Superseded: clock control now goes through Arc.container() directly in TestSetup/@After
// and IotTransitionSteps, keeping the clock as a pure in-process concern.
// File kept to avoid breaking any IDE import caches; no JAX-RS registration.
@@ -0,0 +1,193 @@
package org.botstandards.apix.registry.service;
import jakarta.ws.rs.WebApplicationException;
import org.botstandards.apix.common.ChangeType;
import org.botstandards.apix.common.ServiceStage;
import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RegistryServiceTest {
// Fixed reference moment so tests don't depend on wall-clock time
private static final Instant NOW = Instant.parse("2025-06-15T00:00:00Z");
// ── detectChangeType ──────────────────────────────────────────────────────
@Test
void detectChangeType_lockReleased_whenLockedTransitionsFromTrueToFalse() {
assertThat(RegistryService.detectChangeType(ServiceStage.DEPRECATED, true, patch(null, false, null, null)))
.isEqualTo(ChangeType.LOCK_RELEASED);
}
@Test
void detectChangeType_bsmUpdated_whenLockedSetFalseButWasAlreadyFalse() {
assertThat(RegistryService.detectChangeType(ServiceStage.DEPRECATED, false, patch(null, false, null, null)))
.isEqualTo(ChangeType.BSM_UPDATED);
}
@Test
void detectChangeType_bsmUpdated_whenLockedSetFalseButPreviouslyNull() {
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null, patch(null, false, null, null)))
.isEqualTo(ChangeType.BSM_UPDATED);
}
@Test
void detectChangeType_sunsetDeclared_whenStageToDeprecatedAndSunsetAtInSameRequest() {
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null,
patch(ServiceStage.DEPRECATED, null, NOW.plus(Duration.ofDays(90)), null)))
.isEqualTo(ChangeType.SUNSET_DECLARED);
}
@Test
void detectChangeType_stageChanged_whenStageToDeprecatedWithoutSunsetAt() {
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null,
patch(ServiceStage.DEPRECATED, null, null, null)))
.isEqualTo(ChangeType.STAGE_CHANGED);
}
@Test
void detectChangeType_stageChanged_whenStageToDecommissioned() {
assertThat(RegistryService.detectChangeType(ServiceStage.DEPRECATED, false,
patch(ServiceStage.DECOMMISSIONED, null, null, null)))
.isEqualTo(ChangeType.STAGE_CHANGED);
}
@Test
void detectChangeType_replacementDeclared_whenReplacesServiceIdsProvided() {
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null,
patch(null, null, null, List.of(UUID.randomUUID()))))
.isEqualTo(ChangeType.REPLACEMENT_DECLARED);
}
@Test
void detectChangeType_replacementDeclared_whenReplacesServiceIdsEmpty() {
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null,
patch(null, null, null, List.of())))
.isEqualTo(ChangeType.REPLACEMENT_DECLARED);
}
@Test
void detectChangeType_bsmUpdated_whenOnlyCapabilitiesChanged() {
var req = new ServicePatchRequest(
null, null, null, List.of("new.capability"),
null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null);
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null, req))
.isEqualTo(ChangeType.BSM_UPDATED);
}
@Test
void detectChangeType_lockReleased_takesOverStageChangeWhenBothOccur() {
assertThat(RegistryService.detectChangeType(ServiceStage.DEPRECATED, true,
patch(ServiceStage.DECOMMISSIONED, false, null, null)))
.isEqualTo(ChangeType.LOCK_RELEASED);
}
// ── requireFutureSunset ───────────────────────────────────────────────────
@Test
void requireFutureSunset_passes_whenNull() {
RegistryService.requireFutureSunset(null, NOW); // must not throw
}
@Test
void requireFutureSunset_passes_whenFuture() {
RegistryService.requireFutureSunset(NOW.plus(Duration.ofDays(1)), NOW); // must not throw
}
@Test
void requireFutureSunset_throws422_whenNow() {
// sunsetAt == now is not strictly after now — exclusive boundary means it's already "past"
assertThatThrownBy(() -> RegistryService.requireFutureSunset(NOW, NOW))
.isInstanceOf(WebApplicationException.class)
.satisfies(e -> assertThat(((WebApplicationException) e).getResponse().getStatus()).isEqualTo(422));
}
@Test
void requireFutureSunset_throws422_whenPast() {
assertThatThrownBy(() -> RegistryService.requireFutureSunset(NOW.minus(Duration.ofDays(1)), NOW))
.isInstanceOf(WebApplicationException.class)
.satisfies(e -> assertThat(((WebApplicationException) e).getResponse().getStatus()).isEqualTo(422));
}
// ── requireSunsetBeforeLockRelease ────────────────────────────────────────
@Test
void requireSunsetBeforeLockRelease_passes_whenNotReleasingLock() {
RegistryService.requireSunsetBeforeLockRelease(true, true, null); // not releasing
}
@Test
void requireSunsetBeforeLockRelease_passes_whenSunsetAtPresent() {
RegistryService.requireSunsetBeforeLockRelease(false, true, NOW.plus(Duration.ofDays(90)));
}
@Test
void requireSunsetBeforeLockRelease_throws422_whenNoSunsetAtAndLockWasTrue() {
assertThatThrownBy(() -> RegistryService.requireSunsetBeforeLockRelease(false, true, null))
.isInstanceOf(WebApplicationException.class)
.satisfies(e -> assertThat(((WebApplicationException) e).getResponse().getStatus()).isEqualTo(422));
}
@Test
void requireSunsetBeforeLockRelease_passes_whenPreviousLockWasAlreadyFalse() {
RegistryService.requireSunsetBeforeLockRelease(false, false, null);
}
@Test
void requireSunsetBeforeLockRelease_passes_whenPreviousLockWasNull() {
RegistryService.requireSunsetBeforeLockRelease(false, null, null);
}
// ── requireSunsetPassedBeforeDecommission ─────────────────────────────────
@Test
void requireSunsetPassedBeforeDecommission_passes_whenNotDecommissioning() {
RegistryService.requireSunsetPassedBeforeDecommission(ServiceStage.PRODUCTION, null, NOW);
}
@Test
void requireSunsetPassedBeforeDecommission_passes_whenSunsetIsInPast() {
RegistryService.requireSunsetPassedBeforeDecommission(ServiceStage.DECOMMISSIONED,
NOW.minus(Duration.ofDays(1)), NOW);
}
@Test
void requireSunsetPassedBeforeDecommission_passes_whenSunsetIsNow() {
// Exclusive boundary: now >= sunsetAt means the sunset moment has arrived
RegistryService.requireSunsetPassedBeforeDecommission(ServiceStage.DECOMMISSIONED, NOW, NOW);
}
@Test
void requireSunsetPassedBeforeDecommission_throws422_whenNoSunsetAt() {
assertThatThrownBy(() ->
RegistryService.requireSunsetPassedBeforeDecommission(ServiceStage.DECOMMISSIONED, null, NOW))
.isInstanceOf(WebApplicationException.class)
.satisfies(e -> assertThat(((WebApplicationException) e).getResponse().getStatus()).isEqualTo(422));
}
@Test
void requireSunsetPassedBeforeDecommission_throws422_whenSunsetIsInFuture() {
assertThatThrownBy(() ->
RegistryService.requireSunsetPassedBeforeDecommission(ServiceStage.DECOMMISSIONED,
NOW.plus(Duration.ofDays(30)), NOW))
.isInstanceOf(WebApplicationException.class)
.satisfies(e -> assertThat(((WebApplicationException) e).getResponse().getStatus()).isEqualTo(422));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ServicePatchRequest patch(ServiceStage stage, Boolean locked, Instant sunsetAt, List<UUID> replaces) {
return new ServicePatchRequest(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
stage, locked, sunsetAt, null, replaces);
}
}
@@ -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,14 @@
# ── 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
@@ -0,0 +1,35 @@
Feature: Device owner anonymity guarantee
As an IoT device owner
I must be able to use the full transition discovery process
Without APIX storing or inferring any artefact that identifies me or my device
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And at least one replacement candidate registered
Scenario: Status polling requires no authentication
When GET /services/{smartHubCloudId} is called with no Authorization header
Then the response is HTTP 200
And locked and sunset_date are present in the response body
Scenario: Replacement discovery requires no authentication
When GET /services/{smartHubCloudId}/replacements is called with no Authorization header
Then the response is HTTP 200
And the full replacement list is returned
Scenario: No session state is created during polling
When GET /services/{smartHubCloudId}/replacements is called twice with no shared headers
Then both responses are identical in content
And neither response contains a Set-Cookie header
And neither response contains a session reference
Scenario: Response contains no client-tracking artefacts
When GET /services/{smartHubCloudId}/replacements is called
Then the response headers contain no Set-Cookie
And the response body contains no field that echoes client request details
And the response body contains no correlation ID tied to the caller
Scenario: Polling endpoint is covered by the public cache
When GET /services/{smartHubCloudId}/replacements is called twice within the cache TTL
Then the second response is served from cache
And the cache key does not incorporate any client-identifying header
@@ -0,0 +1,50 @@
Feature: Service decommissioning and historical record preservation
As a template owner
I want to decommission a service after its sunset date
And as a device owner I must be able to access the historical record indefinitely
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service has locked set to true
And the service is in stage "DEPRECATED" with a sunset_date set
And the service has locked set to false
And a registered service "OpenHub" with endpoint "https://api.openhub.example"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And the sunset_date of "SmartHub Cloud" has passed
Scenario: Template owner decommissions the service
When the template owner sets service_stage to "DECOMMISSIONED"
Then the service does not appear in any capability search results
And GET /services/{id} returns HTTP 200 with the complete historical record
And the response body contains service_stage "DECOMMISSIONED"
And a version history entry of type "STAGE_CHANGED" exists for the service
Scenario: Decommissioned service with unreleased lock auto-releases for replacement discovery
Given a deprecated service "NeverReleased" that reached DECOMMISSIONED without setting locked=false
When GET /services/{neverReleasedId}/replacements is called with no authentication header
Then the response is HTTP 200
And replacement candidates are returned regardless of the stored locked value
Scenario: Historical record survives indefinitely
Given a decommissioned service
When GET /services/{id} is called
Then the response is HTTP 200
And the full BSM payload is present in the response
And all version history entries are accessible via GET /services/{id}/history
Scenario: Cannot decommission a service before its sunset date
Given a deprecated service "FutureCloud" with a sunset_date 30 days from now
When the template owner attempts to set service_stage to "DECOMMISSIONED"
Then the response is HTTP 422
And the error message contains "sunset_at has not passed"
Scenario: Full transition timeline is visible in version history
Given "SmartHub Cloud" has completed the full lifecycle
When GET /services/{id}/history is called
Then the history contains an entry of type "REGISTERED"
And the history contains an entry of type "SUNSET_DECLARED"
And the history contains an entry of type "LOCK_RELEASED"
And the history contains an entry of type "REPLACEMENT_DECLARED"
And the history contains an entry of type "STAGE_CHANGED" with new value "DECOMMISSIONED"
And all entries are ordered chronologically ascending
@@ -0,0 +1,46 @@
Feature: Replacement provider declares compatibility
As a replacement service provider
I want to declare that my service covers a deprecated template
So that IoT device owners can discover me as a migration target
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
Scenario: Replacement provider declares compatibility with a deprecated service
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements includes "OpenHub"
And a version history entry of type "REPLACEMENT_DECLARED" exists for "SmartHub Cloud"
And the service_replacements table contains the declared pair
Scenario: Declaration against a non-deprecated service is rejected
Given a service "ActiveCloud" in stage "PRODUCTION" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "ActiveCloud"
Then the response is HTTP 422
And the error message contains "target service is not deprecated"
Scenario: Declaration against a locked deprecated service is rejected
Given a service "LockedCloud" in stage "DEPRECATED" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "LockedCloud"
Then the response is HTTP 422
And the error message contains "target service lock has not been released"
Scenario: Multiple replacement providers for the same deprecated service
Given a service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
And "CloudBridge" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements returns 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
And the ordering is by O-level descending
Scenario: Replacement provider retracts their compatibility declaration
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" removes "SmartHub Cloud" from its replacesServiceIds
Then GET /services/{smartHubCloudId}/replacements no longer includes "OpenHub"
And the service_replacements row for this pair is deleted
Scenario: Duplicate declaration is idempotent
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud" again
Then the response is HTTP 200
And GET /services/{smartHubCloudId}/replacements still returns exactly 1 candidate
@@ -0,0 +1,45 @@
Feature: Device owner discovers replacement services
As an IoT device owner
I want to discover compatible replacement services by polling APIX
Without revealing my identity or device details
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false and a sunset_date set
And "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED" has declared compatibility
And "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED" has declared compatibility
Scenario: Device polls service status without authentication
When GET /services/{smartHubCloudId} is called with no authentication header
Then the response is HTTP 200
And the response body contains service_stage "DEPRECATED"
And the response body contains locked false
And the response body contains a sunset_date
Scenario: Device discovers replacement candidates without authentication
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response contains 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
Scenario: Device filters candidates by minimum O-level
When GET /services/{smartHubCloudId}/replacements?minOLevel=LEGAL_ENTITY_VERIFIED is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "CloudBridge"
Scenario: Device polls a still-locked deprecated service
Given a deprecated service "LockedCloud" with locked set to true
When GET /services/{lockedCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response body contains an empty candidates list
And the response body contains locked true
Scenario: Default production search excludes deprecated services
When GET /services?capability=device.telemetry is called with no authentication header
Then "SmartHub Cloud" is not in the results
And only services with stage "PRODUCTION" are returned
Scenario: Explicit deprecated filter includes deprecated services
When GET /services?capability=device.telemetry&stage=deprecated is called
Then "SmartHub Cloud" is in the results
And each result has service_stage "DEPRECATED"
@@ -0,0 +1,40 @@
Feature: Sunset declaration and lock release
As a template owner
I want to declare a sunset date and release the device lock
So that IoT device owners can prepare for migration in a predictable window
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service is in stage "PRODUCTION"
And the service has locked set to true
Scenario: Template owner declares sunset date
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service stage is "DEPRECATED"
And the service has a sunset_date set
And a version history entry of type "SUNSET_DECLARED" exists for the service
And the service does not appear in default production search results for capability "device.telemetry"
And the service appears in search results when stage filter is "deprecated"
Scenario: Sunset declaration preserves the device lock
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service has locked set to true
And GET /services/{id}/replacements returns HTTP 200 with an empty list
Scenario: Template owner releases the device lock after sunset is declared
Given the service is in stage "DEPRECATED" with a sunset_date set
When the template owner sets locked to false
Then the service has locked set to false
And a version history entry of type "LOCK_RELEASED" exists for the service
And the version history entry contains the previous locked value true
Scenario: Lock cannot be released without a prior sunset declaration
When the template owner attempts to set locked to false without a sunset_date
Then the response is HTTP 422
And the error message contains "sunset_at required before lock release"
Scenario: Sunset date cannot be set in the past
When the template owner attempts to set sunset_date to yesterday
Then the response is HTTP 422
And the error message contains "sunset_at must be a future moment"
@@ -0,0 +1,8 @@
# Prevent the Cucumber engine from discovering feature files on its own.
# Features are provided only by IotTransitionCucumberTest 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 IotTransitionCucumberTest via Main.run(), which does
# not apply this JUnit Platform filter.
cucumber.filter.tags=@_disabled_standalone_run_