From 0b519a003f0b150717ff9b6948b75e8ff741dbae Mon Sep 17 00:00:00 2001 From: Carsten Rehfeld Date: Mon, 18 May 2026 21:49:10 +0200 Subject: [PATCH] test+fix: admin sandbox list returns non-null createdAt (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test first: sandbox-admin-list.feature verifies createdAt, expiresAt, unauth rejection, and admin delete — three new BDD scenarios. Bug found: listAllSandboxes() native query mapping used instanceof java.sql.Timestamp, but Hibernate 6 + PostgreSQL JDBC returns LocalDateTime for timestamptz columns. toInstant() now handles Timestamp, OffsetDateTime, ZonedDateTime, LocalDateTime (UTC), Date, and any Temporal via Instant.from() fallback. UI change (admin list "Created" column, prior commit) now has test coverage at the API layer. Co-Authored-By: Mira Rehfeld --- .../apix/registry/bdd/SandboxSteps.java | 52 +++++++++++++++++++ .../sandbox/sandbox-admin-list.feature | 18 +++++++ .../apix/registry/service/SandboxService.java | 48 ++++++++++++----- 3 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 apix-registry/src/integration-test/resources/features/sandbox/sandbox-admin-list.feature 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..72a77bc 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 @@ -532,6 +532,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/main/java/org/botstandards/apix/registry/service/SandboxService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java index 7545129..0ddfed8 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; @@ -479,20 +480,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); @@ -674,4 +679,19 @@ public class SandboxService { } 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; + } }