11 Commits

Author SHA1 Message Date
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
26 changed files with 1287 additions and 41 deletions

View File

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

View File

@@ -8,14 +8,16 @@ plugins {
} }
dependencies { dependencies {
implementation(hlaeja.fasterxml.jackson.module.kotlin)
implementation(hlaeja.kotlin.logging) implementation(hlaeja.kotlin.logging)
implementation(hlaeja.kotlin.reflect) implementation(hlaeja.kotlin.reflect)
implementation(hlaeja.kotlinx.coroutines) implementation(hlaeja.kotlinx.coroutines)
implementation(hlaeja.library.hlaeja.common.messages) implementation(hlaeja.library.common.messages)
implementation(hlaeja.library.hlaeja.jwt) implementation(hlaeja.library.jwt)
implementation(hlaeja.springboot.starter.actuator) implementation(hlaeja.springboot.starter.actuator)
implementation(hlaeja.springboot.starter.r2dbc) implementation(hlaeja.springboot.starter.r2dbc)
implementation(hlaeja.springboot.starter.security) implementation(hlaeja.springboot.starter.security)
implementation(hlaeja.springboot.starter.validation)
implementation(hlaeja.springboot.starter.webflux) implementation(hlaeja.springboot.starter.webflux)
runtimeOnly(hlaeja.postgresql) runtimeOnly(hlaeja.postgresql)
@@ -29,6 +31,15 @@ dependencies {
testImplementation(hlaeja.springboot.starter.test) testImplementation(hlaeja.springboot.starter.test)
testRuntimeOnly(hlaeja.junit.platform.launcher) testRuntimeOnly(hlaeja.junit.platform.launcher)
integrationTestImplementation(hlaeja.assertj.core)
integrationTestImplementation(hlaeja.library.test)
integrationTestImplementation(hlaeja.projectreactor.reactor.test)
integrationTestImplementation(hlaeja.kotlin.test.junit5)
integrationTestImplementation(hlaeja.kotlinx.coroutines.test)
integrationTestImplementation(hlaeja.springboot.starter.test)
integrationTestRuntimeOnly(hlaeja.junit.platform.launcher)
} }
group = "ltd.hlaeja" group = "ltd.hlaeja"

View File

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

View File

@@ -1,7 +1,7 @@
### get user by id ### get user by id
GET {{hostname}}/account-00000000-0000-7000-0000-000000000001 GET {{hostname}}/account-00000000-0000-7000-0000-000000000001
### Get admin information ### add user
POST {{hostname}}/account POST {{hostname}}/account
Content-Type: application/json Content-Type: application/json
@@ -14,3 +14,28 @@ Content-Type: application/json
"ROLE_TEST" "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

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

@@ -0,0 +1,8 @@
jwt:
private-key: cert/valid-private-key.pem
spring:
r2dbc:
url: r2dbc:pool:postgresql://localhost:5432/test
username: test
password: test

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;
TRUNCATE TABLE accounts;
-- Enable triggers on the account table
ALTER TABLE accounts ENABLE TRIGGER ALL;
ALTER TABLE accounts_audit ENABLE TRIGGER ALL;

View File

@@ -0,0 +1,79 @@
-- 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();
-- 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

@@ -1,16 +1,24 @@
package ltd.hlaeja.controller package ltd.hlaeja.controller
import java.util.UUID import java.util.UUID
import ltd.hlaeja.validator.ValidAccount
import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.library.accountRegistry.Account import ltd.hlaeja.library.accountRegistry.Account
import ltd.hlaeja.service.AccountService import ltd.hlaeja.service.AccountService
import ltd.hlaeja.util.toAccountEntity import ltd.hlaeja.util.toAccountEntity
import ltd.hlaeja.util.toAccountResponse 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.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping 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.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
@RestController @RestController
@@ -25,9 +33,30 @@ class AccountController(
): Mono<Account.Response> = accountService.getUserById(uuid) ): Mono<Account.Response> = accountService.getUserById(uuid)
.map { it.toAccountResponse() } .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") @PostMapping("/account")
@ResponseStatus(CREATED)
fun addAccount( fun addAccount(
@RequestBody request: Account.Request, @RequestBody @ValidAccount request: Account.Request,
): Mono<Account.Response> = accountService.addAccount(request.toAccountEntity(passwordEncoder)) ): Mono<Account.Response> = accountService.addAccount(request.toAccountEntity(passwordEncoder))
.map { it.toAccountResponse() } .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.entity.AccountEntity
import ltd.hlaeja.repository.AccountRepository import ltd.hlaeja.repository.AccountRepository
import org.springframework.dao.DuplicateKeyException 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.stereotype.Service
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -21,23 +24,40 @@ class AccountService(
uuid: UUID, uuid: UUID,
): Mono<AccountEntity> = accountRepository.findById(uuid) ): Mono<AccountEntity> = accountRepository.findById(uuid)
.doOnNext { log.debug { "Get account ${it.id}" } } .doOnNext { log.debug { "Get account ${it.id}" } }
.switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND))) .switchIfEmpty(Mono.error(ResponseStatusException(NOT_FOUND)))
fun getUserByUsername( fun getUserByUsername(
username: String, username: String,
): Mono<AccountEntity> = accountRepository.findByUsername(username) ): Mono<AccountEntity> = accountRepository.findByUsername(username)
.doOnNext { log.debug { "Get account ${it.id} for username $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( fun addAccount(
accountEntity: AccountEntity, accountEntity: AccountEntity,
): Mono<AccountEntity> = accountRepository.save(accountEntity) ): Mono<AccountEntity> = accountRepository.save(accountEntity)
.doOnNext { log.debug { "Added new type: $it.id" } } .doOnNext { log.debug { "Added new type: $it.id" } }
.onErrorResume { .onErrorResume(::onSaveError)
log.debug { it.localizedMessage }
when { fun getAccounts(page: Int, size: Int): Flux<AccountEntity> = try {
it is DuplicateKeyException -> Mono.error(ResponseStatusException(HttpStatus.CONFLICT)) accountRepository.findAll()
else -> Mono.error(ResponseStatusException(HttpStatus.BAD_REQUEST)) .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.entity.AccountEntity
import ltd.hlaeja.library.accountRegistry.Account import ltd.hlaeja.library.accountRegistry.Account
import org.springframework.http.HttpStatus.EXPECTATION_FAILED import org.springframework.http.HttpStatus.EXPECTATION_FAILED
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
@@ -23,6 +24,21 @@ fun Account.Request.toAccountEntity(
updatedAt = ZonedDateTime.now(), updatedAt = ZonedDateTime.now(),
enabled = enabled, enabled = enabled,
username = username, username = username,
password = passwordEncoder.encode(password), password = password
?.let { passwordEncoder.encode(it) }
?: throw ResponseStatusException(BAD_REQUEST),
roles = roles.joinToString(","), 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

@@ -9,13 +9,31 @@ import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.repository.AccountRepository import ltd.hlaeja.repository.AccountRepository
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test 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 org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.test.StepVerifier import reactor.test.StepVerifier
class AccountServiceTest { class AccountServiceTest {
companion object { companion object {
val account = UUID.fromString("00000000-0000-0000-0000-000000000002") 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 private lateinit var accountRepository: AccountRepository
@@ -30,16 +48,6 @@ class AccountServiceTest {
@Test @Test
fun `get account by id - success`() { fun `get account by id - success`() {
// given // given
val accountEntity = AccountEntity(
account,
ZonedDateTime.now(),
ZonedDateTime.now(),
true,
"username",
"password",
"ROLE_TEST",
)
every { accountRepository.findById(any(UUID::class)) } returns Mono.just(accountEntity) every { accountRepository.findById(any(UUID::class)) } returns Mono.just(accountEntity)
// when // when
@@ -64,4 +72,175 @@ class AccountServiceTest {
// then // then
verify { accountRepository.findById(any(UUID::class)) } 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 package ltd.hlaeja.util
import io.mockk.every import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -9,23 +10,46 @@ import java.time.ZonedDateTime
import java.util.UUID import java.util.UUID
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import ltd.hlaeja.entity.AccountEntity import ltd.hlaeja.entity.AccountEntity
import ltd.hlaeja.library.accountRegistry.Account
import org.assertj.core.api.Assertions.assertThat 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.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test 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 import org.springframework.web.server.ResponseStatusException
@ExtendWith(SoftAssertionsExtension::class)
class MappingKtTest { class MappingKtTest {
companion object { companion object {
val account = UUID.fromString("00000000-0000-0000-0000-000000000002") val account = UUID.fromString("00000000-0000-0000-0000-000000000000")
val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), ZoneId.of("UTC")) 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 @BeforeEach
fun setUp() { fun setUp() {
mockkStatic(ZonedDateTime::class) mockkStatic(ZonedDateTime::class)
every { ZonedDateTime.now() } returns timestamp every { ZonedDateTime.now() } returns timestamp
every { passwordEncoder.encode(any()) } answers { firstArg<String>() }
} }
@AfterEach @AfterEach
@@ -38,7 +62,7 @@ class MappingKtTest {
@Test @Test
fun `test toAccountResponse when id is not null`() { fun `test toAccountResponse when id is not null`() {
// Arrange // given
val accountEntity = AccountEntity( val accountEntity = AccountEntity(
id = account, id = account,
createdAt = timestamp, createdAt = timestamp,
@@ -49,10 +73,10 @@ class MappingKtTest {
roles = "ROLE_ADMIN,ROLE_USER", roles = "ROLE_ADMIN,ROLE_USER",
) )
// Act // when
val result = accountEntity.toAccountResponse() val result = accountEntity.toAccountResponse()
// Assert // then
assertThat(result.id).isEqualTo(accountEntity.id) assertThat(result.id).isEqualTo(accountEntity.id)
assertThat(result.timestamp).isEqualTo(accountEntity.updatedAt) assertThat(result.timestamp).isEqualTo(accountEntity.updatedAt)
assertThat(result.enabled).isEqualTo(accountEntity.enabled) assertThat(result.enabled).isEqualTo(accountEntity.enabled)
@@ -62,7 +86,7 @@ class MappingKtTest {
@Test @Test
fun `test toAccountResponse when id is null`() { fun `test toAccountResponse when id is null`() {
// Arrange // given
val accountEntity = AccountEntity( val accountEntity = AccountEntity(
id = null, id = null,
createdAt = timestamp, createdAt = timestamp,
@@ -73,10 +97,126 @@ class MappingKtTest {
roles = "ROLE_ADMIN,ROLE_USER", roles = "ROLE_ADMIN,ROLE_USER",
) )
// Act and Assert // when exception
assertFailsWith<ResponseStatusException> { assertFailsWith<ResponseStatusException> {
accountEntity.toAccountResponse() 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
}
}

View File

@@ -1,8 +0,0 @@
jwt:
private-key: cert/valid-private-key.pem
spring:
r2dbc:
url: r2dbc:postgresql://placeholder
username: placeholder
password: placeholder