feat(outbound): admin CRUD REST + RBAC + audit

New audit categories: OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE.
Controller-level @PreAuthorize defaults to ADMIN; GETs relaxed to ADMIN|OPERATOR.
SecurityConfig permits OPERATOR GETs on /api/v1/admin/outbound-connections/**.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 16:43:48 +02:00
parent a3c35c7df9
commit ea4c56e7f6
4 changed files with 258 additions and 2 deletions

View File

@@ -0,0 +1,135 @@
package com.cameleer.server.app.outbound.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
private String adminJwt;
private String operatorJwt;
private String viewerJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
operatorJwt = securityHelper.operatorToken();
viewerJwt = securityHelper.viewerToken();
// Seed user rows matching the JWT subjects (users(user_id) is a FK target)
seedUser("test-admin");
seedUser("test-operator");
seedUser("test-viewer");
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
}
private void seedUser(String userId) {
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
userId, userId + "@example.com", userId);
}
private static final String CREATE_BODY = """
{"name":"slack-ops","url":"https://hooks.slack.com/x","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
@Test
void adminCanCreate() throws Exception {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode body = objectMapper.readTree(resp.getBody());
assertThat(body.path("name").asText()).isEqualTo("slack-ops");
assertThat(body.path("hmacSecretSet").asBoolean()).isFalse();
assertThat(body.path("id").asText()).isNotBlank();
}
@Test
void operatorCannotCreate() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void operatorCanList() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void viewerCannotList() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void nonHttpsUrlRejected() {
String body = """
{"name":"bad","url":"http://x","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void duplicateNameReturns409() {
restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
ResponseEntity<String> dup = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(dup.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
@Test
void deleteRemoves() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
ResponseEntity<String> del = restTemplate.exchange(
"/api/v1/admin/outbound-connections/" + id, HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
ResponseEntity<String> get = restTemplate.exchange(
"/api/v1/admin/outbound-connections/" + id, HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}