Add files for POST /note

This commit is contained in:
2026-06-01 17:39:15 -06:00
parent fd9a1f4284
commit 3cf345c85f
14 changed files with 935 additions and 11 deletions

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

View File

@@ -3,16 +3,21 @@
*/ */
package com.seanstarkey.notesvault.config; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; 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. * Provides the password encoder bean used to hash and verify user passwords.
@@ -20,18 +25,39 @@ import org.springframework.security.web.SecurityFilterChain;
@Configuration @Configuration
public class SecurityConfig { 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 @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http http
.csrf(csrf -> csrf.disable()) .csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.requestMatchers(HttpMethod.POST, "/auth/register", "/auth/login").permitAll() .authorizeHttpRequests(auth -> auth
.anyRequest().authenticated() .requestMatchers("/auth/register", "/auth/login").permitAll()
) .anyRequest().authenticated()
.sessionManagement(session -> session )
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) .exceptionHandling(exceptions -> exceptions
) .authenticationEntryPoint((request, response, authException) ->
.build(); 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();
} }
/** /**

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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