diff --git a/apix-admin/src/main/resources/templates/SandboxListResource/list.html b/apix-admin/src/main/resources/templates/SandboxListResource/list.html index a456fae..6e5e357 100644 --- a/apix-admin/src/main/resources/templates/SandboxListResource/list.html +++ b/apix-admin/src/main/resources/templates/SandboxListResource/list.html @@ -83,6 +83,7 @@ tr:hover td { background: #161b22; } Services Requests Location + Created Expires @@ -96,6 +97,7 @@ tr:hover td { background: #161b22; } {sb.serviceCount} {sb.totalRequests} {#if sb.location != null}{sb.location}{#else}{/if} + {sb.createdAt} {sb.expiresAt} {/for} @@ -119,6 +121,10 @@ document.querySelectorAll('[data-expires]').forEach(td => { const days = Math.round((d - now) / 86400000); 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); +}); diff --git a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java index b0dc7e2..dce39e2 100644 --- a/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java +++ b/apix-registry/src/integration-test/java/org/botstandards/apix/registry/bdd/SandboxSteps.java @@ -73,6 +73,7 @@ public class SandboxSteps { .statusCode(201); } + @Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}") public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability, String extension, String sandboxName) { @@ -254,6 +255,28 @@ public class SandboxSteps { .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 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}") public void getServicesWithCapabilityAndSandboxKey(String capability, String sandboxName) { String key = resolveKey(sandboxName); @@ -532,6 +555,58 @@ public class SandboxSteps { 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 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 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("the sandbox error contains {string}") diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-admin-list.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-admin-list.feature new file mode 100644 index 0000000..5f04fe0 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-admin-list.feature @@ -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 diff --git a/apix-registry/src/integration-test/resources/features/sandbox/sandbox-stage.feature b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-stage.feature new file mode 100644 index 0000000..002e7c8 --- /dev/null +++ b/apix-registry/src/integration-test/resources/features/sandbox/sandbox-stage.feature @@ -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 diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java index d2adf42..ee6cf9b 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java @@ -20,6 +20,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.HexFormat; @@ -199,7 +200,13 @@ public class SandboxService { 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; + 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.version = 1; @@ -479,20 +486,24 @@ public class SandboxService { Instant now = Instant.now(); List rows = ((List) listQ.getResultList()).stream() - .map(r -> new AdminSandboxSummary( - r[0].toString(), - (String) r[1], - (String) r[2], - ((Number) r[3]).intValue(), - r[4] != null ? ((Number) r[4]).intValue() : null, - r[5] instanceof java.sql.Timestamp t ? t.toInstant() : null, - r[6] instanceof java.sql.Timestamp t ? t.toInstant() : null, - r[6] instanceof java.sql.Timestamp t && now.isAfter(t.toInstant()), - (String) r[7], - r[8] != null ? ((Number) r[8]).doubleValue() : null, - r[9] != null ? ((Number) r[9]).doubleValue() : null, - ((Number) r[10]).longValue(), - ((Number) r[11]).longValue())) + .map(r -> { + Instant createdAt = toInstant(r[5]); + Instant expiresAt = toInstant(r[6]); + return new AdminSandboxSummary( + r[0].toString(), + (String) r[1], + (String) r[2], + ((Number) r[3]).intValue(), + r[4] != null ? ((Number) r[4]).intValue() : null, + createdAt, + expiresAt, + expiresAt != null && now.isAfter(expiresAt), + (String) r[7], + 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(); 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) {} + + /** + * 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; + } }