feat(registry): service stage semantics + sandbox admin list improvements
Deploy to Production / deploy (push) Successful in 3m16s
Deploy to Production / deploy (push) Successful in 3m16s
- Sandbox search without ?stage= defaults to DEVELOPMENT (was PRODUCTION), so freshly registered services are discoverable by default - DEPRECATED and DECOMMISSIONED rejected at creation with 400 (lifecycle-only) - PRODUCTION accepted at creation for established services going live immediately - Admin sandbox list now returns non-null createdAt and expiresAt (toInstant fix for Hibernate 6 returning LocalDateTime from timestamptz native queries) - Admin UI sandbox list shows Created column - BDD: sandbox-admin-list.feature + sandbox-stage.feature (47 scenarios, all green) Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,7 @@ tr:hover td { background: #161b22; }
|
|||||||
<th>Services</th>
|
<th>Services</th>
|
||||||
<th>Requests</th>
|
<th>Requests</th>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
|
<th>Created</th>
|
||||||
<th>Expires</th>
|
<th>Expires</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -96,6 +97,7 @@ tr:hover td { background: #161b22; }
|
|||||||
<td>{sb.serviceCount}</td>
|
<td>{sb.serviceCount}</td>
|
||||||
<td>{sb.totalRequests}</td>
|
<td>{sb.totalRequests}</td>
|
||||||
<td>{#if sb.location != null}{sb.location}{#else}<span style="color:#484f58">—</span>{/if}</td>
|
<td>{#if sb.location != null}{sb.location}{#else}<span style="color:#484f58">—</span>{/if}</td>
|
||||||
|
<td data-created="{sb.createdAt}" style="color:#8b949e">{sb.createdAt}</td>
|
||||||
<td class="{#if sb.expired}expired{#else}ok{/if}" data-expires="{sb.expiresAt}">{sb.expiresAt}</td>
|
<td class="{#if sb.expired}expired{#else}ok{/if}" data-expires="{sb.expiresAt}">{sb.expiresAt}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/for}
|
{/for}
|
||||||
@@ -119,6 +121,10 @@ document.querySelectorAll('[data-expires]').forEach(td => {
|
|||||||
const days = Math.round((d - now) / 86400000);
|
const days = Math.round((d - now) / 86400000);
|
||||||
td.textContent = days < 0 ? ('expired ' + (-days) + 'd ago') : ('in ' + days + 'd');
|
td.textContent = days < 0 ? ('expired ' + (-days) + 'd ago') : ('in ' + days + 'd');
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-created]').forEach(td => {
|
||||||
|
const d = new Date(td.dataset.created);
|
||||||
|
td.textContent = d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+75
@@ -73,6 +73,7 @@ public class SandboxSteps {
|
|||||||
.statusCode(201);
|
.statusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
|
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
|
||||||
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
|
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
|
||||||
String extension, String sandboxName) {
|
String extension, String sandboxName) {
|
||||||
@@ -254,6 +255,28 @@ public class SandboxSteps {
|
|||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@When("sandbox {string} services are searched by capability {string} and stage {string}")
|
||||||
|
public void sandboxServiceSearchCalledWithStage(String sandboxName, String capability, String stage) {
|
||||||
|
lastResponse = given()
|
||||||
|
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&stage=" + stage)
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("a sandbox service with stage {string} and endpoint {string} and capability {string} is registered in {string}")
|
||||||
|
public void whenASandboxServiceWithStageIsRegistered(String stage, String endpoint, String capability, String sandboxName) {
|
||||||
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
|
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
|
||||||
|
payload.put("serviceStage", stage);
|
||||||
|
lastResponse = given()
|
||||||
|
.contentType(JSON)
|
||||||
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
|
.body(payload)
|
||||||
|
.when()
|
||||||
|
.post("/sandbox/" + uuid + "/services")
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
@When("GET \\/services is called with capability {string} and the sandbox key for {string}")
|
@When("GET \\/services is called with capability {string} and the sandbox key for {string}")
|
||||||
public void getServicesWithCapabilityAndSandboxKey(String capability, String sandboxName) {
|
public void getServicesWithCapabilityAndSandboxKey(String capability, String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
@@ -532,6 +555,58 @@ public class SandboxSteps {
|
|||||||
assertThat(actual).as("submissionsByProvider." + provider).isEqualTo(count);
|
assertThat(actual).as("submissionsByProvider." + provider).isEqualTo(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── When — admin operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@When("the admin requests the sandbox list")
|
||||||
|
public void adminRequestsSandboxList() {
|
||||||
|
lastResponse = given()
|
||||||
|
.header("X-Admin-Key", ADMIN_API_KEY)
|
||||||
|
.accept("application/json")
|
||||||
|
.when()
|
||||||
|
.get("/admin/sandboxes")
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("the admin requests the sandbox list without a key")
|
||||||
|
public void adminRequestsSandboxListWithoutKey() {
|
||||||
|
lastResponse = given()
|
||||||
|
.accept("application/json")
|
||||||
|
.when()
|
||||||
|
.get("/admin/sandboxes")
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("the admin deletes sandbox {string}")
|
||||||
|
public void adminDeletesSandbox(String sandboxName) {
|
||||||
|
lastResponse = given()
|
||||||
|
.header("X-Admin-Key", ADMIN_API_KEY)
|
||||||
|
.when()
|
||||||
|
.delete("/admin/sandboxes/" + resolveUuid(sandboxName))
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Then — admin list assertions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Then("the admin list total is at least {int}")
|
||||||
|
public void adminListTotalAtLeast(int min) {
|
||||||
|
int total = lastResponse.jsonPath().getInt("total");
|
||||||
|
assertThat(total).as("admin list total").isGreaterThanOrEqualTo(min);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Then("every entry in the admin list has a non-null createdAt")
|
||||||
|
public void everyEntryHasCreatedAt() {
|
||||||
|
List<Object> values = lastResponse.jsonPath().getList("sandboxes.createdAt");
|
||||||
|
assertThat(values).as("sandboxes.createdAt list").isNotEmpty();
|
||||||
|
assertThat(values).as("no null createdAt entries").doesNotContainNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Then("every entry in the admin list has a non-null expiresAt")
|
||||||
|
public void everyEntryHasExpiresAt() {
|
||||||
|
List<String> values = lastResponse.jsonPath().getList("sandboxes.expiresAt");
|
||||||
|
assertThat(values).as("sandboxes.expiresAt list").isNotEmpty();
|
||||||
|
assertThat(values).as("no null expiresAt entries").doesNotContainNull();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Then — error assertions ───────────────────────────────────────────────
|
// ── Then — error assertions ───────────────────────────────────────────────
|
||||||
|
|
||||||
@Then("the sandbox error contains {string}")
|
@Then("the sandbox error contains {string}")
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
Feature: Admin sandbox list endpoint
|
||||||
|
|
||||||
|
Scenario: Admin retrieves the sandbox list and each entry includes a createdAt timestamp
|
||||||
|
Given a sandbox named "admin-list-test" exists
|
||||||
|
When the admin requests the sandbox list
|
||||||
|
Then the response code is 200
|
||||||
|
And the admin list total is at least 1
|
||||||
|
And every entry in the admin list has a non-null createdAt
|
||||||
|
And every entry in the admin list has a non-null expiresAt
|
||||||
|
|
||||||
|
Scenario: Admin sandbox list request without credentials is rejected
|
||||||
|
When the admin requests the sandbox list without a key
|
||||||
|
Then the response code is 401
|
||||||
|
|
||||||
|
Scenario: Admin can delete a sandbox and it no longer appears
|
||||||
|
Given a sandbox named "admin-delete-target" exists
|
||||||
|
When the admin deletes sandbox "admin-delete-target"
|
||||||
|
Then the response code is 204
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
Feature: Service stage semantics on creation and sandbox search
|
||||||
|
|
||||||
|
# Creation rules:
|
||||||
|
# DEVELOPMENT → allowed (default when omitted)
|
||||||
|
# PRODUCTION → allowed (established services going live immediately)
|
||||||
|
# DEPRECATED / DECOMMISSIONED → rejected (lifecycle-only stages, not creation stages)
|
||||||
|
#
|
||||||
|
# Sandbox search default:
|
||||||
|
# no ?stage= param → DEVELOPMENT (find services you just registered)
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given a sandbox named "stage-test" exists
|
||||||
|
|
||||||
|
Scenario: Service created without stage defaults to DEVELOPMENT
|
||||||
|
Given a sandbox service with endpoint "https://dev-default.example.com" and capability "stage.test" is registered in "stage-test"
|
||||||
|
When sandbox "stage-test" services are searched by capability "stage.test"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://dev-default.example.com" is in the endpoint list
|
||||||
|
|
||||||
|
Scenario: Service created with explicit DEVELOPMENT stage is found by default sandbox search
|
||||||
|
Given a sandbox service with stage "DEVELOPMENT" and endpoint "https://dev-explicit.example.com" and capability "stage.test" is registered in "stage-test"
|
||||||
|
When sandbox "stage-test" services are searched by capability "stage.test"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://dev-explicit.example.com" is in the endpoint list
|
||||||
|
|
||||||
|
Scenario: Service created with PRODUCTION stage is not returned by default sandbox search
|
||||||
|
Given a sandbox service with stage "PRODUCTION" and endpoint "https://prod-explicit.example.com" and capability "stage.test" is registered in "stage-test"
|
||||||
|
When sandbox "stage-test" services are searched by capability "stage.test"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://prod-explicit.example.com" is not in the endpoint list
|
||||||
|
|
||||||
|
Scenario: Service created with PRODUCTION stage is found when searching with stage=PRODUCTION
|
||||||
|
Given a sandbox service with stage "PRODUCTION" and endpoint "https://prod-search.example.com" and capability "stage.test" is registered in "stage-test"
|
||||||
|
When sandbox "stage-test" services are searched by capability "stage.test" and stage "PRODUCTION"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://prod-search.example.com" is in the endpoint list
|
||||||
|
|
||||||
|
Scenario: Creating a service with DEPRECATED stage is rejected
|
||||||
|
When a sandbox service with stage "DEPRECATED" and endpoint "https://dep.example.com" and capability "stage.test" is registered in "stage-test"
|
||||||
|
Then the response code is 400
|
||||||
|
|
||||||
|
Scenario: Creating a service with DECOMMISSIONED stage is rejected
|
||||||
|
When a sandbox service with stage "DECOMMISSIONED" and endpoint "https://dec.example.com" and capability "stage.test" is registered in "stage-test"
|
||||||
|
Then the response code is 400
|
||||||
+55
-15
@@ -20,6 +20,7 @@ import java.security.MessageDigest;
|
|||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
@@ -199,7 +200,13 @@ public class SandboxService {
|
|||||||
service.livenessStatus = LivenessStatus.PENDING;
|
service.livenessStatus = LivenessStatus.PENDING;
|
||||||
service.registeredAt = now;
|
service.registeredAt = now;
|
||||||
service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL;
|
service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL;
|
||||||
service.serviceStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
|
ServiceStage requestedStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
|
||||||
|
if (requestedStage == ServiceStage.DEPRECATED || requestedStage == ServiceStage.DECOMMISSIONED) {
|
||||||
|
throw new WebApplicationException(Response.status(400)
|
||||||
|
.entity(Map.of("message", "Stage " + requestedStage + " is a lifecycle-only stage and cannot be set at creation"))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
service.serviceStage = requestedStage;
|
||||||
service.registryStatus = RegistryStatus.ACTIVE;
|
service.registryStatus = RegistryStatus.ACTIVE;
|
||||||
service.version = 1;
|
service.version = 1;
|
||||||
|
|
||||||
@@ -479,20 +486,24 @@ public class SandboxService {
|
|||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
|
|
||||||
List<AdminSandboxSummary> rows = ((List<Object[]>) listQ.getResultList()).stream()
|
List<AdminSandboxSummary> rows = ((List<Object[]>) listQ.getResultList()).stream()
|
||||||
.map(r -> new AdminSandboxSummary(
|
.map(r -> {
|
||||||
r[0].toString(),
|
Instant createdAt = toInstant(r[5]);
|
||||||
(String) r[1],
|
Instant expiresAt = toInstant(r[6]);
|
||||||
(String) r[2],
|
return new AdminSandboxSummary(
|
||||||
((Number) r[3]).intValue(),
|
r[0].toString(),
|
||||||
r[4] != null ? ((Number) r[4]).intValue() : null,
|
(String) r[1],
|
||||||
r[5] instanceof java.sql.Timestamp t ? t.toInstant() : null,
|
(String) r[2],
|
||||||
r[6] instanceof java.sql.Timestamp t ? t.toInstant() : null,
|
((Number) r[3]).intValue(),
|
||||||
r[6] instanceof java.sql.Timestamp t && now.isAfter(t.toInstant()),
|
r[4] != null ? ((Number) r[4]).intValue() : null,
|
||||||
(String) r[7],
|
createdAt,
|
||||||
r[8] != null ? ((Number) r[8]).doubleValue() : null,
|
expiresAt,
|
||||||
r[9] != null ? ((Number) r[9]).doubleValue() : null,
|
expiresAt != null && now.isAfter(expiresAt),
|
||||||
((Number) r[10]).longValue(),
|
(String) r[7],
|
||||||
((Number) r[11]).longValue()))
|
r[8] != null ? ((Number) r[8]).doubleValue() : null,
|
||||||
|
r[9] != null ? ((Number) r[9]).doubleValue() : null,
|
||||||
|
((Number) r[10]).longValue(),
|
||||||
|
((Number) r[11]).longValue());
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return new AdminSandboxListResponse(total, page, size, rows);
|
return new AdminSandboxListResponse(total, page, size, rows);
|
||||||
@@ -659,5 +670,34 @@ public class SandboxService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deletes a sandbox and all its registered services. DEMO tier sandboxes are protected. */
|
||||||
|
@Transactional
|
||||||
|
public void deleteSandbox(UUID id) {
|
||||||
|
SandboxEntity sb = requireById(id);
|
||||||
|
if ("DEMO".equals(sb.tier)) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
Response.status(403).entity(Map.of("message", "DEMO-tier sandboxes are permanent and cannot be deleted")).build());
|
||||||
|
}
|
||||||
|
em.createQuery("DELETE FROM ServiceEntity s WHERE s.sandboxId = :sid")
|
||||||
|
.setParameter("sid", id.toString())
|
||||||
|
.executeUpdate();
|
||||||
|
em.remove(em.contains(sb) ? sb : em.merge(sb));
|
||||||
|
}
|
||||||
|
|
||||||
public record SandboxCreationResult(SandboxEntity sandbox, String plainKey, String plainMaintenanceKey) {}
|
public record SandboxCreationResult(SandboxEntity sandbox, String plainKey, String plainMaintenanceKey) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a native-query timestamp column to Instant.
|
||||||
|
* Hibernate 6 returns LocalDateTime for timestamptz native queries; older drivers return Timestamp.
|
||||||
|
* LocalDateTime from a timestamptz column represents UTC.
|
||||||
|
*/
|
||||||
|
private static Instant toInstant(Object col) {
|
||||||
|
if (col instanceof java.sql.Timestamp t) return t.toInstant();
|
||||||
|
if (col instanceof OffsetDateTime odt) return odt.toInstant();
|
||||||
|
if (col instanceof java.time.ZonedDateTime z) return z.toInstant();
|
||||||
|
if (col instanceof java.time.LocalDateTime ldt) return ldt.toInstant(java.time.ZoneOffset.UTC);
|
||||||
|
if (col instanceof java.util.Date d) return d.toInstant();
|
||||||
|
if (col instanceof java.time.temporal.Temporal t) { try { return Instant.from(t); } catch (Exception ignored) {} }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user