Add files for POST /note
This commit is contained in:
109
src/main/java/com/seanstarkey/notesvault/auth/JwtAuthFilter.java
Normal file
109
src/main/java/com/seanstarkey/notesvault/auth/JwtAuthFilter.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.POST, "/auth/register", "/auth/login").permitAll()
|
||||
.requestMatchers("/auth/register", "/auth/login").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.authenticationEntryPoint((request, response, authException) ->
|
||||
apiErrorWriter.write(response, HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
|
||||
)
|
||||
.build();
|
||||
.accessDeniedHandler((request, response, accessDeniedException) ->
|
||||
apiErrorWriter.write(response, HttpServletResponse.SC_FORBIDDEN, "Forbidden")
|
||||
)
|
||||
)
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<List<NoteResponse>> 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<NoteResponse> 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<NoteResponse> 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<ShareResponse> 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<NoteResponse> 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<Void> deleteNote(
|
||||
Authentication authentication,
|
||||
@PathVariable UUID id
|
||||
) {
|
||||
noteService.deleteNote(authentication.getName(), id);
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
17
src/main/java/com/seanstarkey/notesvault/dto/NoteAccess.java
Normal file
17
src/main/java/com/seanstarkey/notesvault/dto/NoteAccess.java
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Note, UUID> {
|
||||
|
||||
/**
|
||||
* @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<Note> 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<Note> 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<Note> findAccessibleByIdAndUser(@Param("id") UUID id, @Param("user") User user);
|
||||
}
|
||||
@@ -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<NoteShare, UUID> {
|
||||
|
||||
/**
|
||||
* 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<NoteShare> 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<NoteShare> 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<NoteShare> findAllBySharedWith(User sharedWith);
|
||||
|
||||
/**
|
||||
* Deletes all share grants for a note.
|
||||
*
|
||||
* @param note the note whose share grants should be removed
|
||||
*/
|
||||
void deleteAllByNote(Note note);
|
||||
}
|
||||
@@ -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<NoteResponse> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user