Add DTO, exceptions, Flyway config, JPA file and test that allows user registration.
This commit is contained in:
5
scripts/register
Executable file
5
scripts/register
Executable 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"}'
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
51
src/main/resources/db/migration/V1__init.sql
Normal file
51
src/main/resources/db/migration/V1__init.sql
Normal 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);
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user