19 Commits

Author SHA1 Message Date
hlaeja
32a630d6a3 [RELEASE] - Release version: 0.3.0 2025-07-29 18:05:41 +00:00
c468a5ffa3 update gradlew 2025-07-29 20:04:46 +02:00
3849fa8676 update project 2025-07-29 20:04:46 +02:00
1ee306c151 add GitHub action
- update release in README.md
- add action run checks
- add action release
- remove release.sh
2025-07-29 20:04:46 +02:00
18e95f7213 add actuator.http 2025-07-29 20:04:46 +02:00
82c590dc30 update sql files 2025-07-29 20:04:46 +02:00
da491cecfa update release.sh to move sql files to version folder. 2025-03-05 14:43:15 +01:00
3bc5805a87 [RELEASE] - bump version 2025-02-07 17:13:48 +01:00
ec5991f20b [RELEASE] - release version: 0.2.0 2025-02-07 17:13:45 +01:00
a9b5abda1a update name for hlaeja libraries 2025-02-07 17:12:21 +01:00
c7eb3484e6 Authentication integration test
- add end-to-end test AuthenticationEndpoint
- add user-token.data
- add admin-token.data
- add .data to .editorconfig
2025-02-07 14:42:17 +01:00
5951af7d44 use data and reset sql files and some clean up 2025-02-07 14:33:42 +01:00
c08c3cb880 account validation and integration test
- add end-to-end test AccountEndpoint
- update AccountController validation to
  - updateAccount
  - addAccount
2025-02-03 22:41:24 +01:00
6165bcd512 cleanup AccountService 2025-02-03 22:06:53 +01:00
ddc701ea51 add valid account annotation
- add AccountValidator
- add ValidAccount
2025-02-03 22:05:51 +01:00
a762a05c11 extract accounts and add integration test
- add end-to-end test AccountsEndpoint
- extract get accounts from account.http to accounts.http
- extract get accounts from AccountController to AccountsController
- move spring boot test file to integration test
- add schema.sql
- add dependencies for integration test
2025-02-03 19:51:36 +01:00
6e6ea72d54 add update accounts
- add update accounts to account.http
- add updateAccount to AccountController
- add AccountEntity updateAccountEntity to Mapping.kt
- add updateAccount in AccountService
- update catalog version in gradle.properties
2025-01-28 16:45:15 +01:00
72ac37e603 add get accounts
- add get accounts to account.http
- add getAccounts to AccountController
- add missing test in AccountServiceTest
- add getAccounts to AccountService
2025-01-28 11:44:56 +01:00
f8154fe05f [RELEASE] - bump version 2025-01-02 07:16:07 +01:00
43 changed files with 1339 additions and 147 deletions

View File

@@ -17,6 +17,10 @@ tab_width = 2
[*.bat]
end_of_line = crlf
[*.data]
max_line_length = 1024
insert_final_newline = false
[*.pem]
max_line_length = 64
insert_final_newline = false

12
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Release
on:
workflow_dispatch:
jobs:
release:
uses: swordsteel/hlaeja-common-workflows/.github/workflows/release.yml@master
secrets:
CI_BOT_PAT: ${{ secrets.CI_BOT_PAT }}
with:
TYPE: service

12
.github/workflows/run-checks.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Pull request validation
on:
pull_request:
paths-ignore:
- '.github/**'
jobs:
validate:
uses: swordsteel/hlaeja-common-workflows/.github/workflows/run-checks.yml@master
secrets:
CI_BOT_PAT: ${{ secrets.CI_BOT_PAT }}

View File

@@ -16,7 +16,7 @@ In twilight's hush, where mythic tales unfold, A ledger of legends, the bravest
## Development Configuration
Run `release.sh` script from `master` branch.
Run release pipeline from `master` branch.
## Development Information

View File

@@ -1,21 +1,23 @@
plugins {
alias(hlaeja.plugins.kotlin.jvm)
alias(hlaeja.plugins.kotlin.spring)
alias(hlaeja.plugins.ltd.hlaeja.plugin.certificate)
alias(hlaeja.plugins.ltd.hlaeja.plugin.service)
alias(hlaeja.plugins.spring.boot)
alias(hlaeja.plugins.spring.dependency.management)
alias(hlaeja.plugins.springframework.boot)
alias(hlaeja.plugins.certificate)
alias(hlaeja.plugins.service)
}
dependencies {
implementation(hlaeja.fasterxml.jackson.module.kotlin)
implementation(hlaeja.kotlin.logging)
implementation(hlaeja.kotlin.reflect)
implementation(hlaeja.kotlinx.coroutines)
implementation(hlaeja.library.hlaeja.common.messages)
implementation(hlaeja.library.hlaeja.jwt)
implementation(hlaeja.library.common.messages)
implementation(hlaeja.library.jwt)
implementation(hlaeja.springboot.starter.actuator)
implementation(hlaeja.springboot.starter.r2dbc)
implementation(hlaeja.springboot.starter.security)
implementation(hlaeja.springboot.starter.validation)
implementation(hlaeja.springboot.starter.webflux)
runtimeOnly(hlaeja.postgresql)
@@ -29,6 +31,15 @@ dependencies {
testImplementation(hlaeja.springboot.starter.test)
testRuntimeOnly(hlaeja.junit.platform.launcher)
testIntegrationImplementation(hlaeja.assertj.core)
testIntegrationImplementation(hlaeja.library.test)
testIntegrationImplementation(hlaeja.projectreactor.reactor.test)
testIntegrationImplementation(hlaeja.kotlin.test.junit5)
testIntegrationImplementation(hlaeja.kotlinx.coroutines.test)
testIntegrationImplementation(hlaeja.springboot.starter.test)
testIntegrationRuntimeOnly(hlaeja.junit.platform.launcher)
}
group = "ltd.hlaeja"

View File

@@ -1,4 +1,4 @@
kotlin.code.style=official
version=0.1.0
catalog=0.8.0
version=0.3.0
catalog=0.11.0
container.port.host=9050

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

9
gradlew vendored
View File

@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

4
gradlew.bat vendored
View File

@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View File

@@ -1,7 +1,7 @@
### get user by id
GET {{hostname}}/account-00000000-0000-7000-0000-000000000001
### Get admin information
### add user
POST {{hostname}}/account
Content-Type: application/json
@@ -14,3 +14,28 @@ Content-Type: application/json
"ROLE_TEST"
]
}
### update user all information
PUT {{hostname}}/account-00000000-0000-7000-0000-000000000002
Content-Type: application/json
{
"username": "user",
"password": "pass",
"enabled": true,
"roles": [
"ROLE_TEST"
]
}
### update user information
PUT {{hostname}}/account-00000000-0000-7000-0000-000000000002
Content-Type: application/json
{
"username": "user",
"enabled": true,
"roles": [
"ROLE_TEST"
]
}

8
http/accounts.http Normal file
View File

@@ -0,0 +1,8 @@
### Get accounts
GET {{hostname}}/accounts
### Get accounts by page
GET {{hostname}}/accounts/page-1
### Get accounts by page and size
GET {{hostname}}/accounts/page-1/show-1

5
http/actuator.http Normal file
View File

@@ -0,0 +1,5 @@
### get actuator
GET {{hostname}}/actuator
### get actuator health
GET {{hostname}}/actuator/health

View File

@@ -1,89 +0,0 @@
#!/bin/sh
### This should be a pipeline, but for this example let use this ###
check_active_branch() {
if [ "$(git rev-parse --abbrev-ref HEAD)" != "$1" ]; then
echo "Error: The current branch is not $1."
exit 1
fi
}
check_uncommitted_changes() {
if [ -n "$(git status --porcelain)" ]; then
echo "Error: There are uncommitted changes in the repository."
exit 1
fi
}
prepare_environment() {
git fetch origin
}
check_last_commit() {
last_commit_message=$(git log -1 --pretty=format:%s)
if [ "$last_commit_message" = "[RELEASE] - bump version" ]; then
echo "Warning: Nothing to release!!!"
exit 1
fi
}
check_differences() {
if ! git diff --quiet origin/"$1" "$1"; then
echo "Error: The branches origin/$1 and $1 have differences."
exit 1
fi
}
un_snapshot_version() {
sed -i "s/\($1\s*=\s*[0-9]\+\.[0-9]\+\.[0-9]\+\).*/\1/" gradle.properties
}
current_version() {
awk -F '=' '/version\s*=\s*[0-9.]*/ {gsub(/^ +| +$/,"",$2); print $2}' gradle.properties
}
stage_files() {
for file in "$@"; do
if git diff --exit-code --quiet -- "$file"; then
echo "No changes in $file"
else
git add "$file"
echo "Changes in $file staged for commit"
fi
done
}
commit_change() {
stage_files gradle.properties
git commit -m "[RELEASE] - $1"
git push --porcelain origin master
}
add_release_tag() {
gitTag="v$(current_version)"
git tag -a "$gitTag" -m "Release version $gitTag"
git push --porcelain origin "$gitTag"
}
snapshot_version() {
new_version="$(current_version | awk -F '.' '{print $1 "." $2+1 ".0"}')"
sed -i "s/\(version\s*=\s*\)[0-9.]*/\1$new_version-SNAPSHOT/" gradle.properties
}
# check and prepare for release
check_active_branch master
check_uncommitted_changes
prepare_environment
check_last_commit
check_differences master
# un-snapshot version for release
un_snapshot_version version
un_snapshot_version catalog
# release changes and prepare for next release
commit_change "release version: $(current_version)"
add_release_tag
snapshot_version
commit_change 'bump version'

View File

@@ -56,10 +56,12 @@ CREATE DATABASE account_registry
WITH
OWNER = role_administrator
ENCODING = 'UTF8'
LC_COLLATE = 'en_US.utf8'
LC_CTYPE = 'en_US.utf8'
LC_COLLATE = 'en_US.UTF-8'
LC_CTYPE = 'en_US.UTF-8'
LOCALE_PROVIDER = 'libc'
TABLESPACE = pg_default
CONNECTION LIMIT = -1
IS_TEMPLATE = False;
COMMENT ON DATABASE account_registry
IS 'Primary database for user account registration and identity management';

View File

@@ -1,16 +1,24 @@
package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.validator.ValidAccount
import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.library.accountRegistry.Account
import ltd.hlaeja.service.AccountService
import ltd.hlaeja.util.toAccountEntity
import ltd.hlaeja.util.toAccountResponse
import ltd.hlaeja.util.updateAccountEntity
import org.springframework.http.HttpStatus.ACCEPTED
import org.springframework.http.HttpStatus.CREATED
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Mono
@RestController
@@ -25,9 +33,30 @@ class AccountController(
): Mono<Account.Response> = accountService.getUserById(uuid)
.map { it.toAccountResponse() }
@PutMapping("/account-{uuid}")
fun updateAccount(
@PathVariable uuid: UUID,
@RequestBody @ValidAccount request: Account.Request,
): Mono<Account.Response> = accountService.getUserById(uuid)
.map { user ->
user.updateAccountEntity(request, passwordEncoder)
.also { if (hasChange(user, it)) throw ResponseStatusException(ACCEPTED) }
}
.flatMap { accountService.updateAccount(it) }
.map { it.toAccountResponse() }
@PostMapping("/account")
@ResponseStatus(CREATED)
fun addAccount(
@RequestBody request: Account.Request,
@RequestBody @ValidAccount request: Account.Request,
): Mono<Account.Response> = accountService.addAccount(request.toAccountEntity(passwordEncoder))
.map { it.toAccountResponse() }
private fun hasChange(
user: AccountEntity,
update: AccountEntity,
): Boolean = user.password == update.password &&
user.username == update.username &&
user.enabled == update.enabled &&
user.roles == update.roles
}

View File

@@ -0,0 +1,42 @@
package ltd.hlaeja.controller
import jakarta.validation.constraints.Min
import ltd.hlaeja.library.accountRegistry.Account
import ltd.hlaeja.service.AccountService
import ltd.hlaeja.util.toAccountResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux
@RestController
@RequestMapping("/accounts")
class AccountsController(
private val accountService: AccountService,
) {
companion object {
const val DEFAULT_PAGE: Int = 1
const val DEFAULT_SIZE: Int = 25
}
@GetMapping
fun getDefaultAccounts(): Flux<Account.Response> = getAccounts(DEFAULT_PAGE, DEFAULT_SIZE)
@GetMapping("/page-{page}")
fun getAccountsPage(
@PathVariable @Min(1) page: Int,
): Flux<Account.Response> = getAccounts(page, DEFAULT_SIZE)
@GetMapping("/page-{page}/show-{size}")
fun getAccountsPageSize(
@PathVariable @Min(1) page: Int,
@PathVariable @Min(1) size: Int,
): Flux<Account.Response> = getAccounts(page, size)
private fun getAccounts(
page: Int,
size: Int,
): Flux<Account.Response> = accountService.getAccounts(page, size)
.map { it.toAccountResponse() }
}

View File

@@ -5,9 +5,12 @@ import java.util.UUID
import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.repository.AccountRepository
import org.springframework.dao.DuplicateKeyException
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
private val log = KotlinLogging.logger {}
@@ -21,23 +24,40 @@ class AccountService(
uuid: UUID,
): Mono<AccountEntity> = accountRepository.findById(uuid)
.doOnNext { log.debug { "Get account ${it.id}" } }
.switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
.switchIfEmpty(Mono.error(ResponseStatusException(NOT_FOUND)))
fun getUserByUsername(
username: String,
): Mono<AccountEntity> = accountRepository.findByUsername(username)
.doOnNext { log.debug { "Get account ${it.id} for username $username" } }
.switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
.switchIfEmpty(Mono.error(ResponseStatusException(NOT_FOUND)))
fun addAccount(
accountEntity: AccountEntity,
): Mono<AccountEntity> = accountRepository.save(accountEntity)
.doOnNext { log.debug { "Added new type: $it.id" } }
.onErrorResume {
log.debug { it.localizedMessage }
when {
it is DuplicateKeyException -> Mono.error(ResponseStatusException(HttpStatus.CONFLICT))
else -> Mono.error(ResponseStatusException(HttpStatus.BAD_REQUEST))
}
.onErrorResume(::onSaveError)
fun getAccounts(page: Int, size: Int): Flux<AccountEntity> = try {
accountRepository.findAll()
.skip((page - 1).toLong() * size)
.take(size.toLong())
.doOnNext { log.debug { "Retrieved accounts $page with size $size" } }
} catch (e: IllegalArgumentException) {
Flux.error(ResponseStatusException(BAD_REQUEST, null, e))
}
fun updateAccount(
accountEntity: AccountEntity,
): Mono<AccountEntity> = accountRepository.save(accountEntity)
.doOnNext { log.debug { "updated users: $it.id" } }
.onErrorResume(::onSaveError)
private fun onSaveError(throwable: Throwable): Mono<out AccountEntity> {
log.debug { throwable.localizedMessage }
return when {
throwable is DuplicateKeyException -> Mono.error(ResponseStatusException(CONFLICT))
else -> Mono.error(ResponseStatusException(BAD_REQUEST))
}
}
}

View File

@@ -4,6 +4,7 @@ import java.time.ZonedDateTime
import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.library.accountRegistry.Account
import org.springframework.http.HttpStatus.EXPECTATION_FAILED
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.server.ResponseStatusException
@@ -23,6 +24,21 @@ fun Account.Request.toAccountEntity(
updatedAt = ZonedDateTime.now(),
enabled = enabled,
username = username,
password = passwordEncoder.encode(password),
password = password
?.let { passwordEncoder.encode(it) }
?: throw ResponseStatusException(BAD_REQUEST),
roles = roles.joinToString(","),
)
fun AccountEntity.updateAccountEntity(
request: Account.Request,
passwordEncoder: PasswordEncoder,
): AccountEntity = this.copy(
updatedAt = ZonedDateTime.now(),
enabled = request.enabled,
username = request.username,
password = request.password
?.let { passwordEncoder.encode(it) }
?: this.password,
roles = request.roles.joinToString(","),
)

View File

@@ -0,0 +1,19 @@
package ltd.hlaeja.validator
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import ltd.hlaeja.library.accountRegistry.Account
class AccountValidator : ConstraintValidator<ValidAccount, Any> {
override fun isValid(value: Any?, context: ConstraintValidatorContext): Boolean {
return when (value) {
is Account.Request -> value.validate()
else -> true // Default to valid if the type is not a list
}
}
private fun Account.Request.validate(): Boolean = username.isNotBlank() &&
password?.isNotBlank() ?: true &&
roles.isNotEmpty()
}

View File

@@ -0,0 +1,15 @@
package ltd.hlaeja.validator
import jakarta.validation.Constraint
import jakarta.validation.Payload
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.reflect.KClass
@Constraint(validatedBy = [AccountValidator::class])
@Retention(RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class ValidAccount(
val message: String = "Roles must not be empty",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Payload>> = [],
)

View File

@@ -10,6 +10,20 @@ spring:
name: "%APP_BUILD_OS_NAME%"
version: "%APP_BUILD_OS_VERSION%"
management:
endpoints:
access:
default: none
web:
exposure:
include: "health,info"
endpoint:
health:
show-details: always
access: read_only
info:
access: read_only
jwt:
private-key: cert/private_key.pem

View File

@@ -1,11 +0,0 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger level="DEBUG" name="ltd.hlaeja"/>
</configuration>

View File

@@ -0,0 +1,306 @@
package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.library.accountRegistry.Account
import ltd.hlaeja.test.container.PostgresContainer
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus.ACCEPTED
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class AccountEndpoint {
@InjectSoftAssertions
lateinit var softly: SoftAssertions
@LocalServerPort
var port: Int = 0
lateinit var webClient: WebTestClient
@BeforeEach
fun setup() {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Nested
inner class GetAccount {
@Test
fun `get account with valid uuid`() {
// given
val uuid = UUID.fromString("00000000-0000-7000-0000-000000000001")
// when
val result = webClient.get().uri("/account-$uuid").exchange()
// then
result.expectStatus().isOk()
.expectBody<Account.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id).isEqualTo(uuid)
softly.assertThat(it.responseBody?.username).isEqualTo("admin")
softly.assertThat(it.responseBody?.enabled).isTrue
softly.assertThat(it.responseBody?.roles?.size).isEqualTo(1)
softly.assertThat(it.responseBody?.roles?.get(0)).isEqualTo("ROLE_ADMIN")
}
}
@Test
fun `get account with invalid uuid`() {
// given
val uuid = UUID.fromString("00000000-0000-7000-0000-000000000000")
// when
val result = webClient.get().uri("/account-$uuid").exchange()
// then
result.expectStatus().isNotFound
}
@Test
fun `get account with bad uuid`() {
// given
val uuidInvalid = "000000000001"
// when
val result = webClient.get().uri("/account-$uuidInvalid").exchange()
// then
result.expectStatus().isBadRequest
}
}
@Nested
inner class PutAccount {
@Test
fun `success account with all changes`() {
// given
val uuid = UUID.fromString("00000000-0000-7000-0000-000000000003")
val request = Account.Request(
username = "usernameA",
password = "abc123",
enabled = true,
roles = listOf("ROLE_USER", "ROLE_TEST"),
)
// when
val result = webClient.put().uri("/account-$uuid").bodyValue(request).exchange()
// then
result.expectStatus().isOk()
.expectBody<Account.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id).isEqualTo(uuid)
softly.assertThat(it.responseBody?.username).isEqualTo("usernameA")
softly.assertThat(it.responseBody?.enabled).isTrue
softly.assertThat(it.responseBody?.roles?.size).isEqualTo(2)
softly.assertThat(it.responseBody?.roles).contains("ROLE_USER")
softly.assertThat(it.responseBody?.roles).contains("ROLE_TEST")
}
}
@Test
fun `success account with null password changes`() {
// given
val uuid = UUID.fromString("00000000-0000-7000-0000-000000000003")
val request = Account.Request(
username = "usernameB",
password = null,
enabled = false,
roles = listOf("ROLE_TEST"),
)
// when
val result = webClient.put().uri("/account-$uuid").bodyValue(request).exchange()
// then
result.expectStatus().isOk()
.expectBody<Account.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id).isEqualTo(uuid)
softly.assertThat(it.responseBody?.username).isEqualTo("usernameB")
softly.assertThat(it.responseBody?.enabled).isFalse
softly.assertThat(it.responseBody?.roles?.size).isEqualTo(1)
softly.assertThat(it.responseBody?.roles).contains("ROLE_TEST")
}
}
@Test
fun `success account with no changes`() {
// given
val uuid = UUID.fromString("00000000-0000-7000-0000-000000000002")
val request = Account.Request(
username = "user",
password = null,
enabled = true,
roles = listOf("ROLE_USER"),
)
// when
val result = webClient.put().uri("/account-$uuid").bodyValue(request).exchange()
// then
result.expectStatus().isEqualTo(ACCEPTED)
}
@Test
fun `failed username duplicate`() {
// given
val uuid = UUID.fromString("00000000-0000-7000-0000-000000000002")
val request = Account.Request(
username = "admin",
password = null,
enabled = true,
roles = listOf("ROLE_USER"),
)
// when
val result = webClient.put().uri("/account-$uuid").bodyValue(request).exchange()
// then
result.expectStatus().isEqualTo(CONFLICT)
}
@Test
fun `failed account not found`() {
// given
val uuid = UUID.fromString("00000000-0000-7000-0000-000000000000")
val request = Account.Request(
username = "admin",
password = null,
enabled = true,
roles = listOf("ROLE_USER"),
)
// when
val result = webClient.put().uri("/account-$uuid").bodyValue(request).exchange()
// then
result.expectStatus().isNotFound
}
}
@Nested
inner class PostAccount {
@ParameterizedTest
@CsvSource(
"new-user, new-pass, true, 2, ROLE_USER;ROLE_TEST",
"admin-user, admin-pass, false, 1, ROLE_ADMIN",
"test-user, test-pass, true, 1, ROLE_USER",
)
fun `success added account`(
username: String,
password: String,
enabled: Boolean,
size: Int,
roleList: String,
) {
// given
val roles: List<String> = roleList.split(";")
val request = Account.Request(
username = username,
password = password,
enabled = enabled,
roles = roles,
)
// when
val result = webClient.post().uri("/account").bodyValue(request).exchange()
// then
result.expectStatus().isCreated
.expectBody<Account.Response>()
.consumeWith {
softly.assertThat(it.responseBody?.id?.version()).isEqualTo(7)
softly.assertThat(it.responseBody?.username).isEqualTo(username)
softly.assertThat(it.responseBody?.enabled).isEqualTo(enabled)
softly.assertThat(it.responseBody?.roles?.size).isEqualTo(size)
for (role in roles) {
softly.assertThat(it.responseBody?.roles).contains(role)
}
}
}
@ParameterizedTest
@CsvSource(
"'', new-pass, ROLE_TEST",
"new-user, '', ROLE_ADMIN",
"new-user, new-pass, ''",
)
fun `validation fail on empty values`(
username: String,
password: String,
roleList: String,
) {
// given
val request = Account.Request(
username = username,
password = password,
enabled = true,
roles = when {
roleList.isEmpty() -> emptyList()
else -> listOf(roleList)
},
)
// when
val result = webClient.post().uri("/account").bodyValue(request).exchange()
// then
result.expectStatus().isBadRequest
}
@Test
fun `fail username take`() {
// given
val request = Account.Request(
username = "user",
password = "new-pass",
enabled = true,
roles = listOf("ROLE_USER", "ROLE_TEST"),
)
// when
val result = webClient.post().uri("/account").bodyValue(request).exchange()
// then
result.expectStatus().isEqualTo(CONFLICT)
}
@Test
fun `fail password null`() {
// given
val request = Account.Request(
username = "user",
password = null,
enabled = true,
roles = listOf("ROLE_USER", "ROLE_TEST"),
)
// when
val result = webClient.post().uri("/account").bodyValue(request).exchange()
// then
result.expectStatus().isBadRequest
}
}
}

View File

@@ -0,0 +1,111 @@
package ltd.hlaeja.controller
import ltd.hlaeja.library.accountRegistry.Account
import ltd.hlaeja.test.container.PostgresContainer
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
class AccountsEndpoint {
@LocalServerPort
var port: Int = 0
lateinit var webClient: WebTestClient
@BeforeEach
fun setup() {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Test
fun `get accounts`() {
// when
val result = webClient.get().uri("/accounts").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Account.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(3)
}
}
@ParameterizedTest
@CsvSource(
value = [
"1,3",
"2,0",
],
)
fun `get accounts with pages`(page: Int, expected: Int) {
// when
val result = webClient.get().uri("/accounts/page-$page").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Account.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@Test
fun `get accounts with bad pages`() {
// when
val result = webClient.get().uri("/accounts/page-0").exchange()
// then
result.expectStatus().isBadRequest
}
@ParameterizedTest
@CsvSource(
value = [
"1,2,2",
"2,2,1",
"3,2,0",
"1,5,3",
"2,5,0",
],
)
fun `get accounts with pages and size to show`(page: Int, show: Int, expected: Int) {
// when
val result = webClient.get().uri("/accounts/page-$page/show-$show").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Account.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@ParameterizedTest
@CsvSource(
value = [
"1,0",
"0,1",
"0,0",
"1,-1",
"-1,1",
"-1,-1",
],
)
fun `get accounts with bad pages or bad size to show`(page: Int, show: Int) {
// when
val result = webClient.get().uri("/accounts/page-$page/show-$show").exchange()
// then
result.expectStatus().isBadRequest
}
}

View File

@@ -0,0 +1,116 @@
package ltd.hlaeja.controller
import org.assertj.core.api.Assertions.assertThat
import ltd.hlaeja.library.accountRegistry.Authentication
import ltd.hlaeja.test.compareToFile
import ltd.hlaeja.test.container.PostgresContainer
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class AuthenticationEndpoint {
@InjectSoftAssertions
lateinit var softly: SoftAssertions
@LocalServerPort
var port: Int = 0
lateinit var webClient: WebTestClient
@BeforeEach
fun setup() {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Test
fun `login as admin`() {
// given
val request = Authentication.Request(
username = "admin",
password = "pass",
)
// when
val result = webClient.post().uri("/authenticate").bodyValue(request).exchange()
// then
result.expectStatus().isOk()
.expectBody<Authentication.Response>()
.consumeWith { assertThat(it.responseBody?.token).compareToFile("authenticate/admin-token.data") }
}
@Test
fun `login as user`() {
// given
val request = Authentication.Request(
username = "user",
password = "pass",
)
// when
val result = webClient.post().uri("/authenticate").bodyValue(request).exchange()
// then
result.expectStatus().isOk()
.expectBody<Authentication.Response>()
.consumeWith { assertThat(it.responseBody?.token).compareToFile("authenticate/user-token.data") }
}
@Test
fun `login as disabled user`() {
// given
val request = Authentication.Request(
username = "disabled",
password = "pass",
)
// when
val result = webClient.post().uri("/authenticate").bodyValue(request).exchange()
// then
result.expectStatus().isEqualTo(HttpStatus.LOCKED)
}
@Test
fun `login as non-existent `() {
// given
val request = Authentication.Request(
username = "username",
password = "pass",
)
// when
val result = webClient.post().uri("/authenticate").bodyValue(request).exchange()
// then
result.expectStatus().isNotFound
}
@Test
fun `login as user bad password`() {
// given
val request = Authentication.Request(
username = "user",
password = "password",
)
// when
val result = webClient.post().uri("/authenticate").bodyValue(request).exchange()
// then
result.expectStatus().isUnauthorized
}
}

View File

@@ -6,3 +6,10 @@ spring:
url: r2dbc:postgresql://placeholder
username: placeholder
password: placeholder
container:
postgres:
version: postgres:17
init: postgres/schema.sql
before: postgres/data.sql
after: postgres/reset.sql

View File

@@ -0,0 +1 @@
eyJhbGciOiJSUzI1NiJ9.eyJpZCI6IjAwMDAwMDAwLTAwMDAtNzAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsInVzZXJuYW1lIjoiYWRtaW4iLCJyb2xlIjoiUk9MRV9BRE1JTiJ9.D6pK86XPWcdu1imV_y_4nAM6R4WEZvJpQ7oGaPAYe0_rg3UWdmVMa8Iw7L21bRgFoyIa7FQBwb_0AXojFVdb2mdOVDeGOwxQZAx23dwqeicOGd8yUMnuBaRSnd7z4P65KPMbbf0NOTQtho0Iv5mBAwFMJoF67sw-yntfx3cD_bfrI-Rf4oZaZsVn38Y2HJBe2sO2QI4e5_7s82ikxac416OX7PcIEgaf3IeEK1fSzSjRG_dyBGT_Jq_vAzVURsSu4ep976kI-k5ZXNE9EMxKu1S-n5c5eiaqo96ObnaSl4eWFik5q8vLhNLYIYO-bQi1xlJKnStwZqtUwlR763Gd5w

View File

@@ -0,0 +1 @@
eyJhbGciOiJSUzI1NiJ9.eyJpZCI6IjAwMDAwMDAwLTAwMDAtNzAwMC0wMDAwLTAwMDAwMDAwMDAwMiIsInVzZXJuYW1lIjoidXNlciIsInJvbGUiOiJST0xFX1VTRVIifQ.GvZIq0VF9xB8UY3PUGdnc6JNeUXtv4LzHJ56hWSeqUS6BXH0M_QJ5Lu9ndh9_P85CECp3eKrW4fKymGYe-NUXCtrzhr9-SSZLF6D7GRzAJ4yZjVRCOa_dgqe1RGuIZyZpli36z4NPqeBFqtHJ3Cs5rAI-WdvxGfWPgtM2kzpSJ_0zFihp9mVcZBlWP57HlN7-oKzDJWVpO2E17fWZTy-y4pdrIUsff63c256Cy8NhiAgux9aqZTdzaqp9TsXw59bRsS5d0YH7-gJuBd4xctZwgy_41BOcRk2q-nLyLZgWJs1wmCa_zaW0Fj6fjAsYvpdPNegkpIqrHJcQpGd7nE0KQ

View File

@@ -0,0 +1,5 @@
-- Test data
insert into public.accounts (id, created_at, updated_at, enabled, username, password, roles)
values ('00000000-0000-7000-0000-000000000001'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', true, 'admin', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_ADMIN'),
('00000000-0000-7000-0000-000000000002'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', true, 'user', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_USER'),
('00000000-0000-7000-0000-000000000003'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', false, 'disabled', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_USER');

View File

@@ -0,0 +1,11 @@
-- Disable triggers on the tables
ALTER TABLE accounts DISABLE TRIGGER ALL;
ALTER TABLE accounts_audit DISABLE TRIGGER ALL;
-- Truncate tables
TRUNCATE TABLE accounts_audit CASCADE;
TRUNCATE TABLE accounts CASCADE;
-- Enable triggers on the account table
ALTER TABLE accounts ENABLE TRIGGER ALL;
ALTER TABLE accounts_audit ENABLE TRIGGER ALL;

View File

@@ -0,0 +1,74 @@
-- FUNCTION: public.gen_uuid_v7(timestamp with time zone)
CREATE OR REPLACE FUNCTION public.gen_uuid_v7(p_timestamp timestamp with time zone)
RETURNS uuid
LANGUAGE 'sql'
COST 100
VOLATILE PARALLEL UNSAFE
AS
$BODY$
-- Replace the first 48 bits of a uuid v4 with the provided timestamp (in milliseconds) since 1970-01-01 UTC, and set the version to 7
SELECT encode(set_bit(set_bit(overlay(uuid_send(gen_random_uuid()) PLACING substring(int8send((extract(EPOCH FROM p_timestamp) * 1000):: BIGINT) FROM 3) FROM 1 FOR 6), 52, 1), 53, 1), 'hex') ::uuid;
$BODY$;
-- FUNCTION: public.gen_uuid_v7()
CREATE OR REPLACE FUNCTION public.gen_uuid_v7()
RETURNS uuid
LANGUAGE 'sql'
COST 100
VOLATILE PARALLEL UNSAFE
AS
$BODY$
SELECT gen_uuid_v7(clock_timestamp());
$BODY$;
-- Table: public.accounts
CREATE TABLE IF NOT EXISTS public.accounts
(
id UUID DEFAULT gen_uuid_v7(),
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
enabled boolean NOT NULL DEFAULT true,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
roles VARCHAR(255) NOT NULL,
CONSTRAINT pk_contact_types PRIMARY KEY (id)
);
-- Index: idx_accounts_username
CREATE INDEX IF NOT EXISTS idx_accounts_username
ON public.accounts USING btree (username COLLATE pg_catalog."default" ASC NULLS LAST)
TABLESPACE pg_default;
-- Table: public.accounts_audit
CREATE TABLE IF NOT EXISTS public.accounts_audit
(
id uuid NOT NULL,
timestamp timestamp with time zone NOT NULL,
enabled boolean NOT NULL,
username VARCHAR(50) NOT NULL,
password VARCHAR(255) NOT NULL,
roles VARCHAR(255) NOT NULL,
CONSTRAINT pk_accounts_audit PRIMARY KEY (id, timestamp)
) TABLESPACE pg_default;
-- FUNCTION: public.accounts_audit()
CREATE OR REPLACE FUNCTION public.accounts_audit()
RETURNS trigger
LANGUAGE 'plpgsql'
COST 100
VOLATILE NOT LEAKPROOF
AS
$BODY$
BEGIN
INSERT INTO accounts_audit (id, timestamp, enabled, username, password, roles)
VALUES (NEW.id, NEW.updated_at, NEW.enabled, NEW.username, NEW.password, NEW.roles);
RETURN NULL; -- result is ignored since this is an AFTER trigger
END;
$BODY$;
-- Trigger: accounts_audit_trigger
CREATE OR REPLACE TRIGGER accounts_audit_trigger
AFTER INSERT OR UPDATE
ON public.accounts
FOR EACH ROW
EXECUTE FUNCTION public.accounts_audit();

View File

@@ -9,13 +9,31 @@ import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.repository.AccountRepository
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.dao.DuplicateKeyException
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.test.StepVerifier
class AccountServiceTest {
companion object {
val account = UUID.fromString("00000000-0000-0000-0000-000000000002")
val accountEntity = AccountEntity(
account,
ZonedDateTime.now(),
ZonedDateTime.now(),
true,
"username",
"password",
"ROLE_TEST",
)
val accounts = Flux.just(
accountEntity.copy(username = "username1"),
accountEntity.copy(username = "username2"),
accountEntity.copy(username = "username3"),
)
}
private lateinit var accountRepository: AccountRepository
@@ -30,16 +48,6 @@ class AccountServiceTest {
@Test
fun `get account by id - success`() {
// given
val accountEntity = AccountEntity(
account,
ZonedDateTime.now(),
ZonedDateTime.now(),
true,
"username",
"password",
"ROLE_TEST",
)
every { accountRepository.findById(any(UUID::class)) } returns Mono.just(accountEntity)
// when
@@ -64,4 +72,175 @@ class AccountServiceTest {
// then
verify { accountRepository.findById(any(UUID::class)) }
}
@Test
fun `get account by username - success`() {
// given
every { accountRepository.findByUsername(any()) } returns Mono.just(accountEntity)
// when
StepVerifier.create(accountService.getUserByUsername("username"))
.expectNext(accountEntity)
.verifyComplete()
// then
verify { accountRepository.findByUsername(any()) }
}
@Test
fun `get account by username - fail does not exist`() {
// given
every { accountRepository.findByUsername(any()) } returns Mono.empty()
// when
StepVerifier.create(accountService.getUserByUsername("username"))
.expectError(ResponseStatusException::class.java)
.verify()
// then
verify { accountRepository.findByUsername(any()) }
}
@Test
fun `add account - success`() {
// given
every { accountRepository.save(any()) } returns Mono.just(accountEntity)
// when
StepVerifier.create(accountService.addAccount(accountEntity))
.expectNext(accountEntity)
.verifyComplete()
// then
verify { accountRepository.save(any()) }
}
@Test
fun `add account - fail duplicated user`() {
// given
every { accountRepository.save(any()) } returns Mono.error(DuplicateKeyException("Test"))
// when
StepVerifier.create(accountService.addAccount(accountEntity))
.expectErrorMatches { error ->
error is ResponseStatusException && error.statusCode == CONFLICT
}
.verify()
// then
verify { accountRepository.save(any()) }
}
@Test
fun `add account - fail`() {
// given
every { accountRepository.save(any()) } returns Mono.error(RuntimeException())
// when
StepVerifier.create(accountService.addAccount(accountEntity))
.expectErrorMatches { error ->
error is ResponseStatusException && error.statusCode == BAD_REQUEST
}
.verify()
// then
verify { accountRepository.save(any()) }
}
@Test
fun `get accounts - limit size success`() {
// given
every { accountRepository.findAll() } returns accounts
// when
StepVerifier.create(accountService.getAccounts(1, 2))
.expectNextMatches { accountEntity ->
accountEntity.username == "username1"
}
.expectNextMatches { accountEntity ->
accountEntity.username == "username2"
}
.verifyComplete()
// then
verify { accountRepository.findAll() }
}
@Test
fun `get accounts - negative page fail`() {
// given
every { accountRepository.findAll() } returns accounts
// when
StepVerifier.create(accountService.getAccounts(-1, 10))
.expectErrorMatches { error ->
error is ResponseStatusException && error.statusCode == BAD_REQUEST
}
.verify()
// then
verify { accountRepository.findAll() }
}
@Test
fun `get accounts - negative size fail`() {
// given
every { accountRepository.findAll() } returns accounts
// when
StepVerifier.create(accountService.getAccounts(1, -10))
.expectErrorMatches { error ->
error is ResponseStatusException && error.statusCode == BAD_REQUEST
}
.verify()
// then
verify { accountRepository.findAll() }
}
@Test
fun `update account - success`() {
// given
every { accountRepository.save(any()) } returns Mono.just(accountEntity)
// when
StepVerifier.create(accountService.updateAccount(accountEntity))
.expectNext(accountEntity)
.verifyComplete()
// then
verify { accountRepository.save(any()) }
}
@Test
fun `update account - fail duplicated user`() {
// given
every { accountRepository.save(any()) } returns Mono.error(DuplicateKeyException("Test"))
// when
StepVerifier.create(accountService.updateAccount(accountEntity))
.expectErrorMatches { error ->
error is ResponseStatusException && error.statusCode == CONFLICT
}
.verify()
// then
verify { accountRepository.save(any()) }
}
@Test
fun `update account - fail`() {
// given
every { accountRepository.save(any()) } returns Mono.error(RuntimeException())
// when
StepVerifier.create(accountService.updateAccount(accountEntity))
.expectErrorMatches { error ->
error is ResponseStatusException && error.statusCode == BAD_REQUEST
}
.verify()
// then
verify { accountRepository.save(any()) }
}
}

View File

@@ -1,6 +1,7 @@
package ltd.hlaeja.util
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import java.time.LocalDateTime
@@ -9,23 +10,46 @@ import java.time.ZonedDateTime
import java.util.UUID
import kotlin.test.assertFailsWith
import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.library.accountRegistry.Account
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.web.server.ResponseStatusException
@ExtendWith(SoftAssertionsExtension::class)
class MappingKtTest {
companion object {
val account = UUID.fromString("00000000-0000-0000-0000-000000000002")
val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), ZoneId.of("UTC"))
val account = UUID.fromString("00000000-0000-0000-0000-000000000000")
val utc = ZoneId.of("UTC")
val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), utc)
val originalTimestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(1000, 1, 1, 0, 0, 0, 1), utc)
val originalUser = AccountEntity(
id = account,
username = "username",
enabled = true,
roles = "ROLE_TEST",
password = "password",
createdAt = originalTimestamp,
updatedAt = originalTimestamp,
)
}
@InjectSoftAssertions
lateinit var softly: SoftAssertions
private val passwordEncoder: BCryptPasswordEncoder = mockk()
@BeforeEach
fun setUp() {
mockkStatic(ZonedDateTime::class)
every { ZonedDateTime.now() } returns timestamp
every { passwordEncoder.encode(any()) } answers { firstArg<String>() }
}
@AfterEach
@@ -38,7 +62,7 @@ class MappingKtTest {
@Test
fun `test toAccountResponse when id is not null`() {
// Arrange
// given
val accountEntity = AccountEntity(
id = account,
createdAt = timestamp,
@@ -49,10 +73,10 @@ class MappingKtTest {
roles = "ROLE_ADMIN,ROLE_USER",
)
// Act
// when
val result = accountEntity.toAccountResponse()
// Assert
// then
assertThat(result.id).isEqualTo(accountEntity.id)
assertThat(result.timestamp).isEqualTo(accountEntity.updatedAt)
assertThat(result.enabled).isEqualTo(accountEntity.enabled)
@@ -62,7 +86,7 @@ class MappingKtTest {
@Test
fun `test toAccountResponse when id is null`() {
// Arrange
// given
val accountEntity = AccountEntity(
id = null,
createdAt = timestamp,
@@ -73,10 +97,126 @@ class MappingKtTest {
roles = "ROLE_ADMIN,ROLE_USER",
)
// Act and Assert
// when exception
assertFailsWith<ResponseStatusException> {
accountEntity.toAccountResponse()
}
}
}
@Nested
inner class CreateAccountMapping {
@Test
fun `all fields changed`() {
// given
val request = Account.Request(
username = "username",
enabled = false,
roles = listOf("ROLE_TEST"),
password = "password",
)
// when
val updatedUser = request.toAccountEntity(passwordEncoder)
// then
softly.assertThat(updatedUser.id).isNull()
softly.assertThat(updatedUser.createdAt).isEqualTo(timestamp)
softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp)
softly.assertThat(updatedUser.enabled).isEqualTo(request.enabled)
softly.assertThat(updatedUser.username).isEqualTo(request.username)
softly.assertThat(updatedUser.password).isEqualTo(request.password)
softly.assertThat(updatedUser.roles).isEqualTo("ROLE_TEST")
}
@Test
fun `provided password is null`() {
// Given
val request = Account.Request(
username = "username",
enabled = false,
roles = listOf("ROLE_TEST"),
password = null,
)
// when exception
assertFailsWith<ResponseStatusException> {
request.toAccountEntity(passwordEncoder)
}
}
}
@Nested
inner class UpdateAccountMapping {
@Test
fun `all fields changed`() {
// Given
val request = Account.Request(
username = "new-username",
enabled = false,
roles = listOf("ROLE_MAGIC"),
password = "new-password",
)
// When
val updatedUser = originalUser.updateAccountEntity(request, passwordEncoder)
// Then
softly.assertThat(updatedUser.id).isEqualTo(originalUser.id)
softly.assertThat(updatedUser.createdAt).isEqualTo(originalUser.createdAt)
softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp)
softly.assertThat(updatedUser.enabled).isEqualTo(request.enabled)
softly.assertThat(updatedUser.username).isEqualTo(request.username)
softly.assertThat(updatedUser.password).isEqualTo(request.password)
softly.assertThat(updatedUser.roles).isEqualTo("ROLE_MAGIC")
}
@Test
fun `provided password is null`() {
// Given
val request = Account.Request(
username = originalUser.username,
enabled = originalUser.enabled,
roles = originalUser.roles.split(","),
password = null,
)
// When
val updatedUser = originalUser.updateAccountEntity(request, passwordEncoder)
// Then
softly.assertThat(updatedUser.id).isEqualTo(account)
softly.assertThat(updatedUser.createdAt).isEqualTo(originalUser.createdAt)
softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp)
softly.assertThat(updatedUser.enabled).isEqualTo(request.enabled)
softly.assertThat(updatedUser.username).isEqualTo(request.username)
softly.assertThat(updatedUser.password).isEqualTo(originalUser.password)
softly.assertThat(updatedUser.roles).isEqualTo(originalUser.roles)
}
@Test
fun `roles changed from single to multiple`() {
// Given
val request = Account.Request(
username = originalUser.username,
enabled = originalUser.enabled,
roles = listOf("ROLE_MAGIC", "ROLE_TEST"),
password = null,
)
// When
val updatedUser = originalUser.updateAccountEntity(request, passwordEncoder)
// Then
softly.assertThat(updatedUser.id).isEqualTo(originalUser.id)
softly.assertThat(updatedUser.createdAt).isEqualTo(originalUser.createdAt)
softly.assertThat(updatedUser.updatedAt).isEqualTo(timestamp)
softly.assertThat(updatedUser.enabled).isEqualTo(originalUser.enabled)
softly.assertThat(updatedUser.username).isEqualTo(originalUser.username)
softly.assertThat(updatedUser.password).isEqualTo(originalUser.password)
softly.assertThat(updatedUser.roles).isEqualTo("ROLE_MAGIC,ROLE_TEST")
}
}
}

View File

@@ -0,0 +1,108 @@
package ltd.hlaeja.validator
import io.mockk.mockk
import ltd.hlaeja.library.accountRegistry.Account
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class AccountValidatorTest {
val validator = AccountValidator()
@Test
fun `validate account - success all values`() {
// given
val request = Account.Request(
username = "validUser",
password = "strongPassword",
enabled = true,
roles = listOf("USER", "TEST"),
)
// when
val result = validator.isValid(request, mockk())
// then
assertThat(result).isTrue
}
@Test
fun `validate account - success password null`() {
// given
val request = Account.Request(
username = "validUser",
password = null,
enabled = true,
roles = listOf("USER"),
)
// when
val result = validator.isValid(request, mockk())
// then
assertThat(result).isTrue
}
@Test
fun `validate account - failed username empty`() {
// given
val request = Account.Request(
username = "",
password = "strongPassword",
enabled = true,
roles = listOf("USER"),
)
// when
val result = validator.isValid(request, mockk())
// then
assertThat(result).isFalse
}
@Test
fun `validate account - failed password empty`() {
// given
val request = Account.Request(
username = "validUser",
password = "",
enabled = true,
roles = listOf("USER"),
)
// when
val result = validator.isValid(request, mockk())
// then
assertThat(result).isFalse
}
@Test
fun `validate account - failed roles empty`() {
// given
val request = Account.Request(
username = "validUser",
password = "",
enabled = true,
roles = emptyList(),
)
// when
val result = validator.isValid(request, mockk())
// then
assertThat(result).isFalse
}
@Test
fun `validate account - success wrong data type`() {
// given
val request = "A string"
// when
val result = validator.isValid(request, mockk())
// then
assertThat(result).isTrue
}
}