feat: Add script to refresh README from OpenAPI document

refactor: Improve error handling in PowerDNSAPIClient and streamline response processing

fix: Update RecordController to directly return records without unnecessary mapping

refactor: Update DomainRepository and RecordRepository to remove nullable types for IDs

fix: Change User domains fetch strategy to lazy loading for performance improvement

fix: Enhance user authentication handling in UserFilter to ensure authenticated user presence

refactor: Improve DomainService to enforce ownership checks before domain deletion

refactor: Update RecordService to streamline record retrieval and ensure proper domain association

test: Add OpenAPI generation test to validate API documentation endpoint

test: Implement PowerDNSAPIClient tests to verify HTTP method usage and error handling

test: Create DomainService tests to validate domain ownership and deletion logic

test: Add application-test.properties for in-memory database configuration during tests
This commit is contained in:
dalbodeule committed 2026-10-29 23:11:21 +09:00
1 parent 5936622f64
commit 47461c505f
24 files changed
+920 -1329

No files matched your search

@@ -6,10 +6,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.lang.reflect.Type
import java.util.concurrent.TimeUnit
@Service
@@ -21,40 +21,46 @@ class PowerDNSAPIClient() {
private lateinit var apiKey: String
private val gson = Gson()
private val client = OkHttpClient()
private val client = OkHttpClient.Builder()
.callTimeout(30, TimeUnit.SECONDS)
.build()
@Throws(PowerDNSAPIException::class)
fun createZone(zoneName: String): Response {
try {
val body = gson.toJson(
mapOf(
"name" to "$zoneName.",
"kind" to "Primary"
)
).toRequestBody()
val request = Request.Builder()
.url("$apiUrl/api/v1/servers/localhost/zones")
.addHeader("X-API-Key", apiKey)
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.post(body)
.build()
private fun execute(request: Request) {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) return
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
} catch(ex: Exception) {
println(ex)
throw ex
val error = runCatching {
gson.fromJson(response.peekBody(64 * 1024L).string(), PowerDNSAPIError::class.java)
}.getOrNull()
throw PowerDNSAPIException(
if (error?.error.isNullOrBlank())
PowerDNSAPIError("PowerDNS API returned HTTP ${response.code}", null)
else error
)
}
}
@Throws(PowerDNSAPIException::class)
fun deleteZone(zoneName: String): Response {
fun createZone(zoneName: String) {
val body = gson.toJson(
mapOf(
"name" to "$zoneName.",
"kind" to "Primary"
)
).toRequestBody()
val request = Request.Builder()
.url("$apiUrl/api/v1/servers/localhost/zones")
.addHeader("X-API-Key", apiKey)
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.post(body)
.build()
execute(request)
}
@Throws(PowerDNSAPIException::class)
fun deleteZone(zoneName: String) {
val request = Request.Builder()
.url("$apiUrl/api/v1/servers/localhost/zones/$zoneName.")
.addHeader("X-API-Key", apiKey)
@@ -63,17 +69,11 @@ class PowerDNSAPIClient() {
.delete()
.build()
val response = client.newCall(request).execute()
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
execute(request)
}
@Throws(PowerDNSAPIException::class)
fun createRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0): Response {
fun createRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0) {
val rrset = mapOf(
"name" to recordName,
"type" to recordType,
@@ -95,17 +95,11 @@ class PowerDNSAPIClient() {
.patch(body)
.build()
val response = client.newCall(request).execute()
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
execute(request)
}
@Throws(PowerDNSAPIException::class)
fun updateRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0): Response {
fun updateRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0) {
val rrset = mapOf(
"name" to recordName,
"type" to recordType,
@@ -127,17 +121,11 @@ class PowerDNSAPIClient() {
.patch(body)
.build()
val response = client.newCall(request).execute()
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
execute(request)
}
@Throws(PowerDNSAPIException::class)
fun deleteRecord(zoneName: String, recordName: String, recordType: String): Response {
fun deleteRecord(zoneName: String, recordName: String, recordType: String) {
val rrset = mapOf(
"name" to recordName,
"type" to recordType,
@@ -153,13 +141,7 @@ class PowerDNSAPIClient() {
.patch(body)
.build()
val response = client.newCall(request).execute()
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
execute(request)
}
}
@@ -192,4 +174,4 @@ class PowerDNSAPIException(private val error: PowerDNSAPIError): RuntimeExceptio
get() = error.errors
override fun toString(): String = "PowerDNSAPIException(${error.error} ${errors?.joinToString(", ") ?: ""})"
}
}
@@ -24,7 +24,7 @@ class RecordController(
content = [Content(schema = Schema(implementation = ApiResponseDTO::class))]),
])
fun allRecords(@PathVariable zone_id: String): ApiResponseDTO<List<RecordResponseDTO>> {
return ApiResponseDTO(result = recordService.getRecordsByDomain(zone_id)?.map{ it } ?: listOf())
return ApiResponseDTO(result = recordService.getRecordsByDomain(zone_id))
}
@GetMapping("{zone_id}/dns_records/{dns_record_id}")
@@ -71,4 +71,4 @@ class RecordController(
fun updateRecord(@PathVariable zone_id: String, @PathVariable dns_record_id: String, @RequestBody record: RecordRequestDTO): ApiResponseDTO<RecordResponseDTO> {
return ApiResponseDTO(result = recordService.updateRecord(zone_id, dns_record_id, record))
}
}
}
@@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.*
interface DomainRepository : JpaRepository<Domain?, Long?> {
interface DomainRepository : JpaRepository<Domain, Long> {
@Transactional
fun findByCfid(cfid: String): Optional<Domain>
@@ -14,4 +14,4 @@ interface DomainRepository : JpaRepository<Domain?, Long?> {
@Transactional
fun deleteByCfid(cfid: String): Int
}
}
@@ -5,16 +5,18 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.*
interface RecordRepository : JpaRepository<Record?, Long?> {
interface RecordRepository : JpaRepository<Record, Long> {
@Transactional
fun findByCfid(cfid: String): Optional<Record>
@Transactional
fun findByDomainIdAndCfid(domainId: Long, cfid: String): Optional<Record>
fun findAllByDomainId(domainId: Long): List<Record>
@Transactional
fun deleteByCfid(cfid: String): Int
@Transactional
fun deleteByDomainIdAndCfid(domain_id: Long, cfid: String): Int
}
}
+2 -2
View File
@@ -15,8 +15,8 @@ data class User(
@Column(nullable = false, unique = false, length = 20)
val name: String,
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
val domains: List<Domain> = mutableListOf(),
) {
override fun toString(): String = "User(id=$id, name='$name')"
}
}
@@ -35,4 +35,5 @@ class UserFilter(
}
fun getCurrentUser(): User =
SecurityContextHolder.getContext().authentication.principal as User
SecurityContextHolder.getContext().authentication?.principal as? User
?: throw IllegalStateException("Authenticated user is required")
@@ -20,7 +20,6 @@ class DomainService(
try {
val user = getCurrentUser()
val domain = domainRepository.findAllByUser(user)
if (domain.isEmpty()) throw RuntimeException("Unauthorized")
return domain
} catch(ex: Error) {
@@ -62,14 +61,17 @@ class DomainService(
val domain = domainRepository.findByCfid(domain_id).orElseThrow {
throw RuntimeException("Domain with CFID $domain_id not found")
}
val user = getCurrentUser()
if (domain.user.id != user.id)
throw RuntimeException("Unauthorized to delete domain in API: $domain_id")
powerDNSApiClient.deleteZone(domain.name)
val count = domainRepository.deleteByCfid(domain_id)
if (count > 0) throw RuntimeException("Domain with CFID $domain_id not found")
if (count == 0) throw RuntimeException("Domain with CFID $domain_id not found")
return domain_id
} catch(ex: Error) {
throw ex
}
}
}
}
@@ -64,7 +64,7 @@ class RecordService(
proxied = false,
ttl = record.ttl,
locked = false,
zoneId = record.domain.cfid,
zoneId = domain.cfid,
zoneName = domain.name,
createdOn = record.createdOn.getISOFormat(),
modifiedOn = record.modifiedOn.getISOFormat(),
@@ -82,8 +82,9 @@ class RecordService(
if(domain.user.id != user.id)
throw RuntimeException("Unauthorized to get record in API: $domain_id")
val record = domain.records.find { it.cfid == record_id }
if(record == null) throw RuntimeException("Failed to find record in API: $record_id")
val record = recordRepository.findByDomainIdAndCfid(domain.id!!, record_id).orElseThrow {
RuntimeException("Failed to find record in API: $record_id")
}
return RecordResponseDTO(
id = record.cfid,
@@ -91,15 +92,15 @@ class RecordService(
name = record.name,
content = record.content,
ttl = record.ttl,
zoneId = record.domain.cfid,
zoneName = record.domain.name,
zoneId = domain.cfid,
zoneName = domain.name,
createdOn = record.createdOn.getISOFormat(),
modifiedOn = record.modifiedOn.getISOFormat(),
comment = record.comment,
)
}
fun getRecordsByDomain(domain_id: String): List<RecordResponseDTO>? {
fun getRecordsByDomain(domain_id: String): List<RecordResponseDTO> {
val domain = domainRepository.findByCfid(domain_id).orElseThrow {
RuntimeException("Failed to find domain in API: $domain_id")
}
@@ -108,13 +109,13 @@ class RecordService(
if(domain.user.id != user.id)
throw RuntimeException("Unauthorized to create record in API: $domain_id")
return domain?.records?.map { RecordResponseDTO(
return recordRepository.findAllByDomainId(domain.id!!).map { RecordResponseDTO(
id = it.cfid,
type = it.type,
name = it.name,
content = it.content,
zoneId = it.domain.cfid,
zoneName = it.domain.name,
zoneId = domain.cfid,
zoneName = domain.name,
priority = it.prio,
ttl = it.ttl,
createdOn = it.createdOn.getISOFormat(),
@@ -153,7 +154,7 @@ class RecordService(
else -> record.ttl
}
powerDNSApiClient.updateRecord(domain!!.name, updatedRecord.name, updatedRecord.type, updatedRecord.value)
powerDNSApiClient.updateRecord(domain.name, updatedRecord.name, updatedRecord.type, updatedRecord.value, record.ttl, record.prio)
// 저장
val savedRecord = recordRepository.save(record)
@@ -192,4 +193,4 @@ class RecordService(
return record_id
}
}
}
@@ -2,8 +2,10 @@ package space.mori.dnsapi
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
@SpringBootTest
@ActiveProfiles("test")
class DnsapiApplicationTests {
@Test
@@ -0,0 +1,36 @@
package space.mori.dnsapi
import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import java.nio.file.Files
import java.nio.file.Path
@Tag("openapi-generation")
@SpringBootTest(properties = ["springdoc.api-docs.enabled=true"])
@ActiveProfiles("test")
class OpenApiGenerationTest(@Autowired private val context: WebApplicationContext) {
@Test
fun generateOpenApi() {
val response = MockMvcBuilders.webAppContextSetup(context).build()
.perform(get("/api-docs").with { request ->
request.serverName = "localhost"
request.serverPort = 8080
request
})
.andReturn().response
check(response.status == 200) { "OpenAPI endpoint returned HTTP ${response.status}: ${response.contentAsString}" }
val output = Path.of(System.getProperty("openapi.output"))
val json = GsonBuilder().setPrettyPrinting().create()
.toJson(JsonParser.parseString(response.contentAsString))
Files.writeString(output, json + "\n")
}
}
@@ -0,0 +1,52 @@
package space.mori.dnsapi
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.springframework.test.util.ReflectionTestUtils
class PowerDNSAPIClientTest {
@Test
fun successfulCallsUseTheExpectedHttpMethods() {
MockWebServer().use { server ->
server.start()
repeat(5) { server.enqueue(MockResponse.Builder().body("ok").build()) }
val client = clientFor(server)
client.createZone("example.com")
client.deleteZone("example.com")
client.createRecord("example.com", "www.example.com.", "A", "192.0.2.1")
client.updateRecord("example.com", "www.example.com.", "A", "192.0.2.2")
client.deleteRecord("example.com", "www.example.com.", "A")
assertEquals("POST", server.takeRequest().method)
assertEquals("DELETE", server.takeRequest().method)
repeat(3) { assertEquals("PATCH", server.takeRequest().method) }
}
}
@Test
fun errorResponsesKeepThePowerDnsMessageAndHandleInvalidJson() {
MockWebServer().use { server ->
server.start()
server.enqueue(MockResponse.Builder().code(400)
.body("{\"error\":\"invalid zone\",\"errors\":[\"bad name\"]}").build())
server.enqueue(MockResponse.Builder().code(500).body("not json").build())
val client = clientFor(server)
val error = assertThrows(PowerDNSAPIException::class.java) { client.deleteZone("example.com") }
assertEquals("invalid zone", error.message)
assertEquals(listOf("bad name"), error.errors)
val fallback = assertThrows(PowerDNSAPIException::class.java) { client.deleteZone("example.com") }
assertEquals("PowerDNS API returned HTTP 500", fallback.message)
}
}
private fun clientFor(server: MockWebServer): PowerDNSAPIClient = PowerDNSAPIClient().also {
ReflectionTestUtils.setField(it, "apiUrl", server.url("/").toString().removeSuffix("/"))
ReflectionTestUtils.setField(it, "apiKey", "test-key")
}
}
@@ -0,0 +1,64 @@
package space.mori.dnsapi.service
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import space.mori.dnsapi.PowerDNSAPIClient
import space.mori.dnsapi.db.Domain
import space.mori.dnsapi.db.DomainRepository
import space.mori.dnsapi.db.User
import java.util.Optional
class DomainServiceTest {
private val repository = Mockito.mock(DomainRepository::class.java)
private val powerDns = Mockito.mock(PowerDNSAPIClient::class.java)
private val service = DomainService(repository, powerDns)
@AfterEach
fun clearSecurityContext() {
SecurityContextHolder.clearContext()
}
@Test
fun rejectsDeletingAnotherUsersDomainBeforeCallingPowerDns() {
val owner = User(id = 1, apiKey = "owner", name = "owner")
val otherUser = User(id = 2, apiKey = "other", name = "other")
val domain = Domain(name = "example.com", user = owner)
Mockito.`when`(repository.findByCfid(domain.cfid)).thenReturn(Optional.of(domain))
authenticate(otherUser)
assertThrows(RuntimeException::class.java) { service.deleteDomain(domain.cfid) }
Mockito.verifyNoInteractions(powerDns)
Mockito.verify(repository, Mockito.never()).deleteByCfid(domain.cfid)
}
@Test
fun deletingAnOwnedDomainSucceedsWhenOneRowWasRemoved() {
val owner = User(id = 1, apiKey = "owner", name = "owner")
val domain = Domain(name = "example.com", user = owner)
Mockito.`when`(repository.findByCfid(domain.cfid)).thenReturn(Optional.of(domain))
Mockito.`when`(repository.deleteByCfid(domain.cfid)).thenReturn(1)
authenticate(owner)
assertEquals(domain.cfid, service.deleteDomain(domain.cfid))
Mockito.verify(powerDns).deleteZone(domain.name)
}
@Test
fun aUserWithoutDomainsGetsAnEmptyList() {
val user = User(id = 1, apiKey = "owner", name = "owner")
Mockito.`when`(repository.findAllByUser(user)).thenReturn(emptyList())
authenticate(user)
assertEquals(emptyList<Domain>(), service.getAllDomains())
}
private fun authenticate(user: User) {
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(user, null, emptyList())
}
}
@@ -0,0 +1,8 @@
spring.datasource.url=jdbc:h2:mem:dnsapi;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
pdns.api.key=test
pdns.api.url=http://localhost
pdns.ns=ns.example