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
+911 -1320

No files matched your search

+44 -5
View File
@@ -1,5 +1,5 @@
# Created by https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,intellij # Created by https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,linux,windows,intellij
# Edit at https://www.toptal.com/developers/gitignore?templates=gradle,java,kotlin,macos,intellij # Edit at https://www.toptal.com/developers/gitignore?templates=gradle,java,kotlin,macos,linux,windows,intellij
### Intellij ### ### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
@@ -153,6 +153,21 @@ replay_pid*
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ### ### macOS ###
# General # General
.DS_Store .DS_Store
@@ -186,6 +201,32 @@ Temporary Items
# iCloud generated files # iCloud generated files
*.icloud *.icloud
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### Gradle ### ### Gradle ###
.gradle .gradle
**/build/ **/build/
@@ -213,6 +254,4 @@ gradle-app.setting
# Java heap dump # Java heap dump
*.hprof *.hprof
.env # End of https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,linux,windows,intellij
# End of https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,intellij
+9
View File
@@ -3,4 +3,13 @@
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" /> <bytecodeTargetLevel target="21" />
</component> </component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="dnsapi" options="-parameters" />
<module name="dnsapi.aot" options="-parameters" />
<module name="dnsapi.aotTest" options="-parameters" />
<module name="dnsapi.test" options="-parameters" />
<module name="space.mori.dnsapi.main" options="-parameters" />
</option>
</component>
</project> </project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:97ab7754-c9e9-4e6f-b775-574d260344a3
2:0:1b4583fc-5364-493b-be71-9d4c536f6902
.</data>
</component>
</project>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.24" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.21" />
</component> </component>
</project> </project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SwiftToolchain">
<option name="toolchain" value="xcode-/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift" />
</component>
</project>
Generated
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>
+163 -885
View File
File diff suppressed because it is too large. Load diff
+59 -29
View File
@@ -1,26 +1,34 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
plugins { plugins {
id("java") id("java")
id("org.springframework.boot") version "3.3.0" id("org.springframework.boot") version "4.1.2"
id("io.spring.dependency-management") version "1.1.5" id("io.spring.dependency-management") version "1.1.7"
id("org.hibernate.orm") version "6.5.2.Final" id("org.hibernate.orm") version "7.1.4.Final"
id("org.graalvm.buildtools.native") version "0.10.2" id("org.graalvm.buildtools.native") version "1.1.17"
kotlin("jvm") version "1.9.24" kotlin("jvm") version "2.4.21"
kotlin("plugin.spring") version "1.9.24" kotlin("plugin.spring") version "2.4.21"
kotlin("plugin.jpa") version "1.9.24" kotlin("plugin.jpa") version "2.4.21"
} }
val datetimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmm") val datetimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmm")
val jvmVersion = 25
group = "space.mori" group = "space.mori"
version = "0.0.1" version = "1.0.0"
java { java {
sourceCompatibility = JavaVersion.VERSION_21 toolchain.languageVersion.set(JavaLanguageVersion.of(jvmVersion))
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
jvmTarget.set(JvmTarget.fromTarget(jvmVersion.toString()))
}
} }
repositories { repositories {
@@ -31,40 +39,64 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("tools.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.2.0")
// https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-bindings // https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-bindings
implementation("org.springframework.cloud:spring-cloud-bindings:2.0.3") implementation("org.springframework.cloud:spring-cloud-bindings:2.0.4")
implementation("com.google.code.gson:gson:2.11.0") implementation("com.google.code.gson:gson:2.15.0")
implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") implementation("io.github.cdimascio:dotenv-kotlin:6.5.1")
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:5.5.1")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
testImplementation("com.squareup.okhttp3:mockwebserver3:5.5.1")
testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "21"
}
}
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
tasks.named<Test>("test") {
useJUnitPlatform { excludeTags("openapi-generation") }
}
tasks.register<Test>("generateOpenApi") {
group = "documentation"
description = "Generate the root openapi.json from the running Spring application"
dependsOn(tasks.testClasses)
testClassesDirs = sourceSets.test.get().output.classesDirs
classpath = sourceSets.test.get().runtimeClasspath
useJUnitPlatform { includeTags("openapi-generation") }
val openApiFile = layout.projectDirectory.file("openapi.json")
outputs.file(openApiFile)
systemProperty("openapi.output", openApiFile.asFile.absolutePath)
}
tasks.register<Exec>("updateReadme") {
group = "documentation"
description = "Update README.md from the generated OpenAPI document"
dependsOn("generateOpenApi")
commandLine("python3", "scripts/update_readme.py")
}
tasks.register("updateDocs") {
group = "documentation"
description = "Refresh openapi.json and README.md"
dependsOn("updateReadme")
}
tasks.withType<BootBuildImage> { tasks.withType<BootBuildImage> {
val dockerId = System.getenv("DOCKER_USERNAME") ?: "git.mori.space/dalbodeule" val dockerId = System.getenv("DOCKER_USERNAME") ?: "dalbodeule"
val dockerPw = System.getenv("DOCKER_PASSWORD") ?: "" val dockerPw = System.getenv("DOCKER_PASSWORD") ?: ""
docker { docker {
@@ -83,14 +115,12 @@ tasks.withType<BootBuildImage> {
"BP_NATIVE_IMAGE" to "true", "BP_NATIVE_IMAGE" to "true",
"BP_NATIVE_IMAGE_BUILD_ARGUMENTS" to "-H:+UnlockExperimentalVMOptions", "BP_NATIVE_IMAGE_BUILD_ARGUMENTS" to "-H:+UnlockExperimentalVMOptions",
"BP_JVM_TYPE" to "JDK", "BP_JVM_TYPE" to "JDK",
"BP_JVM_VERSION" to "21", "BP_JVM_VERSION" to jvmVersion.toString(),
) )
} }
hibernate { hibernate {
enhancement { enhancement {}
enableAssociationManagement.set(true)
}
} }
tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") { tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") {
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+290 -321
View File
@@ -1,5 +1,5 @@
{ {
"openapi": "3.0.1", "openapi": "3.1.0",
"info": { "info": {
"title": "Cloudflare compatible PowerDNS API Server", "title": "Cloudflare compatible PowerDNS API Server",
"version": "v1.0.0" "version": "v1.0.0"
@@ -16,6 +16,159 @@
} }
], ],
"paths": { "paths": {
"/zones/{zone_id}/dns_records/{dns_record_id}": {
"get": {
"tags": [
"record"
],
"summary": "Get Record by ID",
"operationId": "getRecordByCfid",
"parameters": [
{
"name": "zone_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "dns_record_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Return Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTO"
}
}
}
}
}
},
"put": {
"tags": [
"record"
],
"summary": "Update Record by ID",
"operationId": "updateRecord",
"parameters": [
{
"name": "zone_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "dns_record_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecordRequestDTO"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Return Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTO"
}
}
}
}
}
},
"delete": {
"tags": [
"record"
],
"summary": "Remove Record by ID",
"operationId": "deleteRecord",
"parameters": [
{
"name": "zone_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "dns_record_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Return Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTODeleteResponseWithId"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTO"
}
}
}
}
}
}
},
"/zones": { "/zones": {
"get": { "get": {
"tags": [ "tags": [
@@ -176,159 +329,6 @@
} }
} }
}, },
"/zones/{zone_id}/dns_records/{dns_record_id}": {
"get": {
"tags": [
"record"
],
"summary": "Get Record by ID",
"operationId": "getRecordByCfid",
"parameters": [
{
"name": "zone_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "dns_record_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Return Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTO"
}
}
}
}
}
},
"delete": {
"tags": [
"record"
],
"summary": "Remove Record by ID",
"operationId": "deleteRecord",
"parameters": [
{
"name": "zone_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "dns_record_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Return Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTODeleteResponseWithId"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTO"
}
}
}
}
}
},
"put": {
"tags": [
"record"
],
"summary": "Update Record by ID",
"operationId": "updateRecord",
"parameters": [
{
"name": "zone_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "dns_record_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecordRequestDTO"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Return Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseDTO"
}
}
}
}
}
}
},
"/zones/{cfid}": { "/zones/{cfid}": {
"get": { "get": {
"tags": [ "tags": [
@@ -414,122 +414,9 @@
}, },
"components": { "components": {
"schemas": { "schemas": {
"DomainRequestDTO": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Domain name(TLD)",
"example": "example.com"
}
},
"description": "Request DTO for Domain"
},
"ApiResponseDTO": {
"required": [
"errors",
"messages",
"success"
],
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"errors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"result": {
"type": "object"
}
}
},
"ErrorOrMessage": {
"required": [
"code",
"message"
],
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
}
}
},
"ApiResponseDTODomainResponseDTO": {
"required": [
"errors",
"messages",
"success"
],
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"errors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"result": {
"$ref": "#/components/schemas/DomainResponseDTO"
}
}
},
"DomainResponseDTO": {
"required": [
"id",
"name"
],
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Domain CFID",
"example": "123e4567e89b12d3a456426655440000"
},
"name": {
"type": "string",
"description": "Domain name(TLD)",
"example": "example.com"
}
},
"description": "Response DTO for Domain"
},
"RecordRequestDTO": { "RecordRequestDTO": {
"required": [
"comment",
"content",
"name",
"proxied",
"ttl",
"type"
],
"type": "object", "type": "object",
"description": "Request DTO for Record",
"properties": { "properties": {
"type": { "type": {
"type": "string", "type": "string",
@@ -541,21 +428,24 @@
"description": "Host name", "description": "Host name",
"example": "www" "example": "www"
}, },
"content": { "value": {
"type": "string", "type": "string",
"description": "Record data", "description": "Record data",
"example": "192.0.2.1" "example": "192.0.2.1"
}, },
"ttl": { "ttl": {
"type": "integer", "type": "integer",
"description": "TTL (Time to Live)",
"format": "int32", "format": "int32",
"description": "TTL (Time to Live)",
"example": 3600 "example": 3600
}, },
"priority": { "priority": {
"type": "integer", "type": [
"description": "Priority", "integer",
"null"
],
"format": "int32", "format": "int32",
"description": "Priority",
"example": 0 "example": 0
}, },
"proxied": { "proxied": {
@@ -567,15 +457,42 @@
"type": "string", "type": "string",
"description": "comment" "description": "comment"
} }
}, }
"description": "Request DTO for Record" },
"ApiResponseDTO": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"errors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"result": {}
}
},
"ErrorOrMessage": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
}
}
}, },
"ApiResponseDTORecordResponseDTO": { "ApiResponseDTORecordResponseDTO": {
"required": [
"errors",
"messages",
"success"
],
"type": "object", "type": "object",
"properties": { "properties": {
"success": { "success": {
@@ -594,26 +511,20 @@
} }
}, },
"result": { "result": {
"$ref": "#/components/schemas/RecordResponseDTO" "oneOf": [
{
"$ref": "#/components/schemas/RecordResponseDTO"
},
{
"type": "null"
}
]
} }
} }
}, },
"RecordResponseDTO": { "RecordResponseDTO": {
"required": [
"content",
"createdOn",
"id",
"locked",
"modifiedOn",
"name",
"proxiable",
"proxied",
"ttl",
"type",
"zoneId",
"zoneName"
],
"type": "object", "type": "object",
"description": "Response DTO for Record",
"properties": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
@@ -656,9 +567,12 @@
"example": "2014-01-01T05:20:00.12345Z" "example": "2014-01-01T05:20:00.12345Z"
}, },
"priority": { "priority": {
"type": "integer", "type": [
"description": "Record priority", "integer",
"null"
],
"format": "int32", "format": "int32",
"description": "Record priority",
"example": 0 "example": 0
}, },
"proxiable": { "proxiable": {
@@ -673,8 +587,8 @@
}, },
"ttl": { "ttl": {
"type": "integer", "type": "integer",
"description": "Record TTL",
"format": "int32", "format": "int32",
"description": "Record TTL",
"example": 300 "example": 300
}, },
"locked": { "locked": {
@@ -683,18 +597,26 @@
"example": false "example": false
}, },
"comment": { "comment": {
"type": "string", "type": [
"string",
"null"
],
"description": "Record comments" "description": "Record comments"
} }
}, }
"description": "Response DTO for Record"
}, },
"ApiResponseDTOListDomainResponseDTO": { "DomainRequestDTO": {
"required": [ "type": "object",
"errors", "description": "Request DTO for Domain",
"messages", "properties": {
"success" "name": {
], "type": "string",
"description": "Domain name(TLD)",
"example": "example.com"
}
}
},
"ApiResponseDTODomainResponseDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"success": { "success": {
@@ -713,7 +635,56 @@
} }
}, },
"result": { "result": {
"oneOf": [
{
"$ref": "#/components/schemas/DomainResponseDTO"
},
{
"type": "null"
}
]
}
}
},
"DomainResponseDTO": {
"type": "object",
"description": "Response DTO for Domain",
"properties": {
"id": {
"type": "string",
"description": "Domain CFID",
"example": "123e4567e89b12d3a456426655440000"
},
"name": {
"type": "string",
"description": "Domain name(TLD)",
"example": "example.com"
}
}
},
"ApiResponseDTOListDomainResponseDTO": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"errors": {
"type": "array", "type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ErrorOrMessage"
}
},
"result": {
"type": [
"array",
"null"
],
"items": { "items": {
"$ref": "#/components/schemas/DomainResponseDTO" "$ref": "#/components/schemas/DomainResponseDTO"
} }
@@ -721,11 +692,6 @@
} }
}, },
"ApiResponseDTOListRecordResponseDTO": { "ApiResponseDTOListRecordResponseDTO": {
"required": [
"errors",
"messages",
"success"
],
"type": "object", "type": "object",
"properties": { "properties": {
"success": { "success": {
@@ -744,7 +710,10 @@
} }
}, },
"result": { "result": {
"type": "array", "type": [
"array",
"null"
],
"items": { "items": {
"$ref": "#/components/schemas/RecordResponseDTO" "$ref": "#/components/schemas/RecordResponseDTO"
} }
@@ -752,11 +721,6 @@
} }
}, },
"ApiResponseDTODeleteResponseWithId": { "ApiResponseDTODeleteResponseWithId": {
"required": [
"errors",
"messages",
"success"
],
"type": "object", "type": "object",
"properties": { "properties": {
"success": { "success": {
@@ -775,14 +739,18 @@
} }
}, },
"result": { "result": {
"$ref": "#/components/schemas/DeleteResponseWithId" "oneOf": [
{
"$ref": "#/components/schemas/DeleteResponseWithId"
},
{
"type": "null"
}
]
} }
} }
}, },
"DeleteResponseWithId": { "DeleteResponseWithId": {
"required": [
"id"
],
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -794,6 +762,7 @@
"securitySchemes": { "securitySchemes": {
"api token": { "api token": {
"type": "http", "type": "http",
"name": "api token",
"scheme": "bearer" "scheme": "bearer"
} }
} }
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Refresh the generated README section from the root OpenAPI document."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
START = "<!-- BEGIN GENERATED API -->"
END = "<!-- END GENERATED API -->"
METHODS = ("get", "post", "put", "patch", "delete")
def schema_type(schema):
if not schema:
return "—"
if "$ref" in schema:
name = schema["$ref"].rsplit("/", 1)[-1]
return f"[{name}](#{name.lower()})"
if "oneOf" in schema:
return " or ".join(schema_type(part) for part in schema["oneOf"])
kind = schema.get("type", "object")
if isinstance(kind, list):
return " or ".join(
f"array of {schema_type(schema.get('items'))}" if part == "array" else part
for part in kind
)
if kind == "array":
return f"array of {schema_type(schema.get('items'))}"
return kind
def media_schema(content):
return schema_type(content.get("application/json", {}).get("schema"))
def cell(value):
return str(value or "—").replace("|", "\\|").replace("\n", " ")
def generate(spec):
lines = [
"Generated from [openapi.json](openapi.json). Do not edit this section by hand.",
"",
"### Endpoints",
"",
"| Method | Path | Summary | Request body | Success response |",
"| --- | --- | --- | --- | --- |",
]
for path, operations in sorted(spec["paths"].items()):
for method in METHODS:
operation = operations.get(method)
if not operation:
continue
request = media_schema(operation.get("requestBody", {}).get("content", {}))
responses = operation.get("responses", {})
success_code = next((code for code in sorted(responses) if code.startswith("2")), None)
response = responses.get(success_code, {})
result = media_schema(response.get("content", {}))
lines.append(
f"| `{method.upper()}` | `{path}` | {cell(operation.get('summary'))} | "
f"{cell(request)} | {cell(success_code)}: {cell(result)} |"
)
lines.extend(["", "### Schemas", ""])
for name, schema in sorted(spec.get("components", {}).get("schemas", {}).items()):
lines.extend([f"#### {name}", ""])
if schema.get("description"):
lines.extend([schema["description"], ""])
properties = schema.get("properties", {})
if not properties:
lines.extend([f"Type: {schema_type(schema)}", ""])
continue
required = set(schema.get("required", []))
lines.extend(["| Field | Type | Required | Description |", "| --- | --- | --- | --- |"])
for field, definition in properties.items():
lines.append(
f"| `{field}` | {cell(schema_type(definition))} | "
f"{'Yes' if field in required else 'No'} | {cell(definition.get('description'))} |"
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main():
spec = json.loads((ROOT / "openapi.json").read_text(encoding="utf-8"))
readme_path = ROOT / "README.md"
readme = readme_path.read_text(encoding="utf-8")
if readme.count(START) != 1 or readme.count(END) != 1:
raise ValueError("README.md must contain one pair of generated API markers")
before, remainder = readme.split(START, 1)
_, after = remainder.split(END, 1)
readme_path.write_text(before + START + "\n" + generate(spec) + END + after, encoding="utf-8")
if __name__ == "__main__":
main()
@@ -6,10 +6,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.lang.reflect.Type import java.lang.reflect.Type
import java.util.concurrent.TimeUnit
@Service @Service
@@ -21,40 +21,46 @@ class PowerDNSAPIClient() {
private lateinit var apiKey: String private lateinit var apiKey: String
private val gson = Gson() private val gson = Gson()
private val client = OkHttpClient() private val client = OkHttpClient.Builder()
.callTimeout(30, TimeUnit.SECONDS)
.build()
@Throws(PowerDNSAPIException::class) private fun execute(request: Request) {
fun createZone(zoneName: String): Response { client.newCall(request).execute().use { response ->
try { if (response.isSuccessful) return
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()
val response = client.newCall(request).execute() val error = runCatching {
if (!response.isSuccessful) { gson.fromJson(response.peekBody(64 * 1024L).string(), PowerDNSAPIError::class.java)
val str = response.body?.string() }.getOrNull()
val error = gson.fromJson(str, PowerDNSAPIError::class.java) throw PowerDNSAPIException(
throw PowerDNSAPIException(error) if (error?.error.isNullOrBlank())
} PowerDNSAPIError("PowerDNS API returned HTTP ${response.code}", null)
return response else error
} catch(ex: Exception) { )
println(ex)
throw ex
} }
} }
@Throws(PowerDNSAPIException::class) @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() val request = Request.Builder()
.url("$apiUrl/api/v1/servers/localhost/zones/$zoneName.") .url("$apiUrl/api/v1/servers/localhost/zones/$zoneName.")
.addHeader("X-API-Key", apiKey) .addHeader("X-API-Key", apiKey)
@@ -63,17 +69,11 @@ class PowerDNSAPIClient() {
.delete() .delete()
.build() .build()
val response = client.newCall(request).execute() execute(request)
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
} }
@Throws(PowerDNSAPIException::class) @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( val rrset = mapOf(
"name" to recordName, "name" to recordName,
"type" to recordType, "type" to recordType,
@@ -95,17 +95,11 @@ class PowerDNSAPIClient() {
.patch(body) .patch(body)
.build() .build()
val response = client.newCall(request).execute() execute(request)
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
} }
@Throws(PowerDNSAPIException::class) @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( val rrset = mapOf(
"name" to recordName, "name" to recordName,
"type" to recordType, "type" to recordType,
@@ -127,17 +121,11 @@ class PowerDNSAPIClient() {
.patch(body) .patch(body)
.build() .build()
val response = client.newCall(request).execute() execute(request)
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
} }
@Throws(PowerDNSAPIException::class) @Throws(PowerDNSAPIException::class)
fun deleteRecord(zoneName: String, recordName: String, recordType: String): Response { fun deleteRecord(zoneName: String, recordName: String, recordType: String) {
val rrset = mapOf( val rrset = mapOf(
"name" to recordName, "name" to recordName,
"type" to recordType, "type" to recordType,
@@ -153,13 +141,7 @@ class PowerDNSAPIClient() {
.patch(body) .patch(body)
.build() .build()
val response = client.newCall(request).execute() execute(request)
if(!response.isSuccessful) {
val str = response.body?.string()
val error = gson.fromJson(str, PowerDNSAPIError::class.java)
throw PowerDNSAPIException(error)
}
return response
} }
} }
@@ -24,7 +24,7 @@ class RecordController(
content = [Content(schema = Schema(implementation = ApiResponseDTO::class))]), content = [Content(schema = Schema(implementation = ApiResponseDTO::class))]),
]) ])
fun allRecords(@PathVariable zone_id: String): ApiResponseDTO<List<RecordResponseDTO>> { 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}") @GetMapping("{zone_id}/dns_records/{dns_record_id}")
@@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.* import java.util.*
interface DomainRepository : JpaRepository<Domain?, Long?> { interface DomainRepository : JpaRepository<Domain, Long> {
@Transactional @Transactional
fun findByCfid(cfid: String): Optional<Domain> fun findByCfid(cfid: String): Optional<Domain>
@@ -5,13 +5,15 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.* import java.util.*
interface RecordRepository : JpaRepository<Record?, Long?> { interface RecordRepository : JpaRepository<Record, Long> {
@Transactional @Transactional
fun findByCfid(cfid: String): Optional<Record> fun findByCfid(cfid: String): Optional<Record>
@Transactional @Transactional
fun findByDomainIdAndCfid(domainId: Long, cfid: String): Optional<Record> fun findByDomainIdAndCfid(domainId: Long, cfid: String): Optional<Record>
fun findAllByDomainId(domainId: Long): List<Record>
@Transactional @Transactional
fun deleteByCfid(cfid: String): Int fun deleteByCfid(cfid: String): Int
+1 -1
View File
@@ -15,7 +15,7 @@ data class User(
@Column(nullable = false, unique = false, length = 20) @Column(nullable = false, unique = false, length = 20)
val name: String, 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(), val domains: List<Domain> = mutableListOf(),
) { ) {
override fun toString(): String = "User(id=$id, name='$name')" override fun toString(): String = "User(id=$id, name='$name')"
@@ -35,4 +35,5 @@ class UserFilter(
} }
fun getCurrentUser(): User = 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 { try {
val user = getCurrentUser() val user = getCurrentUser()
val domain = domainRepository.findAllByUser(user) val domain = domainRepository.findAllByUser(user)
if (domain.isEmpty()) throw RuntimeException("Unauthorized")
return domain return domain
} catch(ex: Error) { } catch(ex: Error) {
@@ -62,11 +61,14 @@ class DomainService(
val domain = domainRepository.findByCfid(domain_id).orElseThrow { val domain = domainRepository.findByCfid(domain_id).orElseThrow {
throw RuntimeException("Domain with CFID $domain_id not found") 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) powerDNSApiClient.deleteZone(domain.name)
val count = domainRepository.deleteByCfid(domain_id) 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 return domain_id
} catch(ex: Error) { } catch(ex: Error) {
throw ex throw ex
@@ -64,7 +64,7 @@ class RecordService(
proxied = false, proxied = false,
ttl = record.ttl, ttl = record.ttl,
locked = false, locked = false,
zoneId = record.domain.cfid, zoneId = domain.cfid,
zoneName = domain.name, zoneName = domain.name,
createdOn = record.createdOn.getISOFormat(), createdOn = record.createdOn.getISOFormat(),
modifiedOn = record.modifiedOn.getISOFormat(), modifiedOn = record.modifiedOn.getISOFormat(),
@@ -82,8 +82,9 @@ class RecordService(
if(domain.user.id != user.id) if(domain.user.id != user.id)
throw RuntimeException("Unauthorized to get record in API: $domain_id") throw RuntimeException("Unauthorized to get record in API: $domain_id")
val record = domain.records.find { it.cfid == record_id } val record = recordRepository.findByDomainIdAndCfid(domain.id!!, record_id).orElseThrow {
if(record == null) throw RuntimeException("Failed to find record in API: $record_id") RuntimeException("Failed to find record in API: $record_id")
}
return RecordResponseDTO( return RecordResponseDTO(
id = record.cfid, id = record.cfid,
@@ -91,15 +92,15 @@ class RecordService(
name = record.name, name = record.name,
content = record.content, content = record.content,
ttl = record.ttl, ttl = record.ttl,
zoneId = record.domain.cfid, zoneId = domain.cfid,
zoneName = record.domain.name, zoneName = domain.name,
createdOn = record.createdOn.getISOFormat(), createdOn = record.createdOn.getISOFormat(),
modifiedOn = record.modifiedOn.getISOFormat(), modifiedOn = record.modifiedOn.getISOFormat(),
comment = record.comment, comment = record.comment,
) )
} }
fun getRecordsByDomain(domain_id: String): List<RecordResponseDTO>? { fun getRecordsByDomain(domain_id: String): List<RecordResponseDTO> {
val domain = domainRepository.findByCfid(domain_id).orElseThrow { val domain = domainRepository.findByCfid(domain_id).orElseThrow {
RuntimeException("Failed to find domain in API: $domain_id") RuntimeException("Failed to find domain in API: $domain_id")
} }
@@ -108,13 +109,13 @@ class RecordService(
if(domain.user.id != user.id) if(domain.user.id != user.id)
throw RuntimeException("Unauthorized to create record in API: $domain_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, id = it.cfid,
type = it.type, type = it.type,
name = it.name, name = it.name,
content = it.content, content = it.content,
zoneId = it.domain.cfid, zoneId = domain.cfid,
zoneName = it.domain.name, zoneName = domain.name,
priority = it.prio, priority = it.prio,
ttl = it.ttl, ttl = it.ttl,
createdOn = it.createdOn.getISOFormat(), createdOn = it.createdOn.getISOFormat(),
@@ -153,7 +154,7 @@ class RecordService(
else -> record.ttl 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) val savedRecord = recordRepository.save(record)
@@ -2,8 +2,10 @@ package space.mori.dnsapi
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
@SpringBootTest @SpringBootTest
@ActiveProfiles("test")
class DnsapiApplicationTests { class DnsapiApplicationTests {
@Test @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