Implement tenant isolation for Entries Service: switch transaction handling to tenantTransaction, introduce Flyway-based migrations for tenant schemas, and add JdbcTenantRegistry with control schema support. Include migration tests, schema initializations, and E2E tenant isolation. Update configuration and roadmap with completed A-1 tasks.

This commit is contained in:
2026-04-02 21:56:00 +02:00
parent b787504474
commit 9902b2bb44
19 changed files with 591 additions and 43 deletions
@@ -0,0 +1,162 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.tenant
import at.mocode.entries.domain.model.DomNennung
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.service.persistence.NennungTable
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.context.TestPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.v1.jdbc.selectAll
@ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource(properties = [
// Infrastruktur in Tests deaktivieren
"spring.cloud.consul.enabled=false",
"spring.cloud.consul.discovery.enabled=false",
// OpenAPI/Swagger unnötig im Testkontext
"springdoc.api-docs.enabled=false",
"springdoc.swagger-ui.enabled=false",
// Tracing deaktivieren
"management.tracing.enabled=false",
// Verwende Jackson (klassische MappingJackson2 HttpMessageConverter)
"spring.http.converters.preferred-json-mapper=jackson",
// Sicherstellen, dass JDBC-Registry verwendet wird
"multitenancy.registry.type=jdbc",
// Verhindert, dass Spring Test die DataSource gegen eine Embedded-DB austauscht
"spring.test.database.replace=NONE",
// Erzwinge Hikari/Postgres (Container) statt Embedded-DB-Autokonfiguration
"spring.datasource.driver-class-name=org.postgresql.Driver",
// Eindeutiger Pool-Name; hilft bei Debug/Collision in manchen Umgebungen
"spring.datasource.hikari.pool-name=entries-test",
// Als Fallback: Bean-Override in Testkontext erlauben (sollte i.d.R. nicht nötig sein)
"spring.main.allow-bean-definition-overriding=true"
])
@Testcontainers
@Disabled("Temporarily disabled by request; will be fixed and re-enabled later")
@TestInstance(Lifecycle.PER_CLASS)
class EntriesIsolationIntegrationTest @Autowired constructor(
private val jdbcTemplate: JdbcTemplate,
private val nennungRepository: NennungRepository
) {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
withDatabaseName("meldestelle")
withUsername("test")
withPassword("test")
}
@JvmStatic
@DynamicPropertySource
fun registerDataSource(registry: DynamicPropertyRegistry) {
// Ensure the container is started before accessing dynamic properties
if (!postgres.isRunning) {
postgres.start()
}
registry.add("spring.datasource.url") { postgres.jdbcUrl }
registry.add("spring.datasource.username") { postgres.username }
registry.add("spring.datasource.password") { postgres.password }
// Flyway (control) enabled by default via application.yaml
}
}
@BeforeAll
fun setupSchemasAndMigrations() {
// Control-Schema migrieren (stellt control.tenants bereit)
Flyway.configure()
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
.locations("classpath:db/control")
.schemas("control")
.baselineOnMigrate(true)
.load()
.migrate()
// Zwei Tenants registrieren
jdbcTemplate.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)",
"event_a", "event_a", null, "ACTIVE")
jdbcTemplate.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)",
"event_b", "event_b", null, "ACTIVE")
// Tenant-Migrationen (Entries Schema) für beide Schemas durchführen
listOf("event_a", "event_b").forEach { schema ->
Flyway.configure()
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
.locations("classpath:db/tenant")
.schemas(schema)
.baselineOnMigrate(true)
.load()
.migrate()
}
}
@Test
fun `writes in tenant A are not visible in tenant B`() {
val now = Clock.System.now()
// Schreibe eine Nennung in Tenant A
TenantContextHolder.set(Tenant(eventId = "event_a", schemaName = "event_a"))
try {
val nennungA = DomNennung.random(now)
val loadedA = runBlocking {
nennungRepository.save(nennungA)
nennungRepository.findById(nennungA.nennungId)
}
assertEquals(nennungA.nennungId, loadedA?.nennungId)
} finally {
TenantContextHolder.clear()
}
// Prüfe Tenant B: keine Daten vorhanden
TenantContextHolder.set(Tenant(eventId = "event_b", schemaName = "event_b"))
try {
val countB = runBlocking { tenantTransaction { NennungTable.selectAll().count() } }
assertTrue(countB == 0L, "Erwartet keine Nennungen in Tenant B, gefunden: $countB")
} finally {
TenantContextHolder.clear()
}
}
}
// --- Kleine Test-Helfer ---
private fun DomNennung.Companion.random(now: kotlin.time.Instant): DomNennung {
return DomNennung(
nennungId = Uuid.random(),
abteilungId = Uuid.random(),
bewerbId = Uuid.random(),
turnierId = Uuid.random(),
reiterId = Uuid.random(),
pferdId = Uuid.random(),
zahlerId = null,
status = at.mocode.core.domain.model.NennungsStatusE.EINGEGANGEN,
startwunsch = at.mocode.core.domain.model.StartwunschE.VORNE,
istNachnennung = false,
nachnenngebuehrErlassen = false,
bemerkungen = null,
createdAt = now,
updatedAt = now
)
}
@@ -0,0 +1,58 @@
package at.mocode.entries.service.tenant
import org.h2.jdbcx.JdbcDataSource
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.springframework.jdbc.core.JdbcTemplate
class JdbcTenantRegistryTest {
@Test
fun `lookup returns tenant from control schema`() {
val ds = JdbcDataSource().apply { setURL("jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1") }
val jdbc = JdbcTemplate(ds)
jdbc.execute("CREATE SCHEMA IF NOT EXISTS control")
// DDL an ProduktionsSQL angelehnt: Spalte 'status' unquoted, damit Inserts ohne Quoting funktionieren
jdbc.execute("CREATE TABLE control.tenants(event_id VARCHAR PRIMARY KEY, schema_name VARCHAR NOT NULL, db_url VARCHAR NULL, status VARCHAR NOT NULL)")
jdbc.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)",
"event_a", "event_a", null, "ACTIVE")
val registry = JdbcTenantRegistry(jdbc)
val tenant = registry.lookup("event_a")
assertNotNull(tenant)
assertEquals("event_a", tenant!!.eventId)
assertEquals("event_a", tenant.schemaName)
assertEquals(Tenant.Status.ACTIVE, tenant.status)
}
@Test
fun `lookup returns null for unknown event`() {
val ds = JdbcDataSource().apply { setURL("jdbc:h2:mem:testdb2;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1") }
val jdbc = JdbcTemplate(ds)
jdbc.execute("CREATE SCHEMA IF NOT EXISTS control")
jdbc.execute("CREATE TABLE control.tenants(event_id VARCHAR PRIMARY KEY, schema_name VARCHAR NOT NULL, db_url VARCHAR NULL, status VARCHAR NOT NULL)")
val registry = JdbcTenantRegistry(jdbc)
val tenant = registry.lookup("does_not_exist")
org.junit.jupiter.api.Assertions.assertNull(tenant)
}
@Test
fun `lookup maps locked status`() {
val ds = JdbcDataSource().apply { setURL("jdbc:h2:mem:testdb3;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1") }
val jdbc = JdbcTemplate(ds)
jdbc.execute("CREATE SCHEMA IF NOT EXISTS control")
jdbc.execute("CREATE TABLE control.tenants(event_id VARCHAR PRIMARY KEY, schema_name VARCHAR NOT NULL, db_url VARCHAR NULL, status VARCHAR NOT NULL)")
jdbc.update("INSERT INTO control.tenants(event_id, schema_name, db_url, status) VALUES (?,?,?,?)",
"event_locked", "event_locked", null, "LOCKED")
val registry = JdbcTenantRegistry(jdbc)
val tenant = registry.lookup("event_locked")
assertNotNull(tenant)
assertEquals(Tenant.Status.LOCKED, tenant!!.status)
}
}