From 6aee16c4a21ab3dbaf6267a6e63bc4351bce07d9 Mon Sep 17 00:00:00 2001 From: Swordsteel Date: Sun, 29 Dec 2024 07:07:44 +0100 Subject: [PATCH] Get Account - add AccountController - add AccountEntity toAccountResponse in Mapping.kt - add AccountService - add AccountRepository - add AccountEntity --- build.gradle.kts | 1 + http/account.http | 2 + sql/002-account.sql | 35 ++++++++ sql/003-account_audit.sql | 26 ++++++ sql/004-account_audit_function.sql | 29 +++++++ .../hlaeja/controller/AccountController.kt | 22 +++++ .../kotlin/ltd/hlaeja/entity/AccountEntity.kt | 18 ++++ .../hlaeja/repository/AccountRepository.kt | 9 ++ .../ltd/hlaeja/service/AccountService.kt | 24 ++++++ src/main/kotlin/ltd/hlaeja/util/Mapping.kt | 15 ++++ .../ltd/hlaeja/service/AccountServiceTest.kt | 67 +++++++++++++++ .../kotlin/ltd/hlaeja/util/MappingKtTest.kt | 82 +++++++++++++++++++ 12 files changed, 330 insertions(+) create mode 100644 http/account.http create mode 100644 sql/002-account.sql create mode 100644 sql/003-account_audit.sql create mode 100644 sql/004-account_audit_function.sql create mode 100644 src/main/kotlin/ltd/hlaeja/controller/AccountController.kt create mode 100644 src/main/kotlin/ltd/hlaeja/entity/AccountEntity.kt create mode 100644 src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt create mode 100644 src/main/kotlin/ltd/hlaeja/service/AccountService.kt create mode 100644 src/main/kotlin/ltd/hlaeja/util/Mapping.kt create mode 100644 src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt create mode 100644 src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 21069aa..658acb2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(hlaeja.kotlin.logging) implementation(hlaeja.kotlin.reflect) implementation(hlaeja.kotlinx.coroutines) + implementation(hlaeja.library.hlaeja.common.messages) implementation(hlaeja.springboot.starter.actuator) implementation(hlaeja.springboot.starter.r2dbc) implementation(hlaeja.springboot.starter.webflux) diff --git a/http/account.http b/http/account.http new file mode 100644 index 0000000..51aa14a --- /dev/null +++ b/http/account.http @@ -0,0 +1,2 @@ +### get user by id +GET {{hostname}}/account-00000000-0000-7000-0000-000000000001 diff --git a/sql/002-account.sql b/sql/002-account.sql new file mode 100644 index 0000000..88c0273 --- /dev/null +++ b/sql/002-account.sql @@ -0,0 +1,35 @@ +-- Table: public.accounts +-- DROP TABLE IF EXISTS 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) +); + +ALTER TABLE IF EXISTS public.accounts + OWNER to role_administrator; + + +-- Index: idx_accounts_username +-- DROP INDEX IF EXISTS public.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; + + +-- Revoke all permissions from existing roles +REVOKE ALL ON TABLE public.accounts FROM role_administrator, role_maintainer, role_support, services; + + +-- Grant appropriate permissions +GRANT ALL ON TABLE public.accounts TO role_administrator; +GRANT SELECT, INSERT, UPDATE ON TABLE public.accounts TO role_maintainer, services; +GRANT SELECT ON TABLE public.accounts TO role_support; diff --git a/sql/003-account_audit.sql b/sql/003-account_audit.sql new file mode 100644 index 0000000..709153f --- /dev/null +++ b/sql/003-account_audit.sql @@ -0,0 +1,26 @@ +-- Table: public.accounts_audit +-- DROP TABLE IF EXISTS 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; + +ALTER TABLE IF EXISTS public.accounts_audit + OWNER to role_administrator; + + +-- Revoke all permissions from existing roles +REVOKE ALL ON TABLE public.accounts_audit FROM role_administrator, role_maintainer, role_support, services; + + +-- Grant appropriate permissions to each role +GRANT ALL ON TABLE public.accounts_audit TO role_administrator; +GRANT SELECT, INSERT ON TABLE public.accounts_audit TO services; +GRANT SELECT ON TABLE public.accounts_audit TO role_maintainer, role_support; diff --git a/sql/004-account_audit_function.sql b/sql/004-account_audit_function.sql new file mode 100644 index 0000000..fde8eb5 --- /dev/null +++ b/sql/004-account_audit_function.sql @@ -0,0 +1,29 @@ +-- FUNCTION: public.accounts_audit() +-- DROP FUNCTION IF EXISTS 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$; + +ALTER FUNCTION public.accounts_audit() + OWNER TO role_administrator; + + +-- Trigger: accounts_audit_trigger +-- DROP TRIGGER IF EXISTS accounts_audit_trigger ON public.accounts; + +CREATE OR REPLACE TRIGGER accounts_audit_trigger + AFTER INSERT OR UPDATE + ON public.accounts + FOR EACH ROW +EXECUTE FUNCTION public.accounts_audit(); diff --git a/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt b/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt new file mode 100644 index 0000000..a75a6dc --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt @@ -0,0 +1,22 @@ +package ltd.hlaeja.controller + +import java.util.UUID +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.RestController +import reactor.core.publisher.Mono + +@RestController +class AccountController( + private val accountService: AccountService, +) { + + @GetMapping("/account-{uuid}") + fun getAccount( + @PathVariable uuid: UUID, + ): Mono = accountService.getUserById(uuid) + .map { it.toAccountResponse() } +} diff --git a/src/main/kotlin/ltd/hlaeja/entity/AccountEntity.kt b/src/main/kotlin/ltd/hlaeja/entity/AccountEntity.kt new file mode 100644 index 0000000..96b0977 --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/entity/AccountEntity.kt @@ -0,0 +1,18 @@ +package ltd.hlaeja.entity + +import java.time.ZonedDateTime +import java.util.UUID +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("accounts") +data class AccountEntity( + @Id + val id: UUID? = null, + val createdAt: ZonedDateTime, + val updatedAt: ZonedDateTime, + val enabled: Boolean, + val username: String, + val password: String, + val roles: String, +) diff --git a/src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt b/src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt new file mode 100644 index 0000000..a1f528e --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/repository/AccountRepository.kt @@ -0,0 +1,9 @@ +package ltd.hlaeja.repository + +import java.util.UUID +import ltd.hlaeja.entity.AccountEntity +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface AccountRepository : ReactiveCrudRepository diff --git a/src/main/kotlin/ltd/hlaeja/service/AccountService.kt b/src/main/kotlin/ltd/hlaeja/service/AccountService.kt new file mode 100644 index 0000000..1dd646a --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/service/AccountService.kt @@ -0,0 +1,24 @@ +package ltd.hlaeja.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.UUID +import ltd.hlaeja.entity.AccountEntity +import ltd.hlaeja.repository.AccountRepository +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import reactor.core.publisher.Mono + +private val log = KotlinLogging.logger {} + +@Service +class AccountService( + private val accountRepository: AccountRepository, +) { + + fun getUserById( + uuid: UUID, + ): Mono = accountRepository.findById(uuid) + .doOnNext { log.debug { "Get account ${it.id}" } } + .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND))) +} diff --git a/src/main/kotlin/ltd/hlaeja/util/Mapping.kt b/src/main/kotlin/ltd/hlaeja/util/Mapping.kt new file mode 100644 index 0000000..5b1f3b1 --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/util/Mapping.kt @@ -0,0 +1,15 @@ +package ltd.hlaeja.util + +import ltd.hlaeja.entity.AccountEntity +import ltd.hlaeja.library.accountRegistry.Account +import org.springframework.http.HttpStatus.EXPECTATION_FAILED +import org.springframework.web.server.ResponseStatusException + +fun AccountEntity.toAccountResponse(): Account.Response = Account.Response( + id ?: throw ResponseStatusException(EXPECTATION_FAILED), + updatedAt, + enabled, + username, + roles.split(","), +) + diff --git a/src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt b/src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt new file mode 100644 index 0000000..70e4abf --- /dev/null +++ b/src/test/kotlin/ltd/hlaeja/service/AccountServiceTest.kt @@ -0,0 +1,67 @@ +package ltd.hlaeja.service + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.ZonedDateTime +import java.util.UUID +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.web.server.ResponseStatusException +import reactor.core.publisher.Mono +import reactor.test.StepVerifier + +class AccountServiceTest { + companion object { + val account = UUID.fromString("00000000-0000-0000-0000-000000000002") + } + + private lateinit var accountRepository: AccountRepository + private lateinit var accountService: AccountService + + @BeforeEach + fun setup() { + accountRepository = mockk() + accountService = AccountService(accountRepository) + } + + @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 + StepVerifier.create(accountService.getUserById(account)) + .expectNext(accountEntity) + .verifyComplete() + + // then + verify { accountRepository.findById(any(UUID::class)) } + } + + @Test + fun `get account by id - fail does not exist`() { + // given + every { accountRepository.findById(any(UUID::class)) } returns Mono.empty() + + // when + StepVerifier.create(accountService.getUserById(account)) + .expectError(ResponseStatusException::class.java) + .verify() + + // then + verify { accountRepository.findById(any(UUID::class)) } + } +} diff --git a/src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt b/src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt new file mode 100644 index 0000000..cc67a36 --- /dev/null +++ b/src/test/kotlin/ltd/hlaeja/util/MappingKtTest.kt @@ -0,0 +1,82 @@ +package ltd.hlaeja.util + +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.util.UUID +import kotlin.test.assertFailsWith +import ltd.hlaeja.entity.AccountEntity +import org.assertj.core.api.Assertions.assertThat +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.springframework.web.server.ResponseStatusException + +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")) + } + + @BeforeEach + fun setUp() { + mockkStatic(ZonedDateTime::class) + every { ZonedDateTime.now() } returns timestamp + } + + @AfterEach + fun tearDown() { + unmockkStatic(ZonedDateTime::class) + } + + @Nested + inner class AccountMapping { + + @Test + fun `test toAccountResponse when id is not null`() { + // Arrange + val accountEntity = AccountEntity( + id = account, + createdAt = timestamp, + updatedAt = timestamp, + enabled = true, + username = "username", + password = "password", + roles = "ROLE_ADMIN,ROLE_USER", + ) + + // Act + val result = accountEntity.toAccountResponse() + + // Assert + assertThat(result.id).isEqualTo(accountEntity.id) + assertThat(result.timestamp).isEqualTo(accountEntity.updatedAt) + assertThat(result.enabled).isEqualTo(accountEntity.enabled) + assertThat(result.username).isEqualTo(accountEntity.username) + assertThat(result.roles).contains("ROLE_ADMIN", "ROLE_USER") + } + + @Test + fun `test toAccountResponse when id is null`() { + // Arrange + val accountEntity = AccountEntity( + id = null, + createdAt = timestamp, + updatedAt = timestamp, + enabled = true, + username = "username", + password = "password", + roles = "ROLE_ADMIN,ROLE_USER", + ) + + // Act and Assert + assertFailsWith { + accountEntity.toAccountResponse() + } + } + } +}