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:
@@ -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>
|
||||
+25
@@ -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
|
||||
) {}
|
||||
}
|
||||
+34
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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;
|
||||
}
|
||||
}
|
||||
+340
@@ -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>
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package org.botstandards.apix.registry.bdd;
|
||||
|
||||
import io.cucumber.core.cli.Main;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Runs all IoT transition BDD scenarios inside the Quarkus test context.
|
||||
*
|
||||
* @QuarkusTest starts the server (DevServices PostgreSQL, port 8181).
|
||||
* Main.run() invokes the Cucumber runtime directly — this bypasses the JUnit Platform
|
||||
* Cucumber engine, so junit-platform.properties tag filters do not apply here.
|
||||
*/
|
||||
@QuarkusTest
|
||||
public class 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");
|
||||
}
|
||||
}
|
||||
+786
@@ -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.
|
||||
+193
@@ -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
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
Feature: Replacement provider declares compatibility
|
||||
As a replacement service provider
|
||||
I want to declare that my service covers a deprecated template
|
||||
So that IoT device owners can discover me as a migration target
|
||||
|
||||
Background:
|
||||
Given a deprecated service "SmartHub Cloud" with locked set to false
|
||||
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
|
||||
|
||||
Scenario: Replacement provider declares compatibility with a deprecated service
|
||||
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
|
||||
Then GET /services/{smartHubCloudId}/replacements includes "OpenHub"
|
||||
And a version history entry of type "REPLACEMENT_DECLARED" exists for "SmartHub Cloud"
|
||||
And the service_replacements table contains the declared pair
|
||||
|
||||
Scenario: Declaration against a non-deprecated service is rejected
|
||||
Given a service "ActiveCloud" in stage "PRODUCTION" with locked set to true
|
||||
When "OpenHub" declares replacesServiceIds containing the ID of "ActiveCloud"
|
||||
Then the response is HTTP 422
|
||||
And the error message contains "target service is not deprecated"
|
||||
|
||||
Scenario: Declaration against a locked deprecated service is rejected
|
||||
Given a service "LockedCloud" in stage "DEPRECATED" with locked set to true
|
||||
When "OpenHub" declares replacesServiceIds containing the ID of "LockedCloud"
|
||||
Then the response is HTTP 422
|
||||
And the error message contains "target service lock has not been released"
|
||||
|
||||
Scenario: Multiple replacement providers for the same deprecated service
|
||||
Given a service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
|
||||
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
|
||||
And "CloudBridge" declares replacesServiceIds containing the ID of "SmartHub Cloud"
|
||||
Then GET /services/{smartHubCloudId}/replacements returns 2 candidates
|
||||
And "CloudBridge" appears before "OpenHub" in the results
|
||||
And the ordering is by O-level descending
|
||||
|
||||
Scenario: Replacement provider retracts their compatibility declaration
|
||||
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
|
||||
When "OpenHub" removes "SmartHub Cloud" from its replacesServiceIds
|
||||
Then GET /services/{smartHubCloudId}/replacements no longer includes "OpenHub"
|
||||
And the service_replacements row for this pair is deleted
|
||||
|
||||
Scenario: Duplicate declaration is idempotent
|
||||
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
|
||||
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud" again
|
||||
Then the response is HTTP 200
|
||||
And GET /services/{smartHubCloudId}/replacements still returns exactly 1 candidate
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
Feature: Device owner discovers replacement services
|
||||
As an IoT device owner
|
||||
I want to discover compatible replacement services by polling APIX
|
||||
Without revealing my identity or device details
|
||||
|
||||
Background:
|
||||
Given a deprecated service "SmartHub Cloud" with locked set to false and a sunset_date set
|
||||
And "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED" has declared compatibility
|
||||
And "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED" has declared compatibility
|
||||
|
||||
Scenario: Device polls service status without authentication
|
||||
When GET /services/{smartHubCloudId} is called with no authentication header
|
||||
Then the response is HTTP 200
|
||||
And the response body contains service_stage "DEPRECATED"
|
||||
And the response body contains locked false
|
||||
And the response body contains a sunset_date
|
||||
|
||||
Scenario: Device discovers replacement candidates without authentication
|
||||
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
|
||||
Then the response is HTTP 200
|
||||
And the response contains 2 candidates
|
||||
And "CloudBridge" appears before "OpenHub" in the results
|
||||
|
||||
Scenario: Device filters candidates by minimum O-level
|
||||
When GET /services/{smartHubCloudId}/replacements?minOLevel=LEGAL_ENTITY_VERIFIED is called with no authentication header
|
||||
Then the response is HTTP 200
|
||||
And the response contains 1 candidate
|
||||
And the candidate is "CloudBridge"
|
||||
|
||||
Scenario: Device polls a still-locked deprecated service
|
||||
Given a deprecated service "LockedCloud" with locked set to true
|
||||
When GET /services/{lockedCloudId}/replacements is called with no authentication header
|
||||
Then the response is HTTP 200
|
||||
And the response body contains an empty candidates list
|
||||
And the response body contains locked true
|
||||
|
||||
Scenario: Default production search excludes deprecated services
|
||||
When GET /services?capability=device.telemetry is called with no authentication header
|
||||
Then "SmartHub Cloud" is not in the results
|
||||
And only services with stage "PRODUCTION" are returned
|
||||
|
||||
Scenario: Explicit deprecated filter includes deprecated services
|
||||
When GET /services?capability=device.telemetry&stage=deprecated is called
|
||||
Then "SmartHub Cloud" is in the results
|
||||
And each result has service_stage "DEPRECATED"
|
||||
@@ -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_
|
||||
Reference in New Issue
Block a user