Add DTO, exceptions, Flyway config, JPA file and test that allows user registration.

This commit is contained in:
2026-05-31 17:10:49 -06:00
parent 578fe6fa19
commit d432f1d1a3
16 changed files with 580 additions and 0 deletions

5
scripts/register Executable file
View File

@@ -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"}'

View File

@@ -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<UserResponse> 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());
}
}

View File

@@ -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();
}
}

View File

@@ -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
) {
}

View File

@@ -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) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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<String, String> fields
) {
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ValidationErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleNoResourceFound(NoResourceFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("Not found"));
}
}

View File

@@ -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);
}
}

View File

@@ -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<User, UUID> {
/**
* @param username the username to search for
* @return an Optional containing the matching user, or empty when no user exists
*/
Optional<User> 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);
}

View File

@@ -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);

View File

@@ -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)));
}
}