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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user