From ef2fd3a30caa1d8d54217cb4ece22b50da1339ca Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Tue, 21 Apr 2026 21:57:04 +0200 Subject: [PATCH 01/10] feat: add file service infrastructure with S3/MinIO support - Added S3Config with S3Client and S3Presigner - Implemented FileService for unique file uploads and presigned URL generation --- compose.yaml | 15 +++ pom.xml | 11 +-- .../config/S3Config.java | 58 +++++++++++ .../file/FileService.java | 99 +++++++++++++++++++ src/main/resources/application.properties | 6 ++ 5 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/example/visacasemanagementsystem/config/S3Config.java create mode 100644 src/main/java/org/example/visacasemanagementsystem/file/FileService.java diff --git a/compose.yaml b/compose.yaml index ce79d1b..57ebce5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,3 +7,18 @@ services: - 'POSTGRES_USER=myuser' ports: - '5432:5432' + + minio: + image: 'minio/minio:latest' + ports: + - '9000:9000' + - '9001:9001' + environment: + - MINIO_ROOT_USER=admin + - MINIO_ROOT_PASSWORD=password + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + +volumes: + minio_data: diff --git a/pom.xml b/pom.xml index bae3cf7..b1a7177 100644 --- a/pom.xml +++ b/pom.xml @@ -143,12 +143,6 @@ testcontainers-postgresql test - - - - - - org.projectlombok lombok @@ -159,6 +153,11 @@ h2 test + + software.amazon.awssdk + s3 + 2.42.20 + diff --git a/src/main/java/org/example/visacasemanagementsystem/config/S3Config.java b/src/main/java/org/example/visacasemanagementsystem/config/S3Config.java new file mode 100644 index 0000000..39509d2 --- /dev/null +++ b/src/main/java/org/example/visacasemanagementsystem/config/S3Config.java @@ -0,0 +1,58 @@ +package org.example.visacasemanagementsystem.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +@Configuration +public class S3Config { + + @Value("${minio.endpoint}") + private String endpoint; + + @Value("${minio.accessKey}") + private String accessKey; + + @Value("${minio.secretKey}") + private String secretKey; + + @Value("${minio.region}") + private String region; + + + @Bean + public S3Client s3Client () { + return S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) +// .checksumValidationEnabled(false) + .build()) + .build(); + } + + @Bean + public S3Presigner s3Presigner () { + return S3Presigner.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .build(); + + } +} diff --git a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java new file mode 100644 index 0000000..da8deba --- /dev/null +++ b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java @@ -0,0 +1,99 @@ +package org.example.visacasemanagementsystem.file; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.io.IOException; +import java.time.Duration; +import java.util.UUID; + +@Service +@Slf4j +public class FileService { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + @Value("${minio.bucketName}") + private String bucketName; + + public FileService(S3Client s3Client, S3Presigner s3Presigner) { + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + } + + @PostConstruct + public void initializeBucket() { + try { + try { + s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build()); + log.info("Bucket '{}' already exists", bucketName); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()); + log.info("Bucket '{}' created successfully", bucketName); + } else { throw e; } + } + } catch (Exception e) { + log.error("Failed to verify/create bucket: {}", e.getMessage()); + } + + + try { + CORSRule corsRule = CORSRule.builder() + .allowedOrigins("*") + .allowedMethods("GET", "PUT", "POST", "DELETE", "HEAD") + .allowedHeaders("*") + .build(); + + s3Client.putBucketCors(PutBucketCorsRequest.builder() + .bucket(bucketName) + .corsConfiguration(CORSConfiguration.builder().corsRules(corsRule).build()) + .build()); + log.info("CORS configuration applied to bucket '{}'", bucketName); + } catch (S3Exception e) { + log.warn("Failed to configure CORS to bucket '{}': {}", bucketName, e.getMessage(), e); + } + } + + public String uploadFile(MultipartFile file) throws IOException { + // Generate a unique file name + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(putObjectRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return fileName; + } + + public String getPresignedDownloadUrl(String fileName) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .responseContentDisposition("attachment; filename=\"" + fileName + "\"") + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f981dd9..291743d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,3 +11,9 @@ spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=false spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect + +minio.endpoint=http://localhost:9000 +minio.accessKey=admin +minio.secretKey=password +minio.bucketName=visa-documents +minio.region=eu-north-1 From 4d78d8cc29e1077890d13f5e5fbb27ed2cf3a9be Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 19:10:22 +0200 Subject: [PATCH 02/10] feat: integrate S3/MinIO file uploads into visa application workflow - Integrated FileService (S3/MinIO) into visa creation and update flows. - Implemented logic for handling multipart file uploads and unique S3 key generation. - Added support for viewing and deleting documents via presigned URLs. - Refactored VisaDTO and UpdateVisaDTO to bridge backend storage with the UI. - Enhanced Thymeleaf templates with real-time file selection feedback (JS). - Fixed Controller rendering issues by ensuring the Model is populated during errors. - Verified workflow with integration tests for file uploads and validation. --- .../file/FileService.java | 8 + .../visa/controller/VisaViewController.java | 68 ++++++- .../visa/dto/UpdateVisaDTO.java | 3 +- .../visa/dto/VisaDTO.java | 5 +- .../visa/mapper/VisaMapper.java | 12 +- .../visa/service/VisaService.java | 72 +++++++- src/main/resources/application.properties | 3 + .../resources/templates/visa/apply-form.html | 54 +++++- .../resources/templates/visa/details.html | 168 +++++++++++------- .../resources/templates/visa/edit-form.html | 83 ++++++++- .../visa/VisaMapperTest.java | 3 +- .../visa/VisaServiceIntegrationTest.java | 8 +- .../visa/VisaServiceTest.java | 18 +- .../visa/VisaViewControllerTest.java | 54 ++++-- 14 files changed, 445 insertions(+), 114 deletions(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java index da8deba..e639782 100644 --- a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java +++ b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java @@ -95,5 +95,13 @@ public String getPresignedDownloadUrl(String fileName) { return s3Presigner.presignGetObject(presignRequest).url().toString(); } + public void deleteFile(String s3Key) { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + s3Client.deleteObject(deleteObjectRequest); + } + } diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java b/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java index 046c037..2fc5086 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java @@ -4,6 +4,7 @@ import jakarta.validation.Valid; import org.example.visacasemanagementsystem.comment.service.CommentService; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.dto.UserDTO; import org.example.visacasemanagementsystem.user.security.UserPrincipal; @@ -19,6 +20,8 @@ import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + import java.util.List; @PreAuthorize("isAuthenticated()") @@ -29,11 +32,13 @@ public class VisaViewController { private final VisaService visaService; private final CommentService commentService; private final UserService userService; + private final FileService fileService; - public VisaViewController(VisaService visaService, CommentService commentService, UserService userService) { + public VisaViewController(VisaService visaService, CommentService commentService, UserService userService, FileService fileService) { this.visaService = visaService; this.commentService = commentService; this.userService = userService; + this.fileService = fileService; } @GetMapping("/dashboard") @@ -81,16 +86,30 @@ public String showApplyForm(@AuthenticationPrincipal UserPrincipal principal, Mo public String submitApplication( @Valid @ModelAttribute("createVisaDTO") CreateVisaDTO createVisaDTO, BindingResult bindingResult, + @RequestParam(value = "passportFile", required = false) MultipartFile passportFile, @AuthenticationPrincipal UserPrincipal principal, Model model) { - + if (bindingResult.hasErrors()) { prepareApplyModel(principal.getUserId(), model); return "visa/apply-form"; } try { - visaService.applyForVisa(createVisaDTO, principal.getUserId()); + String s3Key = null; + + if (passportFile != null && !passportFile.isEmpty()) { + s3Key = fileService.uploadFile(passportFile); + } + + visaService.applyForVisa(createVisaDTO, principal.getUserId(), s3Key); + + } catch (java.io.IOException e) { + bindingResult.reject("upload.error", "Failed to upload document: " + e.getMessage()); + prepareApplyModel(principal.getUserId(), model); + + return "visa/apply-form"; + } catch (IllegalArgumentException e) { bindingResult.rejectValue("travelDate", "error.travelDate", e.getMessage()); prepareApplyModel(principal.getUserId(), model); @@ -118,10 +137,12 @@ public String showEditForm(@PathVariable Long id, @AuthenticationPrincipal UserP visa.nationality(), visa.passportNumber(), visa.travelDate(), - visa.handlerId() + visa.handlerId(), + visa.statusInformation() ); model.addAttribute("updateVisaDto", updateDto); + model.addAttribute("visa", visa); model.addAttribute("currentUser", userDTO); model.addAttribute("visaTypes", VisaType.values()); model.addAttribute("isEdit", true); @@ -133,38 +154,67 @@ public String showEditForm(@PathVariable Long id, @AuthenticationPrincipal UserP @PostMapping("/{id}/edit") public String processUpdate( @PathVariable Long id, - @Valid @ModelAttribute ("updateVisaDto") UpdateVisaDTO updateVisaDTO, + @Valid @ModelAttribute("updateVisaDto") UpdateVisaDTO updateVisaDTO, BindingResult bindingResult, + @RequestParam(value = "passportFile", required = false) MultipartFile passportFile, @AuthenticationPrincipal UserPrincipal principal, Model model) { if (bindingResult.hasErrors()) { + VisaDTO visa = visaService.findVisaDtoById(id); + model.addAttribute("visa", visa); prepareApplyModel(principal.getUserId(), model); model.addAttribute("isEdit", true); - VisaDTO visa = visaService.findVisaDtoById(id); model.addAttribute("statusInformation", visa.statusInformation()); return "visa/edit-form"; } try { - visaService.updateVisa(id, updateVisaDTO, principal.getUserId()); + String newS3Key = null; + + if (passportFile != null && !passportFile.isEmpty()) { + newS3Key = fileService.uploadFile(passportFile); + } + + visaService.updateVisa(id, updateVisaDTO, principal.getUserId(), newS3Key); + + } catch (java.io.IOException e) { + bindingResult.reject("upload.error", "Failed to upload new document: " + e.getMessage()); + VisaDTO visa = visaService.findVisaDtoById(id); + model.addAttribute("visa", visa); + prepareApplyModel(principal.getUserId(), model); + model.addAttribute("isEdit", true); + return "visa/edit-form"; + } catch (IllegalArgumentException e) { - if (e.getMessage().contains("date")) { + if (e.getMessage() != null && e.getMessage().contains("date")) { bindingResult.rejectValue("travelDate", "error.travelDate", e.getMessage()); } else { - bindingResult.reject("globalError",e.getMessage()); + bindingResult.reject("globalError", e.getMessage()); } prepareApplyModel(principal.getUserId(), model); model.addAttribute("isEdit", true); VisaDTO visa = visaService.findVisaDtoById(id); + model.addAttribute("visa", visa); model.addAttribute("statusInformation", visa.statusInformation()); return "visa/edit-form"; } + return "redirect:/visas/" + id; } + @PostMapping("/{id}/documents/delete") + public String deleteVisaDocument(@PathVariable Long id, + @RequestParam String s3Key, + @AuthenticationPrincipal UserPrincipal principal) { + + visaService.removeVisaDocument(id, s3Key, principal.getUserId()); + return "redirect:/visas/" + id + "/edit"; + + } + @PreAuthorize("hasRole('ADMIN')") @PostMapping("/{id}/approve") public String approveVisa(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java b/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java index aaefbba..22f06ad 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/dto/UpdateVisaDTO.java @@ -15,6 +15,7 @@ public record UpdateVisaDTO( @NotBlank(message = "Nationality must be specified") String nationality, @NotBlank(message = "Passport number must be provided") String passportNumber, @NotNull (message = "Travel date must be provided") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate travelDate, - Long handlerId + Long handlerId, + String statusInformation ) { } diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java b/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java index c2369bf..0f2adaa 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/dto/VisaDTO.java @@ -5,6 +5,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; public record VisaDTO( Long id, @@ -19,4 +20,6 @@ public record VisaDTO( String handlerName, LocalDateTime createdAt, LocalDateTime updatedAt, - String statusInformation) {} + String statusInformation, + List downloadUrls, + List s3Keys) {} diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java b/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java index a614f6a..ed31ce9 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/mapper/VisaMapper.java @@ -6,6 +6,8 @@ import org.example.visacasemanagementsystem.visa.entity.Visa; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class VisaMapper { @@ -26,8 +28,9 @@ public VisaDTO toDTO(Visa visa) { visa.getHandler() != null ? visa.getHandler().getFullName() : "Unassigned", visa.getCreatedAt(), visa.getUpdatedAt(), - visa.getStatusInformation() - + visa.getStatusInformation(), + List.of(), + List.copyOf(visa.getS3Keys()) ); } @@ -53,7 +56,8 @@ public void updateEntityFromDTO(UpdateVisaDTO dto, Visa visa) { visa.setPassportNumber(dto.passportNumber()); visa.setTravelDate(dto.travelDate()); + if (dto.statusInformation() != null) { + visa.setStatusInformation(dto.statusInformation()); + } } - - } diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java index 708dbe3..8dac658 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java @@ -3,6 +3,7 @@ import org.example.visacasemanagementsystem.audit.AuditEventType; import org.example.visacasemanagementsystem.audit.service.AuditService; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.repository.UserRepository; @@ -33,13 +34,15 @@ public class VisaService { private final UserRepository userRepository; private final VisaMapper visaMapper; private final AuditService auditService; + private final FileService fileService; - public VisaService(VisaRepository visaRepository, UserRepository userRepository ,VisaMapper visaMapper, AuditService auditService) { + public VisaService(VisaRepository visaRepository, UserRepository userRepository , VisaMapper visaMapper, AuditService auditService, FileService fileService) { this.visaRepository = visaRepository; this.userRepository = userRepository; this.visaMapper = visaMapper; this.auditService = auditService; + this.fileService = fileService; } // --- For filtering in Frontend list-view --- @@ -59,7 +62,30 @@ public VisaDTO findVisaDtoById(Long id) { Visa visa = visaRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException(NOT_FOUND_MESSAGE)); - return visaMapper.toDTO(visa); + VisaDTO dto = visaMapper.toDTO(visa); + + List presignedUrls = visa.getS3Keys().stream() + .map(fileService::getPresignedDownloadUrl) + .toList(); + + return new VisaDTO( + dto.id(), + dto.visaType(), + dto.visaStatus(), + dto.nationality(), + dto.passportNumber(), + dto.travelDate(), + dto.applicantId(), + dto.applicantName(), + dto.handlerId(), + dto.handlerName(), + dto.createdAt(), + dto.updatedAt(), + dto.statusInformation(), + presignedUrls, + dto.s3Keys() + ); + } public List findVisasByApplicant(Long applicantId) { @@ -160,7 +186,7 @@ public VisaDTO updateVisaStatus(Long visaId, VisaStatus newStatus, Long adminId) } @Transactional - public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId) { + public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId, String s3Key) { // Validate travel date validateTravelDate(dto.travelDate()); @@ -173,6 +199,11 @@ public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId) { visa.setApplicant(applicant); visa.setVisaStatus(VisaStatus.SUBMITTED); + + if (s3Key != null && !s3Key.isBlank()) { + visa.getS3Keys().add(s3Key); + } + Visa savedVisa = visaRepository.save(visa); // Create log in database @@ -180,13 +211,13 @@ public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId) { userId, savedVisa.getId(), AuditEventType.CREATED, - "Visa application submitted." + "Visa application submitted." + (s3Key != null ? "Document attached." : "") ); return visaMapper.toDTO(savedVisa); } @Transactional - public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId) { + public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId, String newS3Key) { if (!visaId.equals(dto.id())) { throw new IllegalArgumentException("Mismatched visa id."); } @@ -209,6 +240,10 @@ public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId) { visa.setPassportNumber(dto.passportNumber()); visa.setTravelDate(dto.travelDate()); + if (newS3Key != null) { + visa.getS3Keys().add(newS3Key); + } + visa.setVisaStatus(VisaStatus.SUBMITTED); visa.setStatusInformation(null); @@ -223,6 +258,33 @@ public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId) { return visaMapper.toDTO(savedVisa); } + @Transactional + public void removeVisaDocument(Long visaId, String s3Key, Long userId) { + Visa visa = findVisaById(visaId); + + // Check only the application owner or Admin/sysAdmin can remove files + User user = userRepository.findById(userId).orElseThrow(); + boolean isOwner = visa.getApplicant().getId().equals(user.getId()); + boolean isAdmin = user.getUserAuthorization() != UserAuthorization.USER; + + if (!isOwner && !isAdmin) { + throw new UnauthorizedException("You cannot delete this document."); + } + + if (visa.getS3Keys().remove(s3Key)) { + if (visa.getS3Keys().isEmpty()) { + visa.setVisaStatus(VisaStatus.INCOMPLETE); + } else { + visa.setVisaStatus(VisaStatus.SUBMITTED); + } + visaRepository.save(visa); + + fileService.deleteFile(s3Key); + + auditService.createAuditLog(userId, visaId, AuditEventType.UPDATED, "Removed document: " + s3Key); + } + } + @Transactional public VisaDTO approveVisa(Long visaId, Long adminId) { User admin = validateHandler(adminId); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 291743d..27c422b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,3 +17,6 @@ minio.accessKey=admin minio.secretKey=password minio.bucketName=visa-documents minio.region=eu-north-1 + +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB diff --git a/src/main/resources/templates/visa/apply-form.html b/src/main/resources/templates/visa/apply-form.html index ae95a0b..64918dd 100644 --- a/src/main/resources/templates/visa/apply-form.html +++ b/src/main/resources/templates/visa/apply-form.html @@ -102,8 +102,34 @@ font-size: 0.75rem; margin-top: 8px; display: block; + } + /* Styling for new Choose file-button */ + input[type="file"] { + width: 105px !important; + height: 32px; + color: transparent; + cursor: pointer; + overflow: hidden; } + + input[type="file"]::-webkit-file-upload-button { + display: none; + } + + input[type="file"]::before { + content: 'Choose File'; + display: inline-block; + background: #222; + border: 1px solid #444; + border-radius: 4px; + padding: 5px 12px; + color: #ccc; + font-weight: 600; + font-size: 13px; + } + + @@ -114,7 +140,7 @@

New Application

Logged in as User

-
+
New Application
+
+ +
+ + + + +
+ +

+ Supported Formats: PDF, JPG or PNG. (Max 10MB). +

+
+ Cancel @@ -171,5 +212,16 @@

New Application

+ + diff --git a/src/main/resources/templates/visa/details.html b/src/main/resources/templates/visa/details.html index 1ff39f5..6218a9f 100644 --- a/src/main/resources/templates/visa/details.html +++ b/src/main/resources/templates/visa/details.html @@ -179,9 +179,29 @@

Case #101

Important Information / Notes - + +
+ Attached Documents + + + +
+ + No documents attached. This may delay processing. +
+
+ -

Action Required

@@ -193,7 +213,7 @@

Action Required

- Edit & Resubmit Application + Edit Application
@@ -203,10 +223,12 @@

Case Management

+
+
@@ -220,22 +242,23 @@

Case Management

+ -
+ -
+
@@ -271,66 +294,91 @@

Comments

diff --git a/src/main/resources/templates/visa/edit-form.html b/src/main/resources/templates/visa/edit-form.html index c6bcce7..caa89f3 100644 --- a/src/main/resources/templates/visa/edit-form.html +++ b/src/main/resources/templates/visa/edit-form.html @@ -102,6 +102,31 @@ margin-top: 8px; display: block; } + + input[type="file"] { + width: 105px !important; + height: 32px; + color: transparent; + cursor: pointer; + overflow: hidden; + } + + input[type="file"]::-webkit-file-upload-button { + display: none; + } + + input[type="file"]::before { + content: 'Choose File'; + display: inline-block; + background: #222; + border: 1px solid #444; + border-radius: 4px; + padding: 5px 12px; + color: #ccc; + font-weight: 600; + font-size: 13px; + } + @@ -125,7 +150,7 @@

Edit Application

+ method="post" enctype="multipart/form-data">
@@ -162,9 +187,65 @@

Edit Application

+
+ +
+
+ + + + View Attachment + + + +
+
+
+ +
+ +
+ + + + +
+ +

+ Supported Formats: PDF, JPG or PNG. (Max 10MB). +

+
+ Back to Details
+ +
+
+ +
+
+ + + + diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java index 03866e7..d9e671f 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaMapperTest.java @@ -84,7 +84,8 @@ void shouldUpdateExistingVisaEntityFromUpdateVisaDTO() { "New Nationality", "NEW-456", LocalDate.now().plusMonths(1), - 2L + 2L, + null ); // Act diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java index ad59c6c..73a33a1 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java @@ -39,6 +39,7 @@ class VisaServiceIntegrationTest { void applyForVisa_shouldSaveVisa_WhenDataIsValid() { // Arrange User user = createAndSaveValidUser(); + String testS3Key = "testS3Key"; CreateVisaDTO dto = new CreateVisaDTO( VisaType.STUDY, "Swedish", "PASS-INT-123", @@ -47,12 +48,13 @@ void applyForVisa_shouldSaveVisa_WhenDataIsValid() { ); // Act - visaService.applyForVisa(dto, user.getId()); + visaService.applyForVisa(dto, user.getId(),testS3Key); // Assert var savedVisas = visaRepository.findAll(); assertThat(savedVisas).hasSize(1); assertThat(savedVisas.get(0).getPassportNumber()).isEqualTo("PASS-INT-123"); + assertThat(savedVisas.get(0).getS3Keys()).contains(testS3Key); assertThat(auditService.findAll()).isNotEmpty(); } @@ -75,11 +77,11 @@ void updateVisa_shouldUpdateVisaAndResetStatus_WhenUserIsAuthorized() { UpdateVisaDTO dto = new UpdateVisaDTO( visa.getId(), VisaType.TOURIST, VisaStatus.SUBMITTED, - "Swedish", "NEW-PASS-999", LocalDate.now().plusMonths(1), null + "Swedish", "NEW-PASS-999", LocalDate.now().plusMonths(1), null, null ); // Act - visaService.updateVisa(visa.getId(), dto, user.getId()); + visaService.updateVisa(visa.getId(), dto, user.getId(), null); // Assert Visa updatedVisa = visaRepository.findById(visa.getId()).get(); diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java index 63ec4df..1d62bb7 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceTest.java @@ -40,8 +40,6 @@ class VisaServiceTest { @InjectMocks private VisaService visaService; - // Find metoder? - @Test void applyForVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInThePast() { // Arrange @@ -53,7 +51,7 @@ void applyForVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInThePast( ); // Act & Assert - assertThatThrownBy(() -> visaService.applyForVisa(dto, userId)) + assertThatThrownBy(() -> visaService.applyForVisa(dto, userId, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Travel date cannot be in the past."); @@ -74,7 +72,7 @@ void applyForVisa_shouldThrowException_WhenUserIsNotFoundInDb() { when(userRepository.findById(userId)).thenReturn(Optional.empty()); // Act & Assert - assertThatThrownBy(() -> visaService.applyForVisa(dto, userId)) + assertThatThrownBy(() -> visaService.applyForVisa(dto, userId, null)) .isInstanceOf(EntityNotFoundException.class) .hasMessage("User not found"); } @@ -88,7 +86,7 @@ void updateVisa_shouldThrowUnauthorizedException_WhenUserIsNotTheApplicant() { UpdateVisaDTO dto = new UpdateVisaDTO(100L, VisaType.EMPLOYMENT, VisaStatus.SUBMITTED, "US", "123", - LocalDate.now().plusDays(1), null); + LocalDate.now().plusDays(1), null, null); User actualApplicant = new User(); actualApplicant.setId(actualApplicantId); @@ -99,7 +97,7 @@ void updateVisa_shouldThrowUnauthorizedException_WhenUserIsNotTheApplicant() { when(visaRepository.findById(anyLong())).thenReturn(Optional.of(visa)); // Act & Assert - assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,unauthorizedUserId)) + assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,unauthorizedUserId, null)) .isInstanceOf(UnauthorizedException.class) .hasMessage("You are not authorized to update this application."); @@ -112,7 +110,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenStatusIsNotEditable() { UpdateVisaDTO dto = new UpdateVisaDTO( visaId, VisaType.STUDY, VisaStatus.SUBMITTED, "SE", "123", - LocalDate.now().plusDays(10), null); + LocalDate.now().plusDays(10), null, null); User user = new User(); user.setId(userId); @@ -124,7 +122,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenStatusIsNotEditable() { when(visaRepository.findById(anyLong())).thenReturn(Optional.of(visa)); // Act & Assert - assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,userId)) + assertThatThrownBy(() -> visaService.updateVisa(visaId, dto,userId, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("This application can no longer be edited."); } @@ -137,7 +135,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInPast() { LocalDate pastDate = LocalDate.now().minusDays(1); UpdateVisaDTO dto = new UpdateVisaDTO(100L, VisaType.STUDY, - VisaStatus.SUBMITTED, "Swedish", "CDE789", pastDate, null); + VisaStatus.SUBMITTED, "Swedish", "CDE789", pastDate, null, null); User actualUser = new User(); actualUser.setId(userId); @@ -149,7 +147,7 @@ void updateVisa_shouldThrowIllegalArgumentException_WhenTravelDateIsInPast() { when(visaRepository.findById(anyLong())).thenReturn(Optional.of(visa)); // Act & Assert - assertThatThrownBy(() -> visaService.updateVisa(visaId, dto, userId)) + assertThatThrownBy(() -> visaService.updateVisa(visaId, dto, userId, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Travel date cannot be in the past."); diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java index 95e2436..bd6650d 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java @@ -3,6 +3,7 @@ import org.example.visacasemanagementsystem.comment.dto.CommentDTO; import org.example.visacasemanagementsystem.comment.service.CommentService; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.security.UserPrincipal; @@ -34,9 +35,9 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -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.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.mock.web.MockMultipartFile; @WebMvcTest(VisaViewController.class) class VisaViewControllerTest { @@ -49,6 +50,8 @@ class VisaViewControllerTest { private UserService userService; @MockitoBean private CommentService commentService; + @MockitoBean + private FileService fileService; @AfterEach void tearDown() { @@ -123,18 +126,26 @@ void submitApplication_WithValidData_ShouldRedirectToDashboard() throws Exceptio Long userId = 1L; UserDTO mockUser = createMockUser(userId, UserAuthorization.USER); + MockMultipartFile mockFile = new MockMultipartFile( + "passportFile", // Namnet MΓ…STE matcha @RequestParam i controllern + "test.pdf", + "application/pdf", + "test content".getBytes() + ); + // Act & Assert - mockMvc.perform(post("/visas/apply") - .param("currentUserId", userId.toString()) - .param("visaType", "TOURIST") - .param("nationality", "Swedish") - .param("passportNumber", "ABC12345") - .param("travelDate", "2026-12-01") - .with(csrf())) + mockMvc.perform(multipart("/visas/apply") + .file(mockFile) + .param("currentUserId", userId.toString()) + .param("visaType", "TOURIST") + .param("nationality", "Swedish") + .param("passportNumber", "ABC12345") + .param("travelDate", "2026-12-01") + .with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/visas/dashboard")); - verify(visaService).applyForVisa(any(CreateVisaDTO.class), eq(userId)); + verify(visaService).applyForVisa(any(CreateVisaDTO.class), eq(userId),any()); } @Test @@ -210,12 +221,14 @@ void processUpdate_Success_ShouldRedirectToDetails() throws Exception { Long visaId = 100L; Long userId = 1L; String expectedUrl = "/visas/" + visaId; + MockMultipartFile mockFile = new MockMultipartFile("passportFile", "", "application/octet-stream", new byte[0]); when(userService.findById(userId)).thenReturn(Optional.of(createMockUser(userId, UserAuthorization.USER))); when(visaService.findVisaDtoById(visaId)).thenReturn(createMockVisa(visaId, userId)); // Act - var result = mockMvc.perform(post("/visas/{id}/edit", visaId) + var result = mockMvc.perform(multipart("/visas/{id}/edit", visaId) + .file(mockFile) .param("id", visaId.toString()) .param("currentUserId", userId.toString()) .param("visaType", "TOURIST") @@ -229,7 +242,7 @@ void processUpdate_Success_ShouldRedirectToDetails() throws Exception { result.andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl(expectedUrl)); - verify(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId)); + verify(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId), any()); } @@ -267,12 +280,14 @@ void processUpdate_Unauthorized_ShouldReturnForbiddenStatus() throws Exception { Long visaId = 100L; Long userId = 1L; UserDTO mockUser = createMockUser(userId, UserAuthorization.USER); + MockMultipartFile mockFile = new MockMultipartFile("passportFile", "", "application/octet-stream", new byte[0]); doThrow(new UnauthorizedException("You are not authorized to update this application.")) - .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId)); + .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId), any()); // Act - var result = mockMvc.perform(post("/visas/{id}/edit", visaId) + var result = mockMvc.perform(multipart("/visas/{id}/edit", visaId) + .file(mockFile) .param("id", visaId.toString()) .param("currentUserId", userId.toString()) .param("visaType", "TOURIST") @@ -295,16 +310,17 @@ void processUpdate_InvalidDate_ShouldReturnEditFormWithErrorMessage() throws Exc // Arrange Long visaId = 100L; Long userId = 1L; + MockMultipartFile mockFile = new MockMultipartFile("passportFile", "", "application/octet-stream", new byte[0]); when(userService.findById(userId)).thenReturn(Optional.of(createMockUser(userId, UserAuthorization.USER))); - when(visaService.findVisaDtoById(visaId)).thenReturn(createMockVisa(visaId, userId)); doThrow(new IllegalArgumentException("Travel date cannot be in the past.")) - .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId)); + .when(visaService).updateVisa(eq(visaId), any(UpdateVisaDTO.class), eq(userId), any()); // Act - var result = mockMvc.perform(post("/visas/{id}/edit", visaId) + var result = mockMvc.perform(multipart("/visas/{id}/edit", visaId) + .file(mockFile) .param("id", visaId.toString()) .param("currentUserId", userId.toString()) .param("visaType", "TOURIST") @@ -507,7 +523,9 @@ private VisaDTO createMockVisa(Long visaId, Long applicantId) { null, null, LocalDateTime.now(), LocalDateTime.now(), - "Some status info" + "Some status info", + List.of(), + List.of() ); } From eac7ac1ce287da68f88dab6678c7473e0ebd3d70 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 19:25:00 +0200 Subject: [PATCH 03/10] fix: add missing s3 config values to application-test.properties --- .../visa/VisaServiceIntegrationTest.java | 5 +++++ src/test/resources/application-test.properties | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java index 73a33a1..7889ed2 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java @@ -2,6 +2,7 @@ import org.example.visacasemanagementsystem.audit.AuditEventType; import org.example.visacasemanagementsystem.audit.service.AuditService; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.repository.UserRepository; @@ -14,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -35,6 +37,9 @@ class VisaServiceIntegrationTest { @Autowired private AuditService auditService; + @MockitoBean + private FileService fileService; + @Test void applyForVisa_shouldSaveVisa_WhenDataIsValid() { // Arrange diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 27457a7..bb808b1 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -6,3 +6,10 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=create-drop spring.h2.console.enabled=true spring.flyway.enabled=false + +# S3 / MinIO Dummy Config fφr tester +aws.s3.endpoint=http://localhost:9000 +aws.s3.region=us-east-1 +aws.s3.bucket-name=test-bucket +aws.accessKey=testkey +aws.secretKey=testsecret From f6fcdebe45ad16ab290f92ccd5a0babd1b406bbd Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 20:19:45 +0200 Subject: [PATCH 04/10] fix: syn test-properties with values in S3 FileService --- src/test/resources/application-test.properties | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index bb808b1..fc8b4a7 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -7,9 +7,9 @@ spring.jpa.hibernate.ddl-auto=create-drop spring.h2.console.enabled=true spring.flyway.enabled=false -# S3 / MinIO Dummy Config fφr tester -aws.s3.endpoint=http://localhost:9000 -aws.s3.region=us-east-1 -aws.s3.bucket-name=test-bucket -aws.accessKey=testkey -aws.secretKey=testsecret + +minio.endpoint=http://localhost:9000 +minio.accessKey=testkey +minio.secretKey=testsecret +minio.region=us-east-1 +minio.bucketName=test-bucket From 6a383461859f34eec750921dc1916668aa9bcc67 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 20:34:28 +0200 Subject: [PATCH 05/10] fix: update application-test.properties --- .../resources/application-test.properties | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index fc8b4a7..45e065a 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,15 +1,21 @@ +# Test Profile Configuration +spring.application.name=visa-case-management-system + +# H2 Database for Testing spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= + +# JPA Configuration for H2 spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=create-drop -spring.h2.console.enabled=true -spring.flyway.enabled=false +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false +# H2 Console (optional, for debugging) +spring.h2.console.enabled=false -minio.endpoint=http://localhost:9000 -minio.accessKey=testkey -minio.secretKey=testsecret -minio.region=us-east-1 -minio.bucketName=test-bucket +# Disable unnecessary services +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB From 4ee302ffb3d62daf05265a969b3790c130d9a840 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 20:39:21 +0200 Subject: [PATCH 06/10] fix: update application-test.properties --- src/test/resources/application-test.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 45e065a..ce11443 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -19,3 +19,5 @@ spring.h2.console.enabled=false # Disable unnecessary services spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB + +spring.flyway.enabled=false From 6eb7f910220109236c4234bfd5ea4a44f0383881 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 21:13:18 +0200 Subject: [PATCH 07/10] fix: more updates to fix pipeline --- .../visacasemanagementsystem/file/FileService.java | 7 +++++++ .../TestcontainersConfiguration.java | 9 ++++++--- .../VisaCaseManagementSystemVisaTests.java | 10 ++++++++-- .../comment/CommentServiceIntegrationTest.java | 5 +++++ .../user/service/UserServiceIntegrationTest.java | 5 +++++ src/test/resources/application-test.properties | 6 ++++++ 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java index e639782..6f5b102 100644 --- a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java +++ b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java @@ -2,6 +2,7 @@ import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import org.example.visacasemanagementsystem.config.S3Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -22,6 +23,7 @@ public class FileService { private final S3Client s3Client; private final S3Presigner s3Presigner; + @Value("${minio.bucketName}") private String bucketName; @@ -32,6 +34,11 @@ public FileService(S3Client s3Client, S3Presigner s3Presigner) { @PostConstruct public void initializeBucket() { + + if (bucketName == null || bucketName.equals("test-bucket")) { + log.info("Skipping bucket initialization (test profile or missing config)"); + return; + } try { try { s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build()); diff --git a/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java b/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java index 9f601bb..6a6da30 100644 --- a/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java +++ b/src/test/java/org/example/visacasemanagementsystem/TestcontainersConfiguration.java @@ -3,7 +3,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; -import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @TestConfiguration(proxyBeanMethods = false) @@ -11,8 +11,11 @@ public class TestcontainersConfiguration { @Bean @ServiceConnection - PostgreSQLContainer postgresContainer() { - return new PostgreSQLContainer(DockerImageName.parse("postgres:latest")); + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")) + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); } } diff --git a/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java b/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java index 0e875d6..83dbfa4 100644 --- a/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java +++ b/src/test/java/org/example/visacasemanagementsystem/VisaCaseManagementSystemVisaTests.java @@ -1,13 +1,19 @@ package org.example.visacasemanagementsystem; +import org.example.visacasemanagementsystem.file.FileService; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + -@Import(TestcontainersConfiguration.class) @SpringBootTest +@ActiveProfiles("test") class VisaCaseManagementSystemVisaTests { + @MockitoBean + private FileService fileService; + @Test void contextLoads() { } diff --git a/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java index 8c2fbce..4567a27 100644 --- a/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/comment/CommentServiceIntegrationTest.java @@ -4,6 +4,7 @@ import org.example.visacasemanagementsystem.comment.dto.CreateCommentDTO; import org.example.visacasemanagementsystem.comment.service.CommentService; import org.example.visacasemanagementsystem.exception.ResourceNotFoundException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.entity.User; import org.example.visacasemanagementsystem.user.repository.UserRepository; @@ -21,6 +22,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -41,6 +43,9 @@ class CommentServiceIntegrationTest{ @Autowired private UserRepository userRepository; + @MockitoBean + private FileService fileService; + private User testUser; private User nonExistentUser; private Visa testVisa; diff --git a/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java index d7d5546..b3879c3 100644 --- a/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/user/service/UserServiceIntegrationTest.java @@ -1,6 +1,7 @@ package org.example.visacasemanagementsystem.user.service; import org.example.visacasemanagementsystem.exception.UnauthorizedException; +import org.example.visacasemanagementsystem.file.FileService; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.dto.CreateUserDTO; import org.example.visacasemanagementsystem.user.dto.UpdateUserDTO; @@ -14,6 +15,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; @@ -31,6 +33,9 @@ class UserServiceIntegrationTest { @Autowired private UserRepository userRepository; + @MockitoBean + private FileService fileService; + @Test @DisplayName("Checking if createUser persists and returns the new user when all input is valid") void createUser_shouldSaveUser_WhenDataIsValid() { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index ce11443..5df0c66 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -21,3 +21,9 @@ spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB spring.flyway.enabled=false + +minio.endpoint=http://localhost:9000 +minio.accessKey=test-access-key +minio.secretKey=test-secret-key +minio.region=us-east-1 +minio.bucketName=test-bucket From d5d367d70438880dbc3f8df2c7d141c653d6d3c2 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 21:16:38 +0200 Subject: [PATCH 08/10] fix: spotless fix --- .../org/example/visacasemanagementsystem/file/FileService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java index 6f5b102..8e5aad8 100644 --- a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java +++ b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java @@ -2,7 +2,6 @@ import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; -import org.example.visacasemanagementsystem.config.S3Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; From 6bf3edf7231a0c91e8d9d6ecb248c9c8c5658c39 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Wed, 22 Apr 2026 23:35:00 +0200 Subject: [PATCH 09/10] fix: suggested fixes from CodeRabbit --- .../file/FileService.java | 46 ++++++++++++++++--- .../visa/service/VisaService.java | 8 +++- src/main/resources/application.properties | 14 +++--- .../resources/templates/visa/apply-form.html | 4 +- .../resources/templates/visa/details.html | 22 +++++---- .../resources/templates/visa/edit-form.html | 6 +-- .../visa/VisaViewControllerTest.java | 3 +- 7 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java index 8e5aad8..fc250d3 100644 --- a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java +++ b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java @@ -13,6 +13,9 @@ import java.io.IOException; import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Set; import java.util.UUID; @Service @@ -22,10 +25,20 @@ public class FileService { private final S3Client s3Client; private final S3Presigner s3Presigner; + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "application/pdf", + "image/jpeg", + "image/png"); + + private static final long MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + @Value("${minio.bucketName}") private String bucketName; + @Value("${minio.corsAllowedOrigins:http://localhost:8080}") + private String corsAllowedOrigins; + public FileService(S3Client s3Client, S3Presigner s3Presigner) { this.s3Client = s3Client; this.s3Presigner = s3Presigner; @@ -52,11 +65,16 @@ public void initializeBucket() { log.error("Failed to verify/create bucket: {}", e.getMessage()); } - try { + + List origins = Arrays.stream(corsAllowedOrigins.split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .toList(); + CORSRule corsRule = CORSRule.builder() - .allowedOrigins("*") - .allowedMethods("GET", "PUT", "POST", "DELETE", "HEAD") + .allowedOrigins(origins) + .allowedMethods("GET","HEAD") .allowedHeaders("*") .build(); @@ -71,13 +89,29 @@ public void initializeBucket() { } public String uploadFile(MultipartFile file) throws IOException { - // Generate a unique file name - String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + // Validate file size + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new IOException("File exceeds maximum allowed size of 10 MB"); + } + + // Validate content type + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw new IOException("Unsupported document type: " + contentType + + ". Allowed: PDF, JPEG, PNG"); + } + + // Sanitize filename + String original = file.getOriginalFilename(); + String safeName = (original == null ? "file" + : original.replaceAll("[^A-Za-z0-9._-]", "_")); + + String fileName = UUID.randomUUID() + "_" + safeName; PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(bucketName) .key(fileName) - .contentType(file.getContentType()) + .contentType(contentType) .build(); s3Client.putObject(putObjectRequest, diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java index 8dac658..5824764 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java @@ -211,7 +211,7 @@ public VisaDTO applyForVisa(CreateVisaDTO dto, Long userId, String s3Key) { userId, savedVisa.getId(), AuditEventType.CREATED, - "Visa application submitted." + (s3Key != null ? "Document attached." : "") + "Visa application submitted." + (s3Key != null ? " Document attached." : "") ); return visaMapper.toDTO(savedVisa); } @@ -240,7 +240,7 @@ public VisaDTO updateVisa(Long visaId, UpdateVisaDTO dto, Long userId, String ne visa.setPassportNumber(dto.passportNumber()); visa.setTravelDate(dto.travelDate()); - if (newS3Key != null) { + if (newS3Key != null && !newS3Key.isBlank()) { visa.getS3Keys().add(newS3Key); } @@ -271,6 +271,10 @@ public void removeVisaDocument(Long visaId, String s3Key, Long userId) { throw new UnauthorizedException("You cannot delete this document."); } + if (!visa.getS3Keys().contains(s3Key)) { + throw new IllegalArgumentException("Document does not belong to this visa application."); + } + if (visa.getS3Keys().remove(s3Key)) { if (visa.getS3Keys().isEmpty()) { visa.setVisaStatus(VisaStatus.INCOMPLETE); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 27c422b..6391da5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,7 +2,7 @@ spring.application.name=visa-case-management-system spring.docker.compose.lifecycle-management=start_only spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/visadatabase} -spring.datasource.username=${DB_USER:user} +spring.datasource.username=${DB_USER:myuser} spring.datasource.password=${DB_PASSWORD:secret} # application.properties (default/test/prod) @@ -12,11 +12,13 @@ spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=false spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect -minio.endpoint=http://localhost:9000 -minio.accessKey=admin -minio.secretKey=password -minio.bucketName=visa-documents -minio.region=eu-north-1 +minio.endpoint=${MINIO_ENDPOINT:http://localhost:9000} +minio.accessKey=${MINIO_ACCESS_KEY:admin} +minio.secretKey=${MINIO_SECRET_KEY:password} +minio.bucketName=${MINIO_BUCKET:visa-documents} +minio.region=${MINIO_REGION:eu-north-1} +minio.corsAllowedOrigins=${MINIO_CORS:http://localhost:8080} + spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB diff --git a/src/main/resources/templates/visa/apply-form.html b/src/main/resources/templates/visa/apply-form.html index 64918dd..09fdd2e 100644 --- a/src/main/resources/templates/visa/apply-form.html +++ b/src/main/resources/templates/visa/apply-form.html @@ -216,9 +216,9 @@

New Application

document.getElementById('passportFile').addEventListener('change', function() { const fileNameDisplay = document.getElementById('file-name'); if (this.files && this.files.length > 0) { - fileNameDisplay.innerHTML = 'πŸ“Ž Selected: ' + this.files[0].name; + fileNameDisplay.textContent = 'πŸ“Ž Selected: ' + this.files[0].name; } else { - fileNameDisplay.innerHTML = ''; + fileNameDisplay.textContent = ''; } }); diff --git a/src/main/resources/templates/visa/details.html b/src/main/resources/templates/visa/details.html index 6218a9f..99261f5 100644 --- a/src/main/resources/templates/visa/details.html +++ b/src/main/resources/templates/visa/details.html @@ -184,11 +184,11 @@

Case #101

Attached Documents
- @@ -366,13 +366,17 @@

Comments

card.className = 'comment-card'; card.style.animation = 'fadeIn 0.5s'; - card.innerHTML = ` -
- ${comment.authorName} β€’ Just now -
-
${comment.text}
- `; - container.prepend(card); + const meta = document.createElement('div'); + meta.className = 'comment-meta'; + const nameEl = document.createElement('strong'); + nameEl.textContent = comment.authorName; + meta.append(nameEl, ' β€’ Just now'); + const textEl = document.createElement('div'); + textEl.className = 'comment-text'; + textEl.textContent = comment.text; + + +card.append(meta, textEl); + +container.prepend(card); }) .catch(error => { alert('System Message: ' + error.message); diff --git a/src/main/resources/templates/visa/edit-form.html b/src/main/resources/templates/visa/edit-form.html index caa89f3..f4c5b21 100644 --- a/src/main/resources/templates/visa/edit-form.html +++ b/src/main/resources/templates/visa/edit-form.html @@ -193,7 +193,7 @@

Edit Application

- + View Attachment @@ -239,9 +239,9 @@

Edit Application

document.getElementById('passportFile').addEventListener('change', function() { const fileNameDisplay = document.getElementById('file-name'); if (this.files && this.files.length > 0) { - fileNameDisplay.innerHTML = 'πŸ“Ž Selected: ' + this.files[0].name; + fileNameDisplay.textContent = 'πŸ“Ž Selected: ' + this.files[0].name; } else { - fileNameDisplay.innerHTML = ''; + fileNameDisplay.textContent = ''; } }); diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java index bd6650d..7c83d1f 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaViewControllerTest.java @@ -333,7 +333,8 @@ void processUpdate_InvalidDate_ShouldReturnEditFormWithErrorMessage() throws Exc // Assert result.andExpect(status().isOk()) .andExpect(view().name("visa/edit-form")) - .andExpect(model().hasErrors()); + .andExpect(model().hasErrors()) + .andExpect(model().attributeExists("statusInformation")); } From 321214b8b64973e8d4deca4b1b61274852287fd9 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Thu, 23 Apr 2026 10:55:07 +0200 Subject: [PATCH 10/10] fix: more suggested fixes from CodeRabbit --- .../visacasemanagementsystem/file/FileService.java | 8 +++++++- src/main/resources/templates/visa/details.html | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java index fc250d3..1535214 100644 --- a/src/main/java/org/example/visacasemanagementsystem/file/FileService.java +++ b/src/main/java/org/example/visacasemanagementsystem/file/FileService.java @@ -62,7 +62,8 @@ public void initializeBucket() { } else { throw e; } } } catch (Exception e) { - log.error("Failed to verify/create bucket: {}", e.getMessage()); + log.error("Failed to verify/create bucket '{}': {}", bucketName, e.getMessage(), e); + return; } try { @@ -72,6 +73,11 @@ public void initializeBucket() { .filter(s -> !s.isBlank()) .toList(); + if (origins.isEmpty()) { + log.warn("minio.corsAllowedOrigins is empty; skipping CORS configuration for bucket '{}'", bucketName); + return; + } + CORSRule corsRule = CORSRule.builder() .allowedOrigins(origins) .allowedMethods("GET","HEAD") diff --git a/src/main/resources/templates/visa/details.html b/src/main/resources/templates/visa/details.html index 99261f5..d99ff55 100644 --- a/src/main/resources/templates/visa/details.html +++ b/src/main/resources/templates/visa/details.html @@ -375,8 +375,8 @@

Comments

textEl.className = 'comment-text'; textEl.textContent = comment.text; - +card.append(meta, textEl); - +container.prepend(card); + card.append(meta, textEl); + container.prepend(card); }) .catch(error => { alert('System Message: ' + error.message);