feat!: environment admin URLs use slug; validate and immutabilize slug
UUID-based admin paths were the only remaining UUID-in-URL pattern in
the API. Migrates /api/v1/admin/environments/{id} to /{envSlug} so
slugs are the single environment identifier in every URL. UUIDs stay
internal to the database.
- Controller: @PathVariable UUID id → @PathVariable String envSlug on
get/update/delete and the two nested endpoints (default-container-
config, jar-retention). Handlers resolve slug → Environment via
EnvironmentService.getBySlug, then delegate to existing UUID-based
service methods.
- Service: create() now validates slug against ^[a-z0-9][a-z0-9-]{0,63}$
and returns 400 on invalid slugs. Rationale documented in the class:
slugs are immutable after creation because they appear in URLs,
Docker network names, container names, and ClickHouse partition keys.
- UpdateEnvironmentRequest has no slug field and Jackson's default
ignore-unknown behavior drops any slug supplied in a PUT body;
regression test (updateEnvironment_withSlugInBody_ignoresSlug)
documents this invariant.
- SPA: mutation args change from { id } to { slug }. EnvironmentsPage
still uses env.id for local selection state (UUID from DB) but
passes env.slug to every mutation.
BREAKING CHANGE: /api/v1/admin/environments/{id:UUID}/... paths removed.
Clients must use /{envSlug}/... (slug from the environments list).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,29 +97,65 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
||||
String createJson = """
|
||||
{"slug": "update-test", "displayName": "Before", "production": false}
|
||||
""";
|
||||
ResponseEntity<String> createResponse = restTemplate.exchange(
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
JsonNode created = objectMapper.readTree(createResponse.getBody());
|
||||
String envId = created.path("id").asText();
|
||||
|
||||
// Update it
|
||||
// Update it by slug
|
||||
String updateJson = """
|
||||
{"displayName": "After", "production": true, "enabled": false}
|
||||
""";
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments/" + envId, HttpMethod.PUT,
|
||||
"/api/v1/admin/environments/update-test", HttpMethod.PUT,
|
||||
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("slug").asText()).isEqualTo("update-test");
|
||||
assertThat(body.path("displayName").asText()).isEqualTo("After");
|
||||
assertThat(body.path("production").asBoolean()).isTrue();
|
||||
assertThat(body.path("enabled").asBoolean()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateEnvironment_withSlugInBody_ignoresSlug() throws Exception {
|
||||
String createJson = """
|
||||
{"slug": "slug-immutable-test", "displayName": "Original", "production": false}
|
||||
""";
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
// Attempt to change slug via body — Jackson drops the unknown field
|
||||
String updateJson = """
|
||||
{"slug": "hacked", "displayName": "Renamed", "production": false, "enabled": true}
|
||||
""";
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments/slug-immutable-test", HttpMethod.PUT,
|
||||
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("slug").asText()).isEqualTo("slug-immutable-test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_invalidSlug_returns400() {
|
||||
String json = """
|
||||
{"slug": "Invalid Slug!", "displayName": "Bad"}
|
||||
""";
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_duplicateSlug_returns400() {
|
||||
String json = """
|
||||
@@ -142,25 +178,9 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteEnvironment_defaultEnv_returns400() throws Exception {
|
||||
// Find the default environment ID
|
||||
ResponseEntity<String> listResponse = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
JsonNode envs = objectMapper.readTree(listResponse.getBody());
|
||||
String defaultId = null;
|
||||
for (JsonNode env : envs) {
|
||||
if ("default".equals(env.path("slug").asText())) {
|
||||
defaultId = env.path("id").asText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(defaultId).isNotNull();
|
||||
|
||||
void deleteEnvironment_defaultEnv_returns400() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments/" + defaultId, HttpMethod.DELETE,
|
||||
"/api/v1/admin/environments/default", HttpMethod.DELETE,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user