Skip to content

Commit 1020cd1

Browse files
authored
[Feature] Add patch endpoint for file role edition (#542)
* feat: create update dto for file role edition * feat: add new method for file role edition * feat: implement method for file role edition with necessary checks * feat: add new PATCH endpoint for file role edition * fix: accept UpdateFileRoleRequestDto as query param instead of request body, to fix the Jackson parsing issue * test: update tests for file manager service and controller * feat: add mp4 as an allowed file extension * feat: add more files with allowed extensions for task problem and reference * fix: remove unused imports * test: update tests to include new allowed file extensions * fix: set correct name for the table that stores tasks * feat: add debug info for the updateRole method * fix: allow authenticated file owners to reach the service authorization check * fix: validate the new role against existing file policy * feat: introduce constants for exception messages * fix: Assert that newRole binds to PROBLEM * fix: formatting * fix: update unit tests for new updateRole method implementation * fix: pass checkOwnerOrAdmin arguments in the correct order
1 parent 05919be commit 1020cd1

12 files changed

Lines changed: 278 additions & 15 deletions

File tree

src/main/java/com/itasocialacademy/oitassist/filemanager/controller/FileController.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.itasocialacademy.oitassist.filemanager.controller;
22

3+
import com.azure.core.annotation.QueryParam;
34
import com.itasocialacademy.oitassist.core.web.ErrorResponse;
45
import com.itasocialacademy.oitassist.filemanager.dao.enums.RelatedEntityType;
56
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
7+
import com.itasocialacademy.oitassist.filemanager.dto.request.UpdateFileRoleRequestDto;
68
import com.itasocialacademy.oitassist.filemanager.dto.response.FileResponseDto;
79
import com.itasocialacademy.oitassist.filemanager.service.interfaces.FileCleanupService;
810
import com.itasocialacademy.oitassist.filemanager.service.interfaces.FileService;
@@ -203,4 +205,43 @@ public ResponseEntity<List<FileResponseDto>> getFiles(
203205
@RequestParam Long entityId) {
204206
return ResponseEntity.ok(fileService.getFilesByEntity(entityType, entityId));
205207
}
208+
209+
/**
210+
* Updates the role of an attached file.
211+
*
212+
* @param id the ID of the file to update
213+
* @param requestDto the DTO containing the new role
214+
* @return HTTP 200 with the updated file record
215+
*/
216+
@Operation(
217+
summary = "Update file role",
218+
description = "Updates the role of a file that is in ATTACHED state. "
219+
+ "Accessible only by the file uploader or admin.")
220+
@ApiResponses(value = {
221+
@ApiResponse(
222+
responseCode = "200",
223+
description = "File role updated successfully",
224+
content = @Content(
225+
mediaType = "application/json",
226+
schema = @Schema(implementation = FileResponseDto.class))),
227+
@ApiResponse(
228+
responseCode = "400",
229+
description = "Invalid request or file is not in ATTACHED state",
230+
content = @Content(
231+
mediaType = "application/json",
232+
schema = @Schema(implementation = ErrorResponse.class))),
233+
@ApiResponse(
234+
responseCode = "403",
235+
description = "Access denied"),
236+
@ApiResponse(
237+
responseCode = "404",
238+
description = "File not found in the DB")
239+
})
240+
@PatchMapping("/{id}/role")
241+
@PreAuthorize("isAuthenticated()")
242+
public ResponseEntity<FileResponseDto> updateRole(
243+
@PathVariable Long id,
244+
@Valid @QueryParam("newRole") UpdateFileRoleRequestDto requestDto) {
245+
return ResponseEntity.ok(fileService.updateRole(id, requestDto));
246+
}
206247
}

src/main/java/com/itasocialacademy/oitassist/filemanager/dao/enums/RelatedEntityType.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
@RequiredArgsConstructor
1010
public enum RelatedEntityType {
1111
NEWS("News"),
12-
TASK("Task"),
12+
TASK("TaskBody"),
1313
COMPETITION("Competition"),
1414
SUBMISSION("Submission");
1515

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.itasocialacademy.oitassist.filemanager.dto.request;
2+
3+
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.*;
6+
7+
@Getter
8+
@Setter
9+
@Builder
10+
@NoArgsConstructor
11+
@AllArgsConstructor
12+
public class UpdateFileRoleRequestDto {
13+
@NotNull(message = "New role must not be null")
14+
private FileRole newRole;
15+
}

src/main/java/com/itasocialacademy/oitassist/filemanager/service/FileServiceImpl.java

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.itasocialacademy.oitassist.filemanager.service;
22

3+
import static com.itasocialacademy.oitassist.filemanager.validation.util.FileValidationUtils.*;
34
import com.itasocialacademy.oitassist.core.enums.ErrorCode;
45
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
56
import com.itasocialacademy.oitassist.core.exceptions.ValidationException;
@@ -12,6 +13,7 @@
1213
import com.itasocialacademy.oitassist.filemanager.dao.repository.FileRepository;
1314
import com.itasocialacademy.oitassist.filemanager.dao.specification.FileAssetSpecification;
1415
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
16+
import com.itasocialacademy.oitassist.filemanager.dto.request.UpdateFileRoleRequestDto;
1517
import com.itasocialacademy.oitassist.filemanager.dto.response.FileResponseDto;
1618
import com.itasocialacademy.oitassist.filemanager.exceptions.FileAssetNotFoundException;
1719
import com.itasocialacademy.oitassist.filemanager.exceptions.FileUploadException;
@@ -53,6 +55,19 @@ public class FileServiceImpl implements FileService {
5355
*/
5456
private static final String ROLE_ADMIN = "ADMIN";
5557

58+
/**
59+
* Error message indicating that the user is not authenticated. Used when an
60+
* operation requires authentication, but it is invalid.
61+
*/
62+
public static final String NOT_AUTHENTICATED = "Not authenticated";
63+
64+
/**
65+
* Error message prefix indicating that a requested file could not be found in
66+
* the database. This message is typically appended with the file identifier to
67+
* provide context.
68+
*/
69+
public static final String FILE_NOT_FOUND_IN_THE_DATABASE = "File not found in the database: ";
70+
5671
/**
5772
* {@inheritDoc}
5873
*
@@ -67,7 +82,7 @@ public class FileServiceImpl implements FileService {
6782
public List<FileResponseDto> upload(List<MultipartFile> files, FileUploadRequestDto requestDto) {
6883
Long currentUserId = securityFacade.getCurrentUserId()
6984
.orElseThrow(() -> new AuthorizationException(
70-
"Not authenticated", ErrorCode.ACCESS_DENIED));
85+
NOT_AUTHENTICATED, ErrorCode.ACCESS_DENIED));
7186

7287
RelatedEntityType entityType = requestDto.getRelatedEntityType();
7388
FileRole role = requestDto.getFileRole();
@@ -99,10 +114,10 @@ public List<FileResponseDto> upload(List<MultipartFile> files, FileUploadRequest
99114
public void deleteSoft(Long fileId) {
100115
Long currentUserId = securityFacade.getCurrentUserId()
101116
.orElseThrow(() -> new AuthorizationException(
102-
"Not authenticated", ErrorCode.ACCESS_DENIED));
117+
NOT_AUTHENTICATED, ErrorCode.ACCESS_DENIED));
103118

104119
FileAsset file = repository.findById(fileId)
105-
.orElseThrow(() -> new FileAssetNotFoundException("File not found in the database: " + fileId));
120+
.orElseThrow(() -> new FileAssetNotFoundException(FILE_NOT_FOUND_IN_THE_DATABASE + fileId));
106121

107122
checkOwnerOrAdmin(file.getUserId(), currentUserId);
108123

@@ -121,7 +136,7 @@ public void deleteSoft(Long fileId) {
121136
@Transactional(propagation = Propagation.REQUIRES_NEW)
122137
public void deleteHard(Long fileId) {
123138
FileAsset file = repository.findById(fileId)
124-
.orElseThrow(() -> new FileAssetNotFoundException("File not found in the database: " + fileId));
139+
.orElseThrow(() -> new FileAssetNotFoundException(FILE_NOT_FOUND_IN_THE_DATABASE + fileId));
125140

126141
validateAdmin();
127142

@@ -302,6 +317,55 @@ public Map<Long, List<FileDetailsDTO>> getFilesByEntities(RelatedEntityType enti
302317
return resultMap;
303318
}
304319

320+
@Override
321+
@Transactional
322+
public FileResponseDto updateRole(Long fileId, UpdateFileRoleRequestDto request) {
323+
Long currentUserId = securityFacade.getCurrentUserId()
324+
.orElseThrow(() -> new AuthorizationException(NOT_AUTHENTICATED, ErrorCode.ACCESS_DENIED));
325+
326+
FileAsset file = repository.findById(fileId)
327+
.orElseThrow(() -> new FileAssetNotFoundException(FILE_NOT_FOUND_IN_THE_DATABASE + fileId));
328+
329+
checkOwnerOrAdmin(file.getUserId(), currentUserId);
330+
331+
if (!file.getStatus().equals(FileStatus.ATTACHED)) {
332+
throw new ValidationException(
333+
"File must be in ATTACHED state to update its role",
334+
ErrorCode.FILE_VALIDATION_FAILED);
335+
}
336+
337+
StorageProvider provider = providerResolver.resolve(file.getStorageProvider());
338+
339+
if (file.getFileRole().equals(request.getNewRole())) {
340+
FileAsset saved = repository.save(file);
341+
FileResponseDto dto = fileMapper.toDto(saved);
342+
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));
343+
return dto;
344+
}
345+
346+
// Validate if new role is allowed for the file extension
347+
FilePolicy newPolicy = filePolicyResolver.resolve(file.getRelatedEntityType(), request.getNewRole());
348+
String extension = extractExtension(file.getOriginalFilename());
349+
350+
if (isExtensionNotAllowed(extension, newPolicy.getAllowedExtensions())) {
351+
throw new ValidationException(
352+
"File '%s' has extension '%s' which is not allowed for role %s. Allowed: %s.".formatted(
353+
file.getOriginalFilename(), extension, request.getNewRole(),
354+
formatAllowed(newPolicy.getAllowedExtensions())),
355+
ErrorCode.FILE_VALIDATION_FAILED);
356+
}
357+
358+
file.setFileRole(request.getNewRole());
359+
FileAsset saved = repository.save(file);
360+
361+
log.debug("Updated file role for id={} to {}", saved.getId(), saved.getFileRole());
362+
363+
FileResponseDto dto = fileMapper.toDto(saved);
364+
dto.setUrl(provider.getFileUrl(saved.getStorageKey()));
365+
366+
return dto;
367+
}
368+
305369
/**
306370
* Uploads a single file to the default storage provider and persists its
307371
* metadata.

src/main/java/com/itasocialacademy/oitassist/filemanager/service/interfaces/FileService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileStatus;
66
import com.itasocialacademy.oitassist.filemanager.dao.enums.RelatedEntityType;
77
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
8+
import com.itasocialacademy.oitassist.filemanager.dto.request.UpdateFileRoleRequestDto;
89
import com.itasocialacademy.oitassist.filemanager.dto.response.FileResponseDto;
910
import java.util.List;
1011
import java.util.Map;
@@ -96,4 +97,14 @@ public interface FileService {
9697
*/
9798
Map<Long, List<FileDetailsDTO>> getFilesByEntities(RelatedEntityType entityType, List<Long> entityIds,
9899
Set<FileRole> roles);
100+
101+
/**
102+
* Updates the role of a file if it is in ATTACHED state. Only the owner of the
103+
* file or an ADMIN can update the role.
104+
*
105+
* @param fileId the ID of the file to update
106+
* @param requestDto the DTO containing the new role
107+
* @return the updated file response DTO
108+
*/
109+
FileResponseDto updateRole(Long fileId, UpdateFileRoleRequestDto requestDto);
99110
}

src/main/java/com/itasocialacademy/oitassist/filemanager/validation/enums/AllowedExtension.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ public enum AllowedExtension {
1414
JPEG("jpeg"),
1515
PNG("png"),
1616
GIF("gif"),
17-
WEBP("webp");
17+
WEBP("webp"),
18+
MP4("mp4"),;
1819

1920
private final String rawValue;
2021
}

src/main/java/com/itasocialacademy/oitassist/filemanager/validation/policy/TaskProblemFilePolicy.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ public Set<AllowedExtension> getAllowedExtensions() {
2727
AllowedExtension.DOCX,
2828
AllowedExtension.XLSX,
2929
AllowedExtension.PPTX,
30-
AllowedExtension.ACCDB);
30+
AllowedExtension.ACCDB,
31+
AllowedExtension.JPG,
32+
AllowedExtension.JPEG,
33+
AllowedExtension.PNG,
34+
AllowedExtension.GIF,
35+
AllowedExtension.WEBP);
3136
}
3237

3338
/**

src/main/java/com/itasocialacademy/oitassist/filemanager/validation/policy/TaskReferenceFilePolicy.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ public Set<AllowedExtension> getAllowedExtensions() {
2727
AllowedExtension.DOCX,
2828
AllowedExtension.XLSX,
2929
AllowedExtension.PPTX,
30-
AllowedExtension.ACCDB);
30+
AllowedExtension.ACCDB,
31+
AllowedExtension.JPG,
32+
AllowedExtension.JPEG,
33+
AllowedExtension.PNG,
34+
AllowedExtension.GIF,
35+
AllowedExtension.WEBP,
36+
AllowedExtension.MP4);
3137
}
3238

3339
/**

src/test/java/com/itasocialacademy/oitassist/filemanager/controller/FileControllerTest.java

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
package com.itasocialacademy.oitassist.filemanager.controller;
22

33
import static org.mockito.ArgumentMatchers.any;
4-
import static org.mockito.Mockito.doThrow;
5-
import static org.mockito.Mockito.verify;
6-
import static org.mockito.Mockito.when;
7-
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
8-
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
4+
import static org.mockito.ArgumentMatchers.eq;
5+
import static org.mockito.Mockito.*;
6+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
97
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
108
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
119

1210
import com.itasocialacademy.oitassist.ControllerUnitTest;
1311
import com.itasocialacademy.oitassist.core.enums.ErrorCode;
1412
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
13+
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
1514
import com.itasocialacademy.oitassist.filemanager.dao.enums.RelatedEntityType;
1615
import com.itasocialacademy.oitassist.filemanager.dto.request.FileUploadRequestDto;
16+
import com.itasocialacademy.oitassist.filemanager.dto.request.UpdateFileRoleRequestDto;
1717
import com.itasocialacademy.oitassist.filemanager.dto.response.FileResponseDto;
1818
import com.itasocialacademy.oitassist.filemanager.exceptions.FileAssetNotFoundException;
1919
import com.itasocialacademy.oitassist.filemanager.exceptions.FileUploadException;
@@ -233,4 +233,30 @@ void triggerManualCleanup_ShouldReturnForbidden_WhenUserIsNotAdmin() throws Exce
233233
mockMvc.perform(delete(FILES_CLEANUP_URL))
234234
.andExpect(status().isForbidden());
235235
}
236+
237+
@Test
238+
void updateRole_ShouldReturn200_WhenValidRequest() throws Exception {
239+
Long fileId = 1L;
240+
FileResponseDto responseDto = FileResponseDto.builder().id(fileId).build();
241+
242+
when(fileService.updateRole(
243+
eq(fileId),
244+
argThat(request -> request.getNewRole() == FileRole.PROBLEM)))
245+
.thenReturn(responseDto);
246+
247+
mockMvc.perform(patch("/api/v1/files/{id}/role", fileId)
248+
.param("newRole", "PROBLEM"))
249+
.andExpect(status().isOk())
250+
.andExpect(jsonPath("$.id").value(fileId));
251+
}
252+
253+
@Test
254+
void updateRole_ShouldReturn400_WhenRoleIsNull() throws Exception {
255+
Long fileId = 1L;
256+
257+
mockMvc.perform(patch("/api/v1/files/{id}/role", fileId))
258+
.andExpect(status().isBadRequest());
259+
260+
verifyNoInteractions(fileService);
261+
}
236262
}

0 commit comments

Comments
 (0)