From d432f1d1a3766fd9fa3e65b617fa2e6511242ee8 Mon Sep 17 00:00:00 2001 From: Sean Starkey Date: Sun, 31 May 2026 17:10:49 -0600 Subject: [PATCH] Add DTO, exceptions, Flyway config, JPA file and test that allows user registration. --- scripts/register | 5 + .../notesvault/auth/AuthController.java | 71 +++++++++++ .../notesvault/config/SecurityConfig.java | 44 +++++++ .../notesvault/dto/AuthResponse.java | 22 ++++ .../notesvault/dto/ErrorResponse.java | 13 ++ .../notesvault/dto/LoginRequest.java | 24 ++++ .../notesvault/dto/RegisterRequest.java | 24 ++++ .../notesvault/dto/UserResponse.java | 21 +++ .../dto/ValidationErrorResponse.java | 19 +++ .../exception/ConflictException.java | 25 ++++ .../exception/ForbiddenException.java | 17 +++ .../exception/GlobalExceptionHandler.java | 120 ++++++++++++++++++ .../exception/NotFoundException.java | 17 +++ .../notesvault/repository/UserRepository.java | 25 ++++ src/main/resources/db/migration/V1__init.sql | 51 ++++++++ .../notesvault/auth/AuthControllerTest.java | 82 ++++++++++++ 16 files changed, 580 insertions(+) create mode 100755 scripts/register create mode 100644 src/main/java/com/seanstarkey/notesvault/auth/AuthController.java create mode 100644 src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/AuthResponse.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/ErrorResponse.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/LoginRequest.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/RegisterRequest.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/UserResponse.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/ValidationErrorResponse.java create mode 100644 src/main/java/com/seanstarkey/notesvault/exception/ConflictException.java create mode 100644 src/main/java/com/seanstarkey/notesvault/exception/ForbiddenException.java create mode 100644 src/main/java/com/seanstarkey/notesvault/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/seanstarkey/notesvault/exception/NotFoundException.java create mode 100644 src/main/java/com/seanstarkey/notesvault/repository/UserRepository.java create mode 100644 src/main/resources/db/migration/V1__init.sql create mode 100644 src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java diff --git a/scripts/register b/scripts/register new file mode 100755 index 0000000..2ad040a --- /dev/null +++ b/scripts/register @@ -0,0 +1,5 @@ +#!/bin/bash + +curl -s -X POST http://localhost:8080/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username": "starkey", "password": "password"}' \ No newline at end of file diff --git a/src/main/java/com/seanstarkey/notesvault/auth/AuthController.java b/src/main/java/com/seanstarkey/notesvault/auth/AuthController.java new file mode 100644 index 0000000..095ebf2 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/auth/AuthController.java @@ -0,0 +1,71 @@ +/** + * AuthController.java + * + * Controls REST messages starting with /auth. + */ +package com.seanstarkey.notesvault.auth; + +import com.seanstarkey.notesvault.dto.RegisterRequest; +import com.seanstarkey.notesvault.dto.UserResponse; +import com.seanstarkey.notesvault.entity.User; +import com.seanstarkey.notesvault.exception.ConflictException; +import com.seanstarkey.notesvault.repository.UserRepository; +import jakarta.validation.Valid; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + /** + * @param userRepository repository used to create and look up user accounts + * @param passwordEncoder encoder used to hash plaintext registration passwords + */ + public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + /** + * Registers a new user account with a unique username and BCrypt-hashed password. + * + * @param request validated registration payload containing username and password + * @return 201 Created with the public user representation in the response body + * @throws ConflictException when the requested username is already registered + */ + @PostMapping("/register") + @Transactional + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + if (userRepository.existsByUsername(request.username())) { + throw new ConflictException("Username is already registered"); + } + + User user = new User(); + user.setUsername(request.username()); + user.setPasswordHash(passwordEncoder.encode(request.password())); + + try { + User savedUser = userRepository.saveAndFlush(user); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(toResponse(savedUser)); + } catch (DataIntegrityViolationException ex) { + throw new ConflictException("Username is already registered", ex); + } + } + + private UserResponse toResponse(User user) { + return new UserResponse(user.getId(), user.getUsername(), user.getCreatedAt()); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java b/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java new file mode 100644 index 0000000..b167e4d --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java @@ -0,0 +1,44 @@ +/** + * SecurityConfig.java + */ +package com.seanstarkey.notesvault.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Provides the password encoder bean used to hash and verify user passwords. + */ +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/auth/register").permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .build(); + } + + /** + * Creates the BCrypt-backed password encoder. + * + * @return password encoder for BCrypt hashes + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/AuthResponse.java b/src/main/java/com/seanstarkey/notesvault/dto/AuthResponse.java new file mode 100644 index 0000000..6603eeb --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/AuthResponse.java @@ -0,0 +1,22 @@ +/** + * AuthResponse.java + * + * Response DTO returned after successful authentication. + */ +package com.seanstarkey.notesvault.dto; + +import java.time.Instant; + +/** + * @param token signed JWT string to send in the Authorization header + * @param tokenType token scheme; expected to be {@code Bearer} + * @param expiresInSeconds number of seconds until the token expires + * @param expiresAt absolute expiration timestamp + */ +public record AuthResponse( + String token, + String tokenType, + long expiresInSeconds, + Instant expiresAt +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/ErrorResponse.java b/src/main/java/com/seanstarkey/notesvault/dto/ErrorResponse.java new file mode 100644 index 0000000..0eb0e1c --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/ErrorResponse.java @@ -0,0 +1,13 @@ +/** + * ErrorResponse.java + * + * Response DTO for API errors that can be represented by a single human-readable + * message. + */ +package com.seanstarkey.notesvault.dto; + +/** + * @param error human-readable error message + */ +public record ErrorResponse(String error) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/LoginRequest.java b/src/main/java/com/seanstarkey/notesvault/dto/LoginRequest.java new file mode 100644 index 0000000..3e73aa8 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/LoginRequest.java @@ -0,0 +1,24 @@ +/** + * LoginRequest.java + * + * Request DTO for authenticating an existing user. + */ +package com.seanstarkey.notesvault.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * @param username login name for the account + * @param password plaintext password to verify against the stored bcrypt hash + */ +public record LoginRequest( + @NotBlank + @Size(min = 3, max = 50) + String username, + + @NotBlank + @Size(min = 8) + String password +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/RegisterRequest.java b/src/main/java/com/seanstarkey/notesvault/dto/RegisterRequest.java new file mode 100644 index 0000000..0353841 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/RegisterRequest.java @@ -0,0 +1,24 @@ +/** + * RegisterRequest.java + * + * Request DTO for creating a new Secure Notes Vault user account. + */ +package com.seanstarkey.notesvault.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * @param username unique login name requested by the caller + * @param password plaintext password to hash before persistence + */ +public record RegisterRequest( + @NotBlank + @Size(min = 3, max = 50) + String username, + + @NotBlank + @Size(min = 8) + String password +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/UserResponse.java b/src/main/java/com/seanstarkey/notesvault/dto/UserResponse.java new file mode 100644 index 0000000..142ba66 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/UserResponse.java @@ -0,0 +1,21 @@ +/** + * UserResponse.java + * + * Response DTO for user account data safe to return through the API. + */ +package com.seanstarkey.notesvault.dto; + +import java.time.Instant; +import java.util.UUID; + +/** + * @param id stable user identifier + * @param username unique login name + * @param createdAt timestamp when the account was created + */ +public record UserResponse( + UUID id, + String username, + Instant createdAt +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/ValidationErrorResponse.java b/src/main/java/com/seanstarkey/notesvault/dto/ValidationErrorResponse.java new file mode 100644 index 0000000..5b9df4f --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/ValidationErrorResponse.java @@ -0,0 +1,19 @@ +/** + * ValidationErrorResponse.java + * + * Response DTO for Jakarta Bean Validation failures. Captures both a summary + * message and field-specific validation details. + */ +package com.seanstarkey.notesvault.dto; + +import java.util.Map; + +/** + * @param error summary error message + * @param fields map of field names to validation messages + */ +public record ValidationErrorResponse( + String error, + Map fields +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/exception/ConflictException.java b/src/main/java/com/seanstarkey/notesvault/exception/ConflictException.java new file mode 100644 index 0000000..48e9188 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/exception/ConflictException.java @@ -0,0 +1,25 @@ +/** + * ConflictException.java + */ +package com.seanstarkey.notesvault.exception; + +/** + * Signals an HTTP 409 Conflict condition for domain or persistence uniqueness failures. + */ +public class ConflictException extends RuntimeException { + + /** + * @param message human-readable description of the conflict + */ + public ConflictException(String message) { + super(message); + } + + /** + * @param message human-readable description of the conflict + * @param cause lower-level exception that triggered the conflict + */ + public ConflictException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/exception/ForbiddenException.java b/src/main/java/com/seanstarkey/notesvault/exception/ForbiddenException.java new file mode 100644 index 0000000..8a94700 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/exception/ForbiddenException.java @@ -0,0 +1,17 @@ +/** + * ForbiddenException.java + */ +package com.seanstarkey.notesvault.exception; + +/** + * Signals an HTTP 403 Forbidden condition for authenticated authorization failures. + */ +public class ForbiddenException extends RuntimeException { + + /** + * @param message human-readable description of the authorization failure + */ + public ForbiddenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/exception/GlobalExceptionHandler.java b/src/main/java/com/seanstarkey/notesvault/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..5d17e20 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/exception/GlobalExceptionHandler.java @@ -0,0 +1,120 @@ +/** + * GlobalExceptionHandler.java + */ +package com.seanstarkey.notesvault.exception; + +import com.seanstarkey.notesvault.dto.ErrorResponse; +import com.seanstarkey.notesvault.dto.ValidationErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Maps validation, malformed JSON, and application exceptions to the documented + * error response shape for the Secure Notes Vault API. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * @param ex conflict exception raised by the application layer + * @return 409 response containing a client-safe error message + */ + @ExceptionHandler(ConflictException.class) + public ResponseEntity handleConflict(ConflictException ex) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(new ErrorResponse(ex.getMessage())); + } + + /** + * @param ex forbidden exception raised by the application layer + * @return 403 response containing a client-safe error message + */ + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden(ForbiddenException ex) { + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse(ex.getMessage())); + } + + /** + * @param ex not-found exception raised by the application layer + * @return 404 response containing a client-safe error message + */ + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(NotFoundException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse(ex.getMessage())); + } + + /** + * @param ex authentication exception raised by Spring Security + * @return 401 response containing a generic credential failure message + */ + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthentication(AuthenticationException ex) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("Invalid username or password")); + } + + /** + * @param ex validation exception raised while binding a request body + * @return 422 response containing validation summary and field error messages + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + Map fields = new LinkedHashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> + fields.putIfAbsent(error.getField(), error.getDefaultMessage()) + ); + + return ResponseEntity + .status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(new ValidationErrorResponse("Validation failed", fields)); + } + + /** + * @param ex JSON parsing or request-body conversion failure + * @return 400 response containing a client-safe error message + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleMalformedJson(HttpMessageNotReadableException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("Malformed request body")); + } + + /** + * @param ex type conversion failure raised while binding a request parameter or path variable + * @return 400 response containing a client-safe error message + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("Invalid request parameter")); + } + + /** + * @param ex exception raised when Spring MVC cannot resolve a resource for the route + * @return 404 response containing a client-safe error message + */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFound(NoResourceFoundException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse("Not found")); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/exception/NotFoundException.java b/src/main/java/com/seanstarkey/notesvault/exception/NotFoundException.java new file mode 100644 index 0000000..e59d6b4 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/exception/NotFoundException.java @@ -0,0 +1,17 @@ +/** + * NotFoundException.java + */ +package com.seanstarkey.notesvault.exception; + +/** + * Signals an HTTP 404 Not Found condition for missing or inaccessible resources. + */ +public class NotFoundException extends RuntimeException { + + /** + * @param message human-readable description of the missing resource + */ + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/repository/UserRepository.java b/src/main/java/com/seanstarkey/notesvault/repository/UserRepository.java new file mode 100644 index 0000000..d87ec04 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/repository/UserRepository.java @@ -0,0 +1,25 @@ +/** + * UserRepository.java + */ +package com.seanstarkey.notesvault.repository; + +import com.seanstarkey.notesvault.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + + /** + * @param username the username to search for + * @return an Optional containing the matching user, or empty when no user exists + */ + Optional findByUsername(String username); + + /** + * @param username the username to test for uniqueness + * @return true when the username is already registered; false otherwise + */ + boolean existsByUsername(String username); +} diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..061e3ae --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,51 @@ +/* + * V1__init.sql + * + * Creates the initial Secure Notes Vault schema managed by Flyway. The tables + * mirror the User, Note, and NoteShare JPA entities and keep the SQL portable + * between H2 in PostgreSQL compatibility mode and PostgreSQL. + */ + +CREATE TABLE users ( + id UUID PRIMARY KEY, + username VARCHAR(100) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT uq_users_username UNIQUE (username) +); + +CREATE TABLE notes ( + id UUID PRIMARY KEY, + owner_id UUID NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT fk_notes_owner + FOREIGN KEY (owner_id) + REFERENCES users (id) +); + +CREATE INDEX idx_notes_owner_id ON notes (owner_id); + +CREATE TABLE note_shares ( + id UUID PRIMARY KEY, + note_id UUID NOT NULL, + shared_with_user_id UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT fk_note_shares_note + FOREIGN KEY (note_id) + REFERENCES notes (id) + ON DELETE CASCADE, + CONSTRAINT fk_note_shares_shared_with + FOREIGN KEY (shared_with_user_id) + REFERENCES users (id), + CONSTRAINT uq_note_shares_note_user + UNIQUE (note_id, shared_with_user_id) +); + +CREATE INDEX idx_note_shares_shared_with_user_id + ON note_shares (shared_with_user_id); + +CREATE INDEX idx_note_shares_note_id + ON note_shares (note_id); diff --git a/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java b/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java new file mode 100644 index 0000000..6f63697 --- /dev/null +++ b/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java @@ -0,0 +1,82 @@ +/** + * AuthControllerTest.java + */ +package com.seanstarkey.notesvault.auth; + +import com.seanstarkey.notesvault.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + void registerCreatesUserAndReturnsPublicProfile() throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "starkey", + "password": "secret123" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.username").value("starkey")) + .andExpect(jsonPath("$.createdAt").isNotEmpty()) + .andExpect(jsonPath("$.password").doesNotExist()) + .andExpect(jsonPath("$.passwordHash").doesNotExist()); + + var savedUser = userRepository.findByUsername("starkey").orElseThrow(); + assertThat(savedUser.getPasswordHash()).isNotEqualTo("secret123"); + assertThat(passwordEncoder.matches("secret123", savedUser.getPasswordHash())).isTrue(); + } + + /* + * Test: duplicate usernames are rejected. + * Verifies that the unique account invariant is enforced at the API boundary + * with HTTP 409 instead of silently overwriting or exposing database details. + */ + @Test + void registerRejectsDuplicateUsername() throws Exception { + register("starkey", "secret123").andExpect(status().isCreated()); + + register("starkey", "another123") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("Username is already registered")); + } + + + private org.springframework.test.web.servlet.ResultActions register(String username, String password) throws Exception { + return mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "password": "%s" + } + """.formatted(username, password))); + } +}