feat(outbound): Postgres repository for outbound_connections
- PostgresOutboundConnectionRepository: JdbcTemplate impl of OutboundConnectionRepository; UUID arrays via ConnectionCallback, JSONB for headers/auth/ca-paths, enum casts for method/trust/auth-kind - OutboundBeanConfig: wires the repo + SecretCipher beans - PostgresOutboundConnectionRepositoryIT: 5 Testcontainers tests (save+read, unique-name, allowed-env-ids round-trip, tenant isolation, delete); validates V11 Flyway migration end-to-end - application-test.yml: add jwtsecret default so SecretCipher bean starts up in the Spring test context Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
package com.cameleer.server.app.outbound.config;
|
||||||
|
|
||||||
|
import com.cameleer.server.app.outbound.crypto.SecretCipher;
|
||||||
|
import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OutboundBeanConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OutboundConnectionRepository outboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
|
||||||
|
return new PostgresOutboundConnectionRepository(jdbc, mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecretCipher secretCipher(
|
||||||
|
@Value("${cameleer.server.security.jwtsecret:dev-default-jwt-secret-do-not-use-in-production}")
|
||||||
|
String jwtSecret) {
|
||||||
|
return new SecretCipher(jwtSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.cameleer.server.app.outbound.storage;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.http.TrustMode;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundAuth;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundAuthKind;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundMethod;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.jdbc.core.ConnectionCallback;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
|
import java.sql.Array;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class PostgresOutboundConnectionRepository implements OutboundConnectionRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
public PostgresOutboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OutboundConnection save(OutboundConnection c) {
|
||||||
|
boolean exists = findById(c.tenantId(), c.id()).isPresent();
|
||||||
|
if (exists) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE outbound_connections
|
||||||
|
SET name = ?, description = ?, url = ?, method = ?::outbound_method_enum,
|
||||||
|
default_headers = ?::jsonb, default_body_tmpl = ?,
|
||||||
|
tls_trust_mode = ?::trust_mode_enum, tls_ca_pem_paths = ?::jsonb,
|
||||||
|
hmac_secret_ciphertext = ?, auth_kind = ?::outbound_auth_kind_enum,
|
||||||
|
auth_config = ?::jsonb, allowed_environment_ids = ?,
|
||||||
|
updated_at = now(), updated_by = ?
|
||||||
|
WHERE tenant_id = ? AND id = ?""",
|
||||||
|
c.name(), c.description(), c.url(), c.method().name(),
|
||||||
|
writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
|
||||||
|
c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
|
||||||
|
c.hmacSecretCiphertext(), c.auth().kind().name(),
|
||||||
|
writeJson(c.auth()), toUuidArray(c.allowedEnvironmentIds()),
|
||||||
|
c.updatedBy(), c.tenantId(), c.id());
|
||||||
|
} else {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO outbound_connections (
|
||||||
|
id, tenant_id, name, description, url, method,
|
||||||
|
default_headers, default_body_tmpl,
|
||||||
|
tls_trust_mode, tls_ca_pem_paths,
|
||||||
|
hmac_secret_ciphertext, auth_kind, auth_config,
|
||||||
|
allowed_environment_ids, created_by, updated_by)
|
||||||
|
VALUES (?,?,?,?,?,?::outbound_method_enum,
|
||||||
|
?::jsonb,?,
|
||||||
|
?::trust_mode_enum,?::jsonb,
|
||||||
|
?,?::outbound_auth_kind_enum,?::jsonb,
|
||||||
|
?,?,?)""",
|
||||||
|
c.id(), c.tenantId(), c.name(), c.description(), c.url(), c.method().name(),
|
||||||
|
writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
|
||||||
|
c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
|
||||||
|
c.hmacSecretCiphertext(), c.auth().kind().name(), writeJson(c.auth()),
|
||||||
|
toUuidArray(c.allowedEnvironmentIds()), c.createdBy(), c.updatedBy());
|
||||||
|
}
|
||||||
|
return findById(c.tenantId(), c.id()).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<OutboundConnection> findById(String tenantId, UUID id) {
|
||||||
|
return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND id = ?",
|
||||||
|
rowMapper, tenantId, id).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<OutboundConnection> findByName(String tenantId, String name) {
|
||||||
|
return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND name = ?",
|
||||||
|
rowMapper, tenantId, name).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<OutboundConnection> listByTenant(String tenantId) {
|
||||||
|
return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? ORDER BY name",
|
||||||
|
rowMapper, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String tenantId, UUID id) {
|
||||||
|
jdbc.update("DELETE FROM outbound_connections WHERE tenant_id = ? AND id = ?", tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final RowMapper<OutboundConnection> rowMapper = (rs, i) -> new OutboundConnection(
|
||||||
|
rs.getObject("id", UUID.class), rs.getString("tenant_id"),
|
||||||
|
rs.getString("name"), rs.getString("description"),
|
||||||
|
rs.getString("url"), OutboundMethod.valueOf(rs.getString("method")),
|
||||||
|
readMapString(rs.getString("default_headers")), rs.getString("default_body_tmpl"),
|
||||||
|
TrustMode.valueOf(rs.getString("tls_trust_mode")),
|
||||||
|
readListString(rs.getString("tls_ca_pem_paths")),
|
||||||
|
rs.getString("hmac_secret_ciphertext"),
|
||||||
|
readAuth(OutboundAuthKind.valueOf(rs.getString("auth_kind")), rs.getString("auth_config")),
|
||||||
|
readUuidArray(rs.getArray("allowed_environment_ids")),
|
||||||
|
rs.getTimestamp("created_at").toInstant(), rs.getString("created_by"),
|
||||||
|
rs.getTimestamp("updated_at").toInstant(), rs.getString("updated_by"));
|
||||||
|
|
||||||
|
private String writeJson(Object v) {
|
||||||
|
try { return mapper.writeValueAsString(v); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> readMapString(String json) {
|
||||||
|
try { return mapper.readValue(json, new TypeReference<>() {}); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> readListString(String json) {
|
||||||
|
try { return mapper.readValue(json, new TypeReference<>() {}); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private OutboundAuth readAuth(OutboundAuthKind kind, String cfg) {
|
||||||
|
try {
|
||||||
|
return switch (kind) {
|
||||||
|
case NONE -> new OutboundAuth.None();
|
||||||
|
case BEARER -> mapper.readValue(cfg, OutboundAuth.Bearer.class);
|
||||||
|
case BASIC -> mapper.readValue(cfg, OutboundAuth.Basic.class);
|
||||||
|
};
|
||||||
|
} catch (Exception e) { throw new IllegalStateException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Array toUuidArray(List<UUID> ids) {
|
||||||
|
return jdbc.execute((ConnectionCallback<Array>) conn ->
|
||||||
|
conn.createArrayOf("uuid", ids.toArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<UUID> readUuidArray(Array arr) throws SQLException {
|
||||||
|
if (arr == null) return List.of();
|
||||||
|
Object[] raw = (Object[]) arr.getArray();
|
||||||
|
List<UUID> out = new ArrayList<>(raw.length);
|
||||||
|
for (Object o : raw) out.add((UUID) o);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.cameleer.server.app.outbound.storage;
|
||||||
|
|
||||||
|
import com.cameleer.server.app.AbstractPostgresIT;
|
||||||
|
import com.cameleer.server.core.http.TrustMode;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundAuth;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
|
||||||
|
import com.cameleer.server.core.outbound.OutboundMethod;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class PostgresOutboundConnectionRepositoryIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
OutboundConnectionRepository repo;
|
||||||
|
|
||||||
|
private static final String TENANT = "default";
|
||||||
|
private static final String USER = "test-alice";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seedUser() {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, ?, ?, ?) ON CONFLICT (user_id) DO NOTHING",
|
||||||
|
USER, "test", "alice@example.com", "Alice");
|
||||||
|
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = ?", TENANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OutboundConnection draft(String name) {
|
||||||
|
return new OutboundConnection(
|
||||||
|
UUID.randomUUID(), TENANT, name, "desc",
|
||||||
|
"https://hooks.slack.com/services/T/B/X", OutboundMethod.POST,
|
||||||
|
Map.of("Content-Type", "application/json"), null,
|
||||||
|
TrustMode.SYSTEM_DEFAULT, List.of(),
|
||||||
|
null, new OutboundAuth.None(), List.of(),
|
||||||
|
Instant.now(), USER, Instant.now(), USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveAndRead() {
|
||||||
|
OutboundConnection c = draft("slack-ops");
|
||||||
|
repo.save(c);
|
||||||
|
OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow();
|
||||||
|
assertThat(loaded.name()).isEqualTo("slack-ops");
|
||||||
|
assertThat(loaded.defaultHeaders()).containsEntry("Content-Type", "application/json");
|
||||||
|
assertThat(loaded.method()).isEqualTo(OutboundMethod.POST);
|
||||||
|
assertThat(loaded.tlsTrustMode()).isEqualTo(TrustMode.SYSTEM_DEFAULT);
|
||||||
|
assertThat(loaded.auth()).isInstanceOf(OutboundAuth.None.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uniqueNamePerTenant() {
|
||||||
|
OutboundConnection a = draft("slack-ops");
|
||||||
|
repo.save(a);
|
||||||
|
OutboundConnection b = draft("slack-ops");
|
||||||
|
assertThatThrownBy(() -> repo.save(b))
|
||||||
|
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowedEnvironmentIdsRoundTrip() {
|
||||||
|
UUID env1 = UUID.randomUUID();
|
||||||
|
UUID env2 = UUID.randomUUID();
|
||||||
|
OutboundConnection c = new OutboundConnection(
|
||||||
|
UUID.randomUUID(), TENANT, "multi-env", null,
|
||||||
|
"https://example.com", OutboundMethod.POST,
|
||||||
|
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
|
||||||
|
null, new OutboundAuth.None(), List.of(env1, env2),
|
||||||
|
Instant.now(), USER, Instant.now(), USER);
|
||||||
|
repo.save(c);
|
||||||
|
OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow();
|
||||||
|
assertThat(loaded.allowedEnvironmentIds()).containsExactly(env1, env2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listByTenantOnlyReturnsCurrentTenant() {
|
||||||
|
repo.save(draft("in-tenant"));
|
||||||
|
// The outbound_connections.tenant_id is a plain varchar, so we can insert for a different tenant
|
||||||
|
// without schema changes. Insert another tenant via the repo directly:
|
||||||
|
UUID otherId = UUID.randomUUID();
|
||||||
|
OutboundConnection other = new OutboundConnection(
|
||||||
|
otherId, "other-tenant", "other-tenant-conn", null,
|
||||||
|
"https://example.com", OutboundMethod.POST,
|
||||||
|
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
|
||||||
|
null, new OutboundAuth.None(), List.of(),
|
||||||
|
Instant.now(), USER, Instant.now(), USER);
|
||||||
|
repo.save(other);
|
||||||
|
try {
|
||||||
|
List<OutboundConnection> list = repo.listByTenant(TENANT);
|
||||||
|
assertThat(list).extracting(OutboundConnection::name).containsExactly("in-tenant");
|
||||||
|
} finally {
|
||||||
|
repo.delete("other-tenant", otherId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRemovesRow() {
|
||||||
|
OutboundConnection c = draft("to-delete");
|
||||||
|
repo.save(c);
|
||||||
|
repo.delete(TENANT, c.id());
|
||||||
|
assertThat(repo.findById(TENANT, c.id())).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,3 +16,4 @@ cameleer:
|
|||||||
bootstraptoken: test-bootstrap-token
|
bootstraptoken: test-bootstrap-token
|
||||||
bootstraptokenprevious: old-bootstrap-token
|
bootstraptokenprevious: old-bootstrap-token
|
||||||
infrastructureendpoints: true
|
infrastructureendpoints: true
|
||||||
|
jwtsecret: test-jwt-secret-for-integration-tests-only
|
||||||
|
|||||||
Reference in New Issue
Block a user