test+fix: admin sandbox list returns non-null createdAt (TDD)

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 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-18 21:49:10 +02:00
parent bef9494622
commit 0b519a003f
3 changed files with 104 additions and 14 deletions
@@ -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<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("the sandbox error contains {string}")
@@ -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
@@ -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<AdminSandboxSummary> rows = ((List<Object[]>) listQ.getResultList()).stream()
.map(r -> new AdminSandboxSummary(
.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,
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()),
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()))
((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;
}
}