From 3cf345c85fcd11230bf2e06b8f520b220338e359 Mon Sep 17 00:00:00 2001 From: Sean Starkey Date: Mon, 1 Jun 2026 17:39:15 -0600 Subject: [PATCH] Add files for POST /note --- .../notesvault/auth/JwtAuthFilter.java | 109 ++++++++++ .../notesvault/config/SecurityConfig.java | 48 ++++- .../notesvault/controller/NoteController.java | 131 +++++++++++ .../notesvault/dto/CreateNoteRequest.java | 25 +++ .../notesvault/dto/NoteAccess.java | 17 ++ .../notesvault/dto/NoteResponse.java | 31 +++ .../notesvault/dto/ShareNoteRequest.java | 21 ++ .../notesvault/dto/ShareResponse.java | 21 ++ .../notesvault/dto/UpdateNoteRequest.java | 23 ++ .../notesvault/exception/ApiErrorWriter.java | 37 ++++ .../notesvault/repository/NoteRepository.java | 51 +++++ .../repository/NoteShareRepository.java | 65 ++++++ .../notesvault/service/NoteService.java | 204 ++++++++++++++++++ .../controller/NoteControllerTest.java | 163 ++++++++++++++ 14 files changed, 935 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/seanstarkey/notesvault/auth/JwtAuthFilter.java create mode 100644 src/main/java/com/seanstarkey/notesvault/controller/NoteController.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/CreateNoteRequest.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/NoteAccess.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/NoteResponse.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/ShareNoteRequest.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/ShareResponse.java create mode 100644 src/main/java/com/seanstarkey/notesvault/dto/UpdateNoteRequest.java create mode 100644 src/main/java/com/seanstarkey/notesvault/exception/ApiErrorWriter.java create mode 100644 src/main/java/com/seanstarkey/notesvault/repository/NoteRepository.java create mode 100644 src/main/java/com/seanstarkey/notesvault/repository/NoteShareRepository.java create mode 100644 src/main/java/com/seanstarkey/notesvault/service/NoteService.java create mode 100644 src/test/java/com/seanstarkey/notesvault/controller/NoteControllerTest.java diff --git a/src/main/java/com/seanstarkey/notesvault/auth/JwtAuthFilter.java b/src/main/java/com/seanstarkey/notesvault/auth/JwtAuthFilter.java new file mode 100644 index 0000000..836eee6 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/auth/JwtAuthFilter.java @@ -0,0 +1,109 @@ +/** + * JwtAuthFilter.java + */ +package com.seanstarkey.notesvault.auth; + +import com.seanstarkey.notesvault.exception.ApiErrorWriter; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + private final ApiErrorWriter apiErrorWriter; + + /** + * @param jwtUtil component used to parse and validate JWTs + * @param userDetailsService service used to load the token subject as a Spring user + * @param apiErrorWriter helper that writes consistent JSON errors for rejected tokens + */ + public JwtAuthFilter( + JwtUtil jwtUtil, + UserDetailsService userDetailsService, + ApiErrorWriter apiErrorWriter + ) { + this.jwtUtil = jwtUtil; + this.userDetailsService = userDetailsService; + this.apiErrorWriter = apiErrorWriter; + } + + /** + * @param request incoming HTTP request + * @param response outgoing HTTP response + * @param filterChain remaining servlet filter chain + * @throws ServletException if the downstream filter chain fails + * @throws IOException if response writing or downstream IO fails + */ + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.substring(BEARER_PREFIX.length()).trim(); + + try { + authenticateToken(token, request); + } catch (JwtException | IllegalArgumentException | UsernameNotFoundException ex) { + SecurityContextHolder.clearContext(); + apiErrorWriter.write(response, HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token"); + return; + } + + filterChain.doFilter(request, response); + } + + /** + * @param token compact JWT extracted from the Authorization header + * @param request current HTTP request, used to attach web authentication details + * @throws JwtException when token parsing or validation fails + * @throws IllegalArgumentException when the token is blank or invalid + * @throws UsernameNotFoundException when the token subject is not a registered user + */ + public void authenticateToken(String token, HttpServletRequest request) { + if (SecurityContextHolder.getContext().getAuthentication() != null) { + return; + } + + String username = jwtUtil.getUsernameFromToken(token); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (!jwtUtil.isTokenValid(token, userDetails.getUsername())) { + throw new IllegalArgumentException("JWT token is invalid for the authenticated user"); + } + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java b/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java index 31f9edc..c5c3025 100644 --- a/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java +++ b/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java @@ -3,16 +3,21 @@ */ package com.seanstarkey.notesvault.config; +import com.seanstarkey.notesvault.auth.JwtAuthFilter; +import com.seanstarkey.notesvault.exception.ApiErrorWriter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import jakarta.servlet.http.HttpServletResponse; /** * Provides the password encoder bean used to hash and verify user passwords. @@ -20,18 +25,39 @@ import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { + private final JwtAuthFilter jwtAuthFilter; + private final ApiErrorWriter apiErrorWriter; + + /** + * @param jwtAuthFilter filter that authenticates bearer tokens before request handling + * @param apiErrorWriter helper that writes consistent JSON errors from security handlers + */ + public SecurityConfig(JwtAuthFilter jwtAuthFilter, ApiErrorWriter apiErrorWriter) { + this.jwtAuthFilter = jwtAuthFilter; + this.apiErrorWriter = apiErrorWriter; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST, "/auth/register", "/auth/login").permitAll() - .anyRequest().authenticated() - ) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - .build(); + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/register", "/auth/login").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint((request, response, authException) -> + apiErrorWriter.write(response, HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized") + ) + .accessDeniedHandler((request, response, accessDeniedException) -> + apiErrorWriter.write(response, HttpServletResponse.SC_FORBIDDEN, "Forbidden") + ) + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } /** diff --git a/src/main/java/com/seanstarkey/notesvault/controller/NoteController.java b/src/main/java/com/seanstarkey/notesvault/controller/NoteController.java new file mode 100644 index 0000000..c381982 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/controller/NoteController.java @@ -0,0 +1,131 @@ +/** + * NoteController.java + * + * REST controller for note operations. + */ +package com.seanstarkey.notesvault.controller; + +import com.seanstarkey.notesvault.dto.CreateNoteRequest; +import com.seanstarkey.notesvault.dto.NoteResponse; +import com.seanstarkey.notesvault.dto.ShareNoteRequest; +import com.seanstarkey.notesvault.dto.ShareResponse; +import com.seanstarkey.notesvault.dto.UpdateNoteRequest; +import com.seanstarkey.notesvault.service.NoteService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + + +@RestController +@RequestMapping("/notes") +public class NoteController { + + private final NoteService noteService; + + /** + * @param noteService service that performs note persistence and authorization checks + */ + public NoteController(NoteService noteService) { + this.noteService = noteService; + } + + /** + * @param authentication authenticated Spring Security principal populated from the bearer token + * @return 200 OK with note representations visible to the caller + */ + @GetMapping + public ResponseEntity> listNotes(Authentication authentication) { + return ResponseEntity.ok(noteService.listNotes(authentication.getName())); + } + + /** + * @param authentication authenticated Spring Security principal populated from the bearer token + * @param id note identifier from the request path + * @return 200 OK with the accessible note representation + */ + @GetMapping("/{id}") + public ResponseEntity getNote( + Authentication authentication, + @PathVariable UUID id + ) { + return ResponseEntity.ok(noteService.getNote(authentication.getName(), id)); + } + + /** + * @param authentication authenticated Spring Security principal populated from the bearer token + * @param request validated note creation payload + * @return 201 Created with the newly-created note representation + */ + @PostMapping + public ResponseEntity createNote( + Authentication authentication, + @Valid @RequestBody CreateNoteRequest request + ) { + NoteResponse response = noteService.createNote(authentication.getName(), request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(response); + } + + /** + * @param authentication authenticated Spring Security principal populated from the bearer token + * @param id note identifier from the request path + * @param request validated share target payload + * @return 201 Created with the new share grant representation + */ + @PostMapping("/{id}/share") + public ResponseEntity shareNote( + Authentication authentication, + @PathVariable UUID id, + @Valid @RequestBody ShareNoteRequest request + ) { + ShareResponse response = noteService.shareNote(authentication.getName(), id, request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(response); + } + + /** + * @param authentication authenticated Spring Security principal populated from the bearer token + * @param id note identifier from the request path + * @param request validated replacement note payload + * @return 200 OK with the updated note representation + */ + @PutMapping("/{id}") + public ResponseEntity updateNote( + Authentication authentication, + @PathVariable UUID id, + @Valid @RequestBody UpdateNoteRequest request + ) { + return ResponseEntity.ok(noteService.updateNote(authentication.getName(), id, request)); + } + + /** + * @param authentication authenticated Spring Security principal populated from the bearer token + * @param id note identifier from the request path + * @return 204 No Content when deletion succeeds + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteNote( + Authentication authentication, + @PathVariable UUID id + ) { + noteService.deleteNote(authentication.getName(), id); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/CreateNoteRequest.java b/src/main/java/com/seanstarkey/notesvault/dto/CreateNoteRequest.java new file mode 100644 index 0000000..6c47384 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/CreateNoteRequest.java @@ -0,0 +1,25 @@ +/** + * CreateNoteRequest.java + * + * Request DTO for creating a note. + */ +package com.seanstarkey.notesvault.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Carries note content supplied by an authenticated owner. + * + * @param title optional note title, limited to a concise display length + * @param content required note body + */ +public record CreateNoteRequest( + @Size(max = 200) + String title, + + @NotBlank + @Size(max = 10000) + String content +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/NoteAccess.java b/src/main/java/com/seanstarkey/notesvault/dto/NoteAccess.java new file mode 100644 index 0000000..9be95b5 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/NoteAccess.java @@ -0,0 +1,17 @@ +/** + * NoteAccess.java + * + * API enum describing the caller's effective access to a note in note responses. + */ +package com.seanstarkey.notesvault.dto; + +/** + * Distinguishes owner access from read-only shared access for the authenticated caller. + */ +public enum NoteAccess { + /** Caller owns the note and may read, update, delete, and share it. */ + OWNER, + + /** Caller received an explicit share and may only read the note. */ + SHARED_READ +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/NoteResponse.java b/src/main/java/com/seanstarkey/notesvault/dto/NoteResponse.java new file mode 100644 index 0000000..7a29cca --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/NoteResponse.java @@ -0,0 +1,31 @@ +/** + * NoteResponse.java + * + * Response DTO for note data returned through the API. + */ +package com.seanstarkey.notesvault.dto; + +import java.time.Instant; +import java.util.UUID; + +/** + * Represents a note visible to the authenticated caller. + * + * @param id stable note identifier + * @param title note title + * @param content note body + * @param ownerUsername username of the note owner + * @param access caller's effective permissions for the note + * @param createdAt timestamp when the note was created + * @param updatedAt timestamp when the note was last updated + */ +public record NoteResponse( + UUID id, + String title, + String content, + String ownerUsername, + NoteAccess access, + Instant createdAt, + Instant updatedAt +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/ShareNoteRequest.java b/src/main/java/com/seanstarkey/notesvault/dto/ShareNoteRequest.java new file mode 100644 index 0000000..1a06747 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/ShareNoteRequest.java @@ -0,0 +1,21 @@ +/** + * ShareNoteRequest.java + * + * Request DTO for sharing a note with another user. + */ +package com.seanstarkey.notesvault.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Carries the target username for a note share operation. + * + * @param username recipient username to grant read-only access + */ +public record ShareNoteRequest( + @NotBlank + @Size(min = 3, max = 50) + String username +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/ShareResponse.java b/src/main/java/com/seanstarkey/notesvault/dto/ShareResponse.java new file mode 100644 index 0000000..5425f4f --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/ShareResponse.java @@ -0,0 +1,21 @@ +/** + * ShareResponse.java + * + * Response DTO returned after a note is successfully shared. + */ +package com.seanstarkey.notesvault.dto; + +import java.time.Instant; +import java.util.UUID; + +/** + * @param noteId identifier of the shared note + * @param sharedWithUsername username receiving read-only access + * @param createdAt timestamp when the share was created + */ +public record ShareResponse( + UUID noteId, + String sharedWithUsername, + Instant createdAt +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/dto/UpdateNoteRequest.java b/src/main/java/com/seanstarkey/notesvault/dto/UpdateNoteRequest.java new file mode 100644 index 0000000..3e65284 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/dto/UpdateNoteRequest.java @@ -0,0 +1,23 @@ +/** + * UpdateNoteRequest.java + * + * Request DTO for replacing note fields. + */ +package com.seanstarkey.notesvault.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * @param title optional note title, limited to a concise display length + * @param content required replacement note body + */ +public record UpdateNoteRequest( + @Size(max = 200) + String title, + + @NotBlank + @Size(max = 10000) + String content +) { +} diff --git a/src/main/java/com/seanstarkey/notesvault/exception/ApiErrorWriter.java b/src/main/java/com/seanstarkey/notesvault/exception/ApiErrorWriter.java new file mode 100644 index 0000000..b18229c --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/exception/ApiErrorWriter.java @@ -0,0 +1,37 @@ +/** + * ApiErrorWriter.java + */ +package com.seanstarkey.notesvault.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.seanstarkey.notesvault.dto.ErrorResponse; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class ApiErrorWriter { + + private final ObjectMapper objectMapper; + + /** + * @param objectMapper mapper used to serialize the error response DTO + */ + public ApiErrorWriter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * @param response outgoing servlet response + * @param status HTTP status code to set on the response + * @param message client-safe error message + * @throws IOException when the response body cannot be written + */ + public void write(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), new ErrorResponse(message)); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/repository/NoteRepository.java b/src/main/java/com/seanstarkey/notesvault/repository/NoteRepository.java new file mode 100644 index 0000000..d9c7d11 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/repository/NoteRepository.java @@ -0,0 +1,51 @@ +/** + * NoteRepository.java + */ +package com.seanstarkey.notesvault.repository; + +import com.seanstarkey.notesvault.entity.Note; +import com.seanstarkey.notesvault.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface NoteRepository extends JpaRepository { + + /** + * @param user the authenticated user whose accessible notes should be returned + * @return all notes the user can read, including owned and shared notes + */ + @Query(""" + SELECT DISTINCT n + FROM Note n + LEFT JOIN n.shares s + WHERE n.owner = :user OR s.sharedWith = :user + ORDER BY n.updatedAt DESC, n.createdAt DESC + """) + List findAllAccessibleByUser(@Param("user") User user); + + /** + * @param id the note id to load + * @param owner the user who must own the note + * @return an Optional containing the note when owned by the user, or empty otherwise + */ + Optional findByIdAndOwner(UUID id, User owner); + + /** + * @param id the note id to load + * @param user the authenticated user whose read access should be checked + * @return an Optional containing the accessible note, or empty when not found or inaccessible + */ + @Query(""" + SELECT DISTINCT n + FROM Note n + LEFT JOIN n.shares s + WHERE n.id = :id + AND (n.owner = :user OR s.sharedWith = :user) + """) + Optional findAccessibleByIdAndUser(@Param("id") UUID id, @Param("user") User user); +} diff --git a/src/main/java/com/seanstarkey/notesvault/repository/NoteShareRepository.java b/src/main/java/com/seanstarkey/notesvault/repository/NoteShareRepository.java new file mode 100644 index 0000000..12e15da --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/repository/NoteShareRepository.java @@ -0,0 +1,65 @@ +/** + * NoteShareRepository.java + * + * Spring Data repository for note share grants. Provides lookup helpers for + * duplicate-share checks, read-access checks, and data-layer tests around cascade + * deletion behavior. + */ +package com.seanstarkey.notesvault.repository; + +import com.seanstarkey.notesvault.entity.Note; +import com.seanstarkey.notesvault.entity.NoteShare; +import com.seanstarkey.notesvault.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for NoteShare entities. Encapsulates queries around read-only share + * grants between notes and recipient users. + */ +public interface NoteShareRepository extends JpaRepository { + + /** + * Finds an existing share grant for a note and recipient user. + * + * @param note the shared note + * @param sharedWith the user who may have read-only access + * @return an Optional containing the share grant, or empty when no grant exists + */ + Optional findByNoteAndSharedWith(Note note, User sharedWith); + + /** + * Checks whether a note is already shared with a recipient. + * + * @param note the note whose share grants should be searched + * @param sharedWith the recipient user + * @return true when a share grant exists; false otherwise + */ + boolean existsByNoteAndSharedWith(Note note, User sharedWith); + + /** + * Lists all share grants for a note. + * + * @param note the note whose share grants should be returned + * @return all share grants associated with the note + */ + List findAllByNote(Note note); + + /** + * Lists all share grants received by a user. + * + * @param sharedWith the user whose received shares should be returned + * @return all share grants where the supplied user is the recipient + */ + List findAllBySharedWith(User sharedWith); + + /** + * Deletes all share grants for a note. + * + * @param note the note whose share grants should be removed + */ + void deleteAllByNote(Note note); +} diff --git a/src/main/java/com/seanstarkey/notesvault/service/NoteService.java b/src/main/java/com/seanstarkey/notesvault/service/NoteService.java new file mode 100644 index 0000000..a6b5852 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/service/NoteService.java @@ -0,0 +1,204 @@ +/** + * NoteService.java + */ +package com.seanstarkey.notesvault.service; + +import com.seanstarkey.notesvault.dto.CreateNoteRequest; +import com.seanstarkey.notesvault.dto.NoteAccess; +import com.seanstarkey.notesvault.dto.NoteResponse; +import com.seanstarkey.notesvault.dto.ShareNoteRequest; +import com.seanstarkey.notesvault.dto.ShareResponse; +import com.seanstarkey.notesvault.dto.UpdateNoteRequest; +import com.seanstarkey.notesvault.entity.Note; +import com.seanstarkey.notesvault.entity.NoteShare; +import com.seanstarkey.notesvault.entity.User; +import com.seanstarkey.notesvault.exception.ConflictException; +import com.seanstarkey.notesvault.exception.ForbiddenException; +import com.seanstarkey.notesvault.exception.NotFoundException; +import com.seanstarkey.notesvault.repository.NoteRepository; +import com.seanstarkey.notesvault.repository.NoteShareRepository; +import com.seanstarkey.notesvault.repository.UserRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +public class NoteService { + + private static final String NOTE_NOT_FOUND = "Note not found"; + + private final NoteRepository noteRepository; + private final NoteShareRepository noteShareRepository; + private final UserRepository userRepository; + + /** + * @param noteRepository repository used to persist and query notes + * @param noteShareRepository repository used to persist and query read-only share grants + * @param userRepository repository used to resolve authenticated and share target users + */ + public NoteService( + NoteRepository noteRepository, + NoteShareRepository noteShareRepository, + UserRepository userRepository + ) { + this.noteRepository = noteRepository; + this.noteShareRepository = noteShareRepository; + this.userRepository = userRepository; + } + + /** + * @param username authenticated username that will own the note + * @param request validated note creation payload + * @return response DTO for the newly created note with OWNER access + * @throws NotFoundException when the authenticated user no longer exists + */ + @Transactional + public NoteResponse createNote(String username, CreateNoteRequest request) { + User owner = requireUser(username); + + Note note = new Note(); + note.setOwner(owner); + note.setTitle(normalizeTitle(request.title())); + note.setContent(request.content()); + + return toResponse(noteRepository.save(note), owner); + } + + /** + * @param username authenticated username whose visible notes should be listed + * @return note responses ordered by repository-defined recency + * @throws NotFoundException when the authenticated user no longer exists + */ + @Transactional(readOnly = true) + public List listNotes(String username) { + User user = requireUser(username); + + return noteRepository.findAllAccessibleByUser(user).stream() + .map(note -> toResponse(note, user)) + .toList(); + } + + /** + * @param username authenticated username requesting the note + * @param noteId note identifier to retrieve + * @return response DTO for the accessible note + * @throws NotFoundException when the note is missing or inaccessible + */ + @Transactional(readOnly = true) + public NoteResponse getNote(String username, UUID noteId) { + User user = requireUser(username); + Note note = noteRepository.findAccessibleByIdAndUser(noteId, user) + .orElseThrow(() -> new NotFoundException(NOTE_NOT_FOUND)); + + return toResponse(note, user); + } + + /** + * @param username authenticated username requesting the update + * @param noteId note identifier to update + * @param request validated replacement values + * @return response DTO for the updated note with OWNER access + * @throws ForbiddenException when the user has only shared read access + * @throws NotFoundException when the note is missing or inaccessible + */ + @Transactional + public NoteResponse updateNote(String username, UUID noteId, UpdateNoteRequest request) { + User user = requireUser(username); + Note note = noteRepository.findByIdAndOwner(noteId, user) + .orElseGet(() -> requireNotSharedReadOnly(noteId, user)); + + note.setTitle(normalizeTitle(request.title())); + note.setContent(request.content()); + + return toResponse(noteRepository.save(note), user); + } + + /** + * @param username authenticated username requesting deletion + * @param noteId note identifier to delete + * @throws ForbiddenException when the user has only shared read access + * @throws NotFoundException when the note is missing or inaccessible + */ + @Transactional + public void deleteNote(String username, UUID noteId) { + User user = requireUser(username); + Note note = noteRepository.findByIdAndOwner(noteId, user) + .orElseGet(() -> requireNotSharedReadOnly(noteId, user)); + + noteRepository.delete(note); + } + + /** + * @param username authenticated owner username requesting the share + * @param noteId note identifier to share + * @param request validated target username payload + * @return response DTO describing the created share grant + * @throws ForbiddenException when the caller can read but does not own the note + * @throws NotFoundException when the note, caller, or target user cannot be found + * @throws ConflictException when sharing with the owner or creating a duplicate share + */ + @Transactional + public ShareResponse shareNote(String username, UUID noteId, ShareNoteRequest request) { + User owner = requireUser(username); + Note note = noteRepository.findByIdAndOwner(noteId, owner) + .orElseGet(() -> requireNotSharedReadOnly(noteId, owner)); + User target = userRepository.findByUsername(request.username()) + .orElseThrow(() -> new NotFoundException("User not found")); + + if (note.getOwner().getId().equals(target.getId())) { + throw new ConflictException("Cannot share a note with its owner"); + } + + if (noteShareRepository.existsByNoteAndSharedWith(note, target)) { + throw new ConflictException("Note is already shared with this user"); + } + + NoteShare share = new NoteShare(); + share.setNote(note); + share.setSharedWith(target); + + try { + NoteShare savedShare = noteShareRepository.saveAndFlush(share); + return new ShareResponse(note.getId(), target.getUsername(), savedShare.getCreatedAt()); + } catch (DataIntegrityViolationException ex) { + throw new ConflictException("Note is already shared with this user", ex); + } + } + + private User requireUser(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> new NotFoundException("User not found")); + } + + private Note requireNotSharedReadOnly(UUID noteId, User user) { + noteRepository.findAccessibleByIdAndUser(noteId, user) + .ifPresent(note -> { + throw new ForbiddenException("Shared notes are read-only"); + }); + + throw new NotFoundException(NOTE_NOT_FOUND); + } + + private NoteResponse toResponse(Note note, User caller) { + NoteAccess access = note.getOwner().getId().equals(caller.getId()) + ? NoteAccess.OWNER + : NoteAccess.SHARED_READ; + + return new NoteResponse( + note.getId(), + note.getTitle(), + note.getContent(), + note.getOwner().getUsername(), + access, + note.getCreatedAt(), + note.getUpdatedAt() + ); + } + + private String normalizeTitle(String title) { + return title == null ? "" : title; + } +} diff --git a/src/test/java/com/seanstarkey/notesvault/controller/NoteControllerTest.java b/src/test/java/com/seanstarkey/notesvault/controller/NoteControllerTest.java new file mode 100644 index 0000000..18ea09e --- /dev/null +++ b/src/test/java/com/seanstarkey/notesvault/controller/NoteControllerTest.java @@ -0,0 +1,163 @@ +/** + * NoteControllerTest.java + * + * API-level tests for authenticated note endpoints exposed by NoteController. + */ +package com.seanstarkey.notesvault.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.seanstarkey.notesvault.auth.JwtUtil; +import com.seanstarkey.notesvault.entity.Note; +import com.seanstarkey.notesvault.entity.NoteShare; +import com.seanstarkey.notesvault.entity.User; +import com.seanstarkey.notesvault.repository.NoteRepository; +import com.seanstarkey.notesvault.repository.NoteShareRepository; +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.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies note CRUD, sharing, authentication, authorization, and validation + * behavior through the real Spring MVC, Security, JPA, validation, and + * Flyway-backed H2 application stack. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class NoteControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private NoteRepository noteRepository; + + @Autowired + private NoteShareRepository noteShareRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ObjectMapper objectMapper; + + /* + * Test: authenticated users can create notes. + * Verifies that POST /notes stores a note owned by the bearer token subject, + * returns HTTP 201, and exposes the documented NoteResponse shape. + */ + @Test + void createNotePersistsOwnedNoteAndReturnsResponse() throws Exception { + register("alice", "secret123"); + String token = jwtUtil.generateToken("alice"); + + String responseBody = mockMvc.perform(post("/notes") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "My Note", + "content": "Hello, world!" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.title").value("My Note")) + .andExpect(jsonPath("$.content").value("Hello, world!")) + .andExpect(jsonPath("$.ownerUsername").value("alice")) + .andExpect(jsonPath("$.access").value("OWNER")) + .andExpect(jsonPath("$.createdAt").isNotEmpty()) + .andExpect(jsonPath("$.updatedAt").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + UUID noteId = UUID.fromString(objectMapper.readTree(responseBody).get("id").asText()); + Note savedNote = noteRepository.findById(noteId).orElseThrow(); + + assertThat(savedNote.getTitle()).isEqualTo("My Note"); + assertThat(savedNote.getContent()).isEqualTo("Hello, world!"); + assertThat(savedNote.getOwner().getUsername()).isEqualTo("alice"); + } + + /* + * Test: note creation requires authentication. + * Verifies that POST /notes is protected by the JWT security chain and + * returns HTTP 401 when no bearer token is supplied. + */ + @Test + void createNoteRequiresBearerToken() throws Exception { + mockMvc.perform(post("/notes") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "My Note", + "content": "Hello, world!" + } + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Unauthorized")); + } + + private void register(String username, String password) throws Exception { + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "password": "%s" + } + """.formatted(username, password))) + .andExpect(status().isCreated()); + + assertThat(userRepository.findByUsername(username)).isPresent(); + } + + private UUID createNote(String token, String title, String content) throws Exception { + String responseBody = mockMvc.perform(post("/notes") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "%s", + "content": "%s" + } + """.formatted(title, content))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + return UUID.fromString(objectMapper.readTree(responseBody).get("id").asText()); + } + + private void shareWith(UUID noteId, String username) { + Note note = noteRepository.findById(noteId).orElseThrow(); + User sharedWith = userRepository.findByUsername(username).orElseThrow(); + + NoteShare share = new NoteShare(); + share.setNote(note); + share.setSharedWith(sharedWith); + noteShareRepository.saveAndFlush(share); + } +}