diff --git a/README.md b/README.md index 17a65b0..278fb0c 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,32 @@ createdAt = 2026-03-27 ``` - Check MinIO Console → your bucket → object is present. -5) Test file download (GET) -- Take the `id` from the upload response above and request: +5) Secure download via presigned URL (Week 2) + +- Hämta en presignerad URL (kräver JWT och behörighet): + ```bash + curl -H "Authorization: Bearer " \ + -X POST "http://localhost:8080/api/files/42/presign?ttl=120" + ``` +- Svar: + ```json + { "url": "http://localhost:9000/alfs-attachments/?X-Amz-...", "expiresInSeconds": 120 } + ``` +- Öppna URL:en i webbläsare eller via curl för direktnedladdning från MinIO. +- Content-Disposition sätts så att webbläsaren föreslår originalfilnamnet. + +6) Direkt download-endpoint (GET) +- Finns kvar för utveckling/test: `GET /api/files/{id}/download`. +- Kräver nu inloggning och samma behörighetsregler som presign. +- Exempel: ```bash - curl -v -o downloaded.pdf "http://localhost:8080/api/files/42/download" + curl -H "Authorization: Bearer " -v -o downloaded.pdf "http://localhost:8080/api/files/42/download" ``` -- The file should be downloaded as `downloaded.pdf`. +- Rekommenderad väg för klienter är presigned URL-flödet ovan. Notes -- No authentication is enforced on these endpoints yet (Week 1 scope). +- Upload (POST /api/files/upload) är öppen i dev enligt Week 1 scope; presign och download kräver JWT och behörighet. +- Behörighetsregler (Week 2): ADMIN/INVESTIGATOR alltid; REPORTER endast om ägare av ärendet. - Ensure a Ticket with the provided `ticketId` exists in the database before uploading. diff --git a/pom.xml b/pom.xml index e10e23c..43aeeb4 100644 --- a/pom.xml +++ b/pom.xml @@ -80,17 +80,18 @@ org.springframework.boot - spring-boot-starter-security-test + spring-boot-starter-test test - org.springframework.boot - spring-boot-starter-validation-test + org.springframework.security + spring-security-test test + org.springframework.boot - spring-boot-starter-webmvc-test + spring-boot-test-autoconfigure test diff --git a/src/main/java/org/example/alfs/config/S3Properties.java b/src/main/java/org/example/alfs/config/S3Properties.java index 4e4ed87..360a50f 100644 --- a/src/main/java/org/example/alfs/config/S3Properties.java +++ b/src/main/java/org/example/alfs/config/S3Properties.java @@ -19,6 +19,9 @@ public class S3Properties { private String region; private boolean secure; + // Maximal giltighetstid (sekunder) för presignerade URL:er. Standard: 24h. + private int presignMaxTtlSeconds = 86400; + public String getEndpoint() { return endpoint; } @@ -66,4 +69,12 @@ public boolean isSecure() { public void setSecure(boolean secure) { this.secure = secure; } + + public int getPresignMaxTtlSeconds() { + return presignMaxTtlSeconds; + } + + public void setPresignMaxTtlSeconds(int presignMaxTtlSeconds) { + this.presignMaxTtlSeconds = presignMaxTtlSeconds; + } } diff --git a/src/main/java/org/example/alfs/config/SecurityConfig.java b/src/main/java/org/example/alfs/config/SecurityConfig.java index b2e7a36..e74208f 100644 --- a/src/main/java/org/example/alfs/config/SecurityConfig.java +++ b/src/main/java/org/example/alfs/config/SecurityConfig.java @@ -56,6 +56,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti //allow access to endpoints during development .requestMatchers("/tickets/previewTicket").permitAll() .requestMatchers(HttpMethod.POST, "/api/files/upload").permitAll() + // presign ska kräva inloggning (ingen finmaskig ägarskap ännu) + .requestMatchers(HttpMethod.POST, "/api/files/*/presign").authenticated() .requestMatchers("/css/**", "/js/**", "/images/**", "/static/**").permitAll() .requestMatchers("/login", "/login-form").permitAll() .requestMatchers("/signup", "/signup-form").permitAll() diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index f2a82a7..a44dc9e 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -2,8 +2,15 @@ import io.minio.GetObjectResponse; import org.example.alfs.entities.Attachment; +import org.example.alfs.dto.attachment.PresignedUrlResponseDTO; import org.example.alfs.repositories.AttachmentRepository; +import org.example.alfs.security.SecurityUtils; +import org.example.alfs.entities.User; import org.example.alfs.services.storage.MinioStorageService; +import org.example.alfs.services.AuditService; +import org.example.alfs.enums.AuditAction; +import org.example.alfs.services.AuthorizationService; +import org.example.alfs.config.S3Properties; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; @@ -14,6 +21,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -25,11 +34,23 @@ public class AttachmentDownloadController { private final AttachmentRepository attachmentRepository; private final MinioStorageService storageService; + private final SecurityUtils securityUtils; + private final AuditService auditService; + private final AuthorizationService authorizationService; + private final S3Properties s3Properties; public AttachmentDownloadController(AttachmentRepository attachmentRepository, - MinioStorageService storageService) { + MinioStorageService storageService, + SecurityUtils securityUtils, + AuditService auditService, + AuthorizationService authorizationService, + S3Properties s3Properties) { this.attachmentRepository = attachmentRepository; this.storageService = storageService; + this.securityUtils = securityUtils; + this.auditService = auditService; + this.authorizationService = authorizationService; + this.s3Properties = s3Properties; } @GetMapping("/{id}/download") @@ -37,6 +58,30 @@ public ResponseEntity download(@PathVariable Long id) { Attachment att = attachmentRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id)); + // Säkerställ att den som laddar ner har rättigheter (samma regler som för presign) + User current = securityUtils.getCurrentUser(); + if (!authorizationService.canAccessAttachment(current, att)) { + auditService.log( + AuditAction.ACCESS_DENIED, + "attachment", + null, + "download denied: attachmentId=" + att.getId(), + att.getTicket(), + current + ); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment"); + } + + // Audit: registrera nedladdningsförsök + auditService.log( + AuditAction.FILE_DOWNLOAD_REQUESTED, + "attachment", + null, + "download requested: attachmentId=" + att.getId(), + att.getTicket(), + current + ); + final GetObjectResponse object; try { object = storageService.download(att.getS3Key()); @@ -68,4 +113,61 @@ public ResponseEntity download(@PathVariable Long id) { return builder.body(resource); } + + @PostMapping("/{id}/presign") + public ResponseEntity presign(@PathVariable Long id, + @RequestParam(name = "ttl", required = false) Integer ttlSeconds) { + Attachment att = attachmentRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id)); + + // Enkel behörighetskontroll (steg 2, vecka 2): + // - ADMIN och INVESTIGATOR får alltid presigna + // - REPORTER får endast presigna om hen äger ärendet (ticket.reporter) + User current = securityUtils.getCurrentUser(); + if (!authorizationService.canAccessAttachment(current, att)) { + // Audit: access denied to presign for this attachment + auditService.log( + AuditAction.ACCESS_DENIED, + "attachment", + null, + "presign denied: attachmentId=" + att.getId(), + att.getTicket(), + current + ); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment"); + } + + // Validera och begränsa TTL enligt konfiguration + int maxTtl = Math.max(1, s3Properties.getPresignMaxTtlSeconds()); + int defaultTtl = Math.min(120, maxTtl); + + int ttl = (ttlSeconds == null ? defaultTtl : ttlSeconds); + if (ttl <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ttl must be a positive integer (seconds)"); + } + if (ttl > maxTtl) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "ttl exceeds max allowed (" + maxTtl + " seconds). Configure storage.s3.presign-max-ttl-seconds if needed."); + } + + final String url; + try { + // Använd variant som sätter Content-Disposition till originalfilnamnet + url = storageService.generatePresignedGetUrlWithContentDisposition(att.getS3Key(), ttl, att.getFileName()); + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Failed to create presigned URL", ex); + } + + // Audit: presigned URL issued + auditService.log( + AuditAction.FILE_PRESIGNED, + "attachment", + null, + "presign issued: attachmentId=" + att.getId() + ", ttl=" + ttl, + att.getTicket(), + current + ); + + return ResponseEntity.ok(new PresignedUrlResponseDTO(url, ttl)); + } } diff --git a/src/main/java/org/example/alfs/dto/attachment/PresignedUrlResponseDTO.java b/src/main/java/org/example/alfs/dto/attachment/PresignedUrlResponseDTO.java new file mode 100644 index 0000000..cf813f8 --- /dev/null +++ b/src/main/java/org/example/alfs/dto/attachment/PresignedUrlResponseDTO.java @@ -0,0 +1,23 @@ +package org.example.alfs.dto.attachment; + +/** + * Litet svar-DTO för att returnera en presignerad URL och dess TTL i sekunder. + * Används av kommande presign-endpoint. + */ +public class PresignedUrlResponseDTO { + private final String url; + private final int expiresInSeconds; + + public PresignedUrlResponseDTO(String url, int expiresInSeconds) { + this.url = url; + this.expiresInSeconds = expiresInSeconds; + } + + public String getUrl() { + return url; + } + + public int getExpiresInSeconds() { + return expiresInSeconds; + } +} diff --git a/src/main/java/org/example/alfs/enums/AuditAction.java b/src/main/java/org/example/alfs/enums/AuditAction.java index 44f2fd3..c1df625 100644 --- a/src/main/java/org/example/alfs/enums/AuditAction.java +++ b/src/main/java/org/example/alfs/enums/AuditAction.java @@ -6,5 +6,8 @@ public enum AuditAction { ASSIGNED, UNASSIGNED, COMMENT_ADDED, - ATTACHMENT_ADDED + ATTACHMENT_ADDED, + FILE_DOWNLOAD_REQUESTED, + FILE_PRESIGNED, + ACCESS_DENIED } \ No newline at end of file diff --git a/src/main/java/org/example/alfs/services/AuthorizationService.java b/src/main/java/org/example/alfs/services/AuthorizationService.java new file mode 100644 index 0000000..9debf1b --- /dev/null +++ b/src/main/java/org/example/alfs/services/AuthorizationService.java @@ -0,0 +1,32 @@ +package org.example.alfs.services; + +import org.example.alfs.entities.Attachment; +import org.example.alfs.entities.User; +import org.example.alfs.enums.Role; +import org.springframework.stereotype.Service; + +@Service +public class AuthorizationService { + + /** + * Kontrollerar om användaren får åtkomst till en bilaga kopplad till ett ärende. + * Regler (vecka 2): + * - ADMIN och INVESTIGATOR: alltid tillåtna + * - REPORTER: endast om användaren är reporter för ticketen + */ + public boolean canAccessAttachment(User user, Attachment attachment) { + if (user == null || attachment == null) return false; + + Role role = user.getRole(); + if (role == Role.ADMIN || role == Role.INVESTIGATOR) return true; + + if (role == Role.REPORTER) { + var ticket = attachment.getTicket(); + return ticket != null + && ticket.getReporter() != null + && ticket.getReporter().getId().equals(user.getId()); + } + + return false; + } +} diff --git a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java index a051f81..d0a94d3 100644 --- a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java +++ b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java @@ -2,15 +2,20 @@ import io.minio.GetObjectArgs; import io.minio.GetObjectResponse; +import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; +import io.minio.http.Method; import org.example.alfs.config.S3Properties; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.UUID; +import java.util.HashMap; +import java.util.Map; @Service public class MinioStorageService { @@ -31,7 +36,7 @@ public String upload(MultipartFile file) throws Exception { if (fileName == null || fileName.isBlank()) { fileName = "file"; } - String objectKey = UUID.randomUUID() + "/" + sanitize(fileName); + String objectKey = UUID.randomUUID() + "/" + sanitizeFileName(fileName); try (InputStream is = file.getInputStream()) { String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream"; @@ -65,7 +70,119 @@ public void delete(String objectKey) throws Exception { ); } - private String sanitize(String name) { - return name.replace("\\", "_").replace("/", "_"); + /** + * Skapar en tidsbegränsad (pre-signerad) GET-URL för ett objekt i S3/MinIO. + * + * @param objectKey S3/MinIO-objektets key + * @param ttlSeconds Giltighetstid i sekunder (MinIO/S3 begränsar maxvärden, t.ex. upp till 7 dagar) + * @return Publik URL som kan användas för direkt nedladdning under giltighetstiden + */ + public String generatePresignedGetUrl(String objectKey, int ttlSeconds) throws Exception { + if (ttlSeconds <= 0) { + ttlSeconds = 60; // defensivt standardvärde + } + GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(props.getBucket()) + .object(objectKey) + .expiry(ttlSeconds) + .build(); + return minioClient.getPresignedObjectUrl(args); + } + + /** + * Variant som inkluderar response-content-disposition så att webbläsaren föreslår originalfilnamn. + */ + public String generatePresignedGetUrlWithContentDisposition(String objectKey, int ttlSeconds, String fileName) throws Exception { + if (ttlSeconds <= 0) { + ttlSeconds = 60; + } + // Sanitize the supplied filename for safe use in headers and URLs + String safeName = sanitizeFileName(fileName); + String quoted = escapeForQuotedString(safeName); + String rfc5987 = rfc5987Encode(safeName); + // S3-kompatibla tjänster accepterar "response-content-disposition" som query-param + String disposition = "attachment; filename=\"" + quoted + "\"; filename*=UTF-8''" + rfc5987; + Map extra = new HashMap<>(); + extra.put("response-content-disposition", disposition); + + GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(props.getBucket()) + .object(objectKey) + .expiry(ttlSeconds) + .extraQueryParams(extra) + .build(); + return minioClient.getPresignedObjectUrl(args); + } + + /** + * Sanitizes a file name for safe use in headers/paths: + * - Trim whitespace + * - Remove CR/LF and other control characters + * - Replace path separators and dangerous characters with underscore + * - Fallback to "file" if empty after sanitization + */ + private String sanitizeFileName(String name) { + if (name == null) return "file"; + String trimmed = name.trim(); + // remove control chars including CR(\r), LF(\n), tab, etc. + StringBuilder sb = new StringBuilder(trimmed.length()); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (c < 0x20 || c == 0x7F) { + continue; // skip control chars + } + // Disallow path separators and reserved header-breaking chars + if (c == '/' || c == '\\' || c == '"' || c == '\'' || c == ':' || c == ';' || c == ',' || c == '\n' || c == '\r') { + sb.append('_'); + } else { + sb.append(c); + } + } + String result = sb.toString(); + if (result.isBlank()) return "file"; + return result; + } + + /** + * Escapes a value for use inside a quoted-string (RFC 2616/7230 style): + * backslash-escape backslashes and double quotes. + */ + private String escapeForQuotedString(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\'); + } + sb.append(c); + } + return sb.toString(); + } + + /** + * RFC 5987 percent-encoding for HTTP header parameters using UTF-8. + * Spaces are encoded as %20 (not '+'), and non-ASCII bytes are percent-encoded. + */ + private String rfc5987Encode(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + StringBuilder sb = new StringBuilder(bytes.length * 3); + for (byte b : bytes) { + int v = b & 0xFF; + // unreserved according to RFC 3986: ALPHA / DIGIT / "-" / "." / "_" / "~" + if ((v >= 'a' && v <= 'z') || (v >= 'A' && v <= 'Z') || (v >= '0' && v <= '9') + || v == '-' || v == '.' || v == '_' || v == '~') { + sb.append((char) v); + } else if (v == ' ') { + sb.append("%20"); + } else { + sb.append('%'); + String hex = Integer.toHexString(v).toUpperCase(); + if (hex.length() == 1) sb.append('0'); + sb.append(hex); + } + } + return sb.toString(); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 76e45ae..7bce467 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,13 +4,15 @@ spring.application.name=alfs #Disable whitelabel spring.web.error.whitelabel.enabled=false -# Storage (MinIO/S3) ? default local dev values; override via env in andra milj�er +# Storage (MinIO/S3) ? default local dev values; override via env in andra miljoer storage.s3.endpoint=http://localhost:9000 storage.s3.accessKey=minio storage.s3.secretKey=minio123 storage.s3.bucket=alfs-attachments storage.s3.region=us-east-1 storage.s3.secure=false +# Max giltighetstid i sekunder for presignerade URL:er (kan overridas via env) +storage.s3.presign-max-ttl-seconds=86400 # Multipart upload limits (kan justeras vid behov) spring.servlet.multipart.max-file-size=50MB diff --git a/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java b/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java new file mode 100644 index 0000000..08ca26c --- /dev/null +++ b/src/test/java/org/example/alfs/controllers/AttachmentControllerUploadTest.java @@ -0,0 +1,107 @@ +package org.example.alfs.controllers; + +import org.example.alfs.repositories.AttachmentRepository; +import org.example.alfs.security.SecurityUtils; +import org.example.alfs.services.AttachmentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AttachmentControllerUploadTest { + + private MockMvc mockMvc; + + private AttachmentService attachmentService; + private AttachmentRepository attachmentRepository; + private SecurityUtils securityUtils; + + @BeforeEach + void setup() { + attachmentService = mock(AttachmentService.class); + attachmentRepository = mock(AttachmentRepository.class); + securityUtils = mock(SecurityUtils.class); + + // For upload in dev we allow anonymous (controller handles null user gracefully) + when(securityUtils.getCurrentUser()).thenThrow(new RuntimeException("No authenticated user in security context")); + + AttachmentController controller = new AttachmentController( + attachmentService, + attachmentRepository, + securityUtils + ); + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void upload_with_ticketId_zero_should_return_400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.pdf", MediaType.APPLICATION_PDF_VALUE, "hello".getBytes() + ); + + mockMvc.perform(multipart("/api/files/upload") + .file(file) + .param("ticketId", "0")) + .andExpect(status().isBadRequest()); + + verify(attachmentService, never()).uploadToTicket(any(), any(), any(), any()); + } + + @Test + void upload_with_empty_file_should_return_400() throws Exception { + MockMultipartFile empty = new MockMultipartFile( + "file", "empty.txt", MediaType.TEXT_PLAIN_VALUE, new byte[0] + ); + + mockMvc.perform(multipart("/api/files/upload") + .file(empty) + .param("ticketId", "1")) + .andExpect(status().isBadRequest()); + + verify(attachmentService, never()).uploadToTicket(any(), any(), any(), any()); + } + + @Test + void upload_success_should_redirect_to_ticket_page_and_call_service() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.pdf", MediaType.APPLICATION_PDF_VALUE, "hello".getBytes() + ); + + mockMvc.perform(multipart("/api/files/upload") + .file(file) + .param("ticketId", "1")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/1")); + + verify(attachmentService, times(1)).uploadToTicket(eq(1L), any(), isNull(), isNull()); + } + + @Test + void upload_with_token_should_redirect_to_token_url_and_call_service_with_token() throws Exception { + // Arrange + MockMultipartFile file = new MockMultipartFile( + "file", "tokenfile.pdf", MediaType.APPLICATION_PDF_VALUE, "hello token".getBytes() + ); + + // Act & Assert + mockMvc.perform(multipart("/api/files/upload") + .file(file) + .param("ticketId", "1") + .param("token", "abc123")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/token/abc123")); + + // Verify the service is called with the provided token and null user (dev flow allows anonymous) + verify(attachmentService, times(1)).uploadToTicket(eq(1L), any(), isNull(), eq("abc123")); + } +} diff --git a/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java new file mode 100644 index 0000000..395a329 --- /dev/null +++ b/src/test/java/org/example/alfs/controllers/AttachmentDownloadControllerTest.java @@ -0,0 +1,335 @@ +package org.example.alfs.controllers; + +import org.example.alfs.config.S3Properties; +import org.example.alfs.entities.Attachment; +import org.example.alfs.entities.Ticket; +import org.example.alfs.entities.User; +import org.example.alfs.enums.Role; +import org.example.alfs.repositories.AttachmentRepository; +import org.example.alfs.security.SecurityUtils; +import org.example.alfs.services.AuditService; +import org.example.alfs.services.AuthorizationService; +import org.example.alfs.services.storage.MinioStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.hamcrest.Matchers.containsString; + +class AttachmentDownloadControllerTest { + + private MockMvc mockMvc; + + private AttachmentRepository attachmentRepository; + private MinioStorageService storageService; + private SecurityUtils securityUtils; + private AuditService auditService; + private AuthorizationService authorizationService; + private S3Properties s3Properties; + + @BeforeEach + void setup() { + attachmentRepository = mock(AttachmentRepository.class); + storageService = mock(MinioStorageService.class); + securityUtils = mock(SecurityUtils.class); + auditService = mock(AuditService.class); + authorizationService = mock(AuthorizationService.class); + s3Properties = mock(S3Properties.class); + + AttachmentDownloadController controller = new AttachmentDownloadController( + attachmentRepository, + storageService, + securityUtils, + auditService, + authorizationService, + s3Properties + ); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice() + .build(); + } + + private static Attachment sampleAttachment() { + Ticket t = new Ticket(); + User reporter = new User(); + reporter.setId(10L); + reporter.setRole(Role.REPORTER); + t.setReporter(reporter); + + Attachment a = new Attachment(); + a.setId(1L); + a.setTicket(t); + a.setFileName("test.pdf"); + a.setS3Key("some/key"); + return a; + } + + private static User sampleUser() { + User u = new User(); + u.setId(99L); + u.setRole(Role.ADMIN); + u.setUsername("admin"); + return u; + } + + @Test + void presign_ttl_exceeds_max_returns_400() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + + // Configure max TTL to 60 seconds + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(60); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + // Verify no presign call attempted due to validation failure + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_access_denied_returns_403_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(false); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + // Verify audit logged ACCESS_DENIED (we don't assert exact params to keep test resilient) + verify(auditService).log(any(), any(), any(), any(), any(), any()); + // Ensure storage was never called + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_success_returns_200_with_url_and_ttl_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any())) + .thenReturn("http://presigned.example/object?sig=abc"); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"expiresInSeconds\":120"))) + .andExpect(content().string(containsString("http://presigned.example/object?sig=abc"))); + + // Verify audit logged (FILE_PRESIGNED) and storage called + verify(auditService).log(any(), any(), any(), any(), any(), any()); + verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_without_ttl_uses_default_120_when_max_is_higher() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); // max > 120, so default should be 120 + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), eq(120), any())) + .thenReturn("http://presigned.example/object?sig=default120"); + + // Act & Assert (no ttl param) + mockMvc.perform(post("/api/files/1/presign") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"expiresInSeconds\":120"))) + .andExpect(content().string(containsString("http://presigned.example/object?sig=default120"))); + + // Verify storage called with ttl=120 + verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), eq(120), any()); + } + + @Test + void presign_without_ttl_uses_max_when_max_less_than_120() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(60); // max < 120, so default should be 60 + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), eq(60), any())) + .thenReturn("http://presigned.example/object?sig=default60"); + + // Act & Assert (no ttl param) + mockMvc.perform(post("/api/files/1/presign") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"expiresInSeconds\":60"))) + .andExpect(content().string(containsString("http://presigned.example/object?sig=default60"))); + + // Verify storage called with ttl=60 + verify(storageService).generatePresignedGetUrlWithContentDisposition(any(), eq(60), any()); + } + + @Test + void download_access_denied_returns_403_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(false); + + // Act & Assert + mockMvc.perform(get("/api/files/1/download")) + .andExpect(status().isForbidden()); + + verify(auditService).log(any(), any(), any(), any(), any(), any()); + verify(storageService, never()).download(any()); + } + + @Test + void download_success_returns_200_and_sets_content_disposition_and_audit_logged() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + + // Mock GetObjectResponse as an InputStream + io.minio.GetObjectResponse objectResponse = Mockito.mock(io.minio.GetObjectResponse.class); + when(objectResponse.read()).thenReturn(-1); // no content read during response build + // headers() returns okhttp3.Headers in newer MinIO; we can just return null-safe behavior by returning null for get("Content-Length") + var headersMock = new okhttp3.Headers.Builder().build(); + when(objectResponse.headers()).thenReturn(headersMock); + + when(storageService.download(any())).thenReturn(objectResponse); + + // Act & Assert + mockMvc.perform(get("/api/files/1/download")) + .andExpect(status().isOk()) + // Relaxed assertion to support RFC 5987 filename* and encoded variants used by Spring + .andExpect(header().string("Content-Disposition", containsString("test.pdf"))); + + verify(auditService).log(any(), any(), any(), any(), any(), any()); + verify(storageService).download(any()); + } + + @Test + void download_storage_failure_returns_502() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(storageService.download(any())).thenThrow(new RuntimeException("boom")); + + // Act & Assert + mockMvc.perform(get("/api/files/1/download")) + .andExpect(status().isBadGateway()); + } + + @Test + void presign_when_attachment_not_found_returns_404() throws Exception { + // Arrange + when(attachmentRepository.findById(404L)).thenReturn(Optional.empty()); + + // Act & Assert + mockMvc.perform(post("/api/files/404/presign") + .param("ttl", "60") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void download_when_attachment_not_found_returns_404() throws Exception { + // Arrange + when(attachmentRepository.findById(404L)).thenReturn(Optional.empty()); + + // Act & Assert + mockMvc.perform(get("/api/files/404/download")) + .andExpect(status().isNotFound()); + } + + @Test + void presign_with_zero_ttl_returns_400() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "0") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_with_negative_ttl_returns_400() throws Exception { + // Arrange + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "-5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verify(storageService, never()).generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any()); + } + + @Test + void presign_storage_failure_returns_502() throws Exception { + // Arrange: happy path until storage throws + Attachment att = sampleAttachment(); + when(attachmentRepository.findById(1L)).thenReturn(Optional.of(att)); + when(securityUtils.getCurrentUser()).thenReturn(sampleUser()); + when(authorizationService.canAccessAttachment(any(User.class), any(Attachment.class))).thenReturn(true); + when(s3Properties.getPresignMaxTtlSeconds()).thenReturn(300); + // Storage fails when generating presigned URL + when(storageService.generatePresignedGetUrlWithContentDisposition(any(), any(Integer.class), any())) + .thenThrow(new RuntimeException("minio down")); + + // Act & Assert + mockMvc.perform(post("/api/files/1/presign") + .param("ttl", "120") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadGateway()); + } +} diff --git a/src/test/java/org/example/alfs/services/AuditServiceDataJpaTest.java b/src/test/java/org/example/alfs/services/AuditServiceDataJpaTest.java new file mode 100644 index 0000000..308fa6c --- /dev/null +++ b/src/test/java/org/example/alfs/services/AuditServiceDataJpaTest.java @@ -0,0 +1,55 @@ +package org.example.alfs.services; + +import org.example.alfs.entities.AuditLog; +import org.example.alfs.entities.Ticket; +import org.example.alfs.entities.User; +import org.example.alfs.enums.AuditAction; +import org.example.alfs.enums.Role; +import org.example.alfs.repositories.AuditLogRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class AuditServiceDataJpaTest { + + private AuditLogRepository auditLogRepository; + private AuditService auditService; + + @BeforeEach + void setup() { + auditLogRepository = mock(AuditLogRepository.class); + // echo back the entity when save is called + when(auditLogRepository.save(any(AuditLog.class))).thenAnswer(inv -> inv.getArgument(0)); + auditService = new AuditService(auditLogRepository); + } + + @Test + void log_should_populate_and_call_repository_save() { + // Arrange + User reporter = new User(); + reporter.setId(5L); + reporter.setUsername("reporter1"); + reporter.setPasswordHash("x"); + reporter.setRole(Role.REPORTER); + + Ticket t = new Ticket(); + t.setId(11L); + t.setTitle("Test case"); + t.setDescription("Desc"); + t.setReporter(reporter); + + // Act + auditService.log(AuditAction.FILE_PRESIGNED, + "attachment", + null, + "presign issued: attachmentId=1, ttl=120", + t, + reporter); + + // Assert + verify(auditLogRepository, times(1)).save(any(AuditLog.class)); + } +} diff --git a/src/test/java/org/example/alfs/services/AuthorizationServiceTest.java b/src/test/java/org/example/alfs/services/AuthorizationServiceTest.java new file mode 100644 index 0000000..4771134 --- /dev/null +++ b/src/test/java/org/example/alfs/services/AuthorizationServiceTest.java @@ -0,0 +1,89 @@ +package org.example.alfs.services; + +import org.example.alfs.entities.Attachment; +import org.example.alfs.entities.Ticket; +import org.example.alfs.entities.User; +import org.example.alfs.enums.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class AuthorizationServiceTest { + + private AuthorizationService authorizationService; + + @BeforeEach + void setUp() { + authorizationService = new AuthorizationService(); + } + + private static User userWithRole(Role role, Long id) { + User u = new User(); + u.setRole(role); + u.setId(id); + return u; + } + + private static Attachment attachmentWithReporter(Long reporterId) { + Ticket t = new Ticket(); + if (reporterId != null) { + User reporter = new User(); + reporter.setId(reporterId); + reporter.setRole(Role.REPORTER); + t.setReporter(reporter); + } else { + t.setReporter(null); + } + Attachment a = new Attachment(); + a.setTicket(t); + return a; + } + + @Test + void admin_should_have_access() { + User admin = userWithRole(Role.ADMIN, 1L); + Attachment att = attachmentWithReporter(2L); + assertTrue(authorizationService.canAccessAttachment(admin, att)); + } + + @Test + void investigator_should_have_access() { + User inv = userWithRole(Role.INVESTIGATOR, 10L); + Attachment att = attachmentWithReporter(2L); + assertTrue(authorizationService.canAccessAttachment(inv, att)); + } + + @Test + void reporter_owner_should_have_access() { + User rep = userWithRole(Role.REPORTER, 5L); + Attachment att = attachmentWithReporter(5L); + assertTrue(authorizationService.canAccessAttachment(rep, att)); + } + + @Test + void reporter_non_owner_should_be_denied() { + User rep = userWithRole(Role.REPORTER, 5L); + Attachment att = attachmentWithReporter(6L); + assertFalse(authorizationService.canAccessAttachment(rep, att)); + } + + @Test + void reporter_when_ticket_has_no_reporter_should_be_denied() { + User rep = userWithRole(Role.REPORTER, 5L); + Attachment att = attachmentWithReporter(null); + assertFalse(authorizationService.canAccessAttachment(rep, att)); + } + + @Test + void null_user_should_be_denied() { + Attachment att = attachmentWithReporter(1L); + assertFalse(authorizationService.canAccessAttachment(null, att)); + } + + @Test + void null_attachment_should_be_denied() { + User admin = userWithRole(Role.ADMIN, 1L); + assertFalse(authorizationService.canAccessAttachment(admin, null)); + } +}