diff --git a/build.gradle b/build.gradle index da5a982..465b2f0 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' compileOnly 'org.projectlombok:lombok' + implementation 'org.apache.poi:poi:5.4.0' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.flywaydb:flyway-database-postgresql' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/fowoco/server/document/api/DocumentController.java b/src/main/java/com/fowoco/server/document/api/DocumentController.java index 56bffc4..3b313f0 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentController.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentController.java @@ -2,6 +2,7 @@ import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.document.application.DocumentDetailResult; import com.fowoco.server.document.application.DocumentPageResult; import com.fowoco.server.document.application.DocumentService; import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; @@ -23,6 +24,7 @@ import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -96,4 +98,30 @@ public DocumentPageResponse list( .toList(); return new DocumentPageResponse(items, result.page(), result.size(), result.totalElements()); } + + @Operation( + operationId = "getDocument", + summary = "서류 단건 상세 조회", + description = "연결된 파일 정보와 expected_version 기준값을 함께 반환합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentDetailResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @GetMapping(path = "/{documentId}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DocumentDetailResponse findById(@Parameter(description = "서류 ID") @PathVariable UUID documentId) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + DocumentDetailResult result = documentService.findById(documentId, actor); + return DocumentDetailResponse.from(result); + } } diff --git a/src/main/java/com/fowoco/server/document/api/DocumentDetailResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentDetailResponse.java new file mode 100644 index 0000000..9250cb6 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentDetailResponse.java @@ -0,0 +1,168 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.document.application.DocumentDetailResult; +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "DocumentDetailResponse", description = "서류 단건 상세 응답 (연결된 파일 정보 포함)") +public final class DocumentDetailResponse { + + @JsonProperty("worker_document_id") + @Schema(name = "worker_document_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID workerDocumentId; + + @JsonProperty("worker_id") + @Schema(name = "worker_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID workerId; + + @JsonProperty("task_id") + @Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID") + private final UUID taskId; + + @JsonProperty("display_name") + @Schema(name = "display_name", description = "근로자 화면 표시 이름") + private final String displayName; + + @JsonProperty("document_type") + @Schema(name = "document_type", requiredMode = Schema.RequiredMode.REQUIRED) + private final DocumentType documentType; + + @JsonProperty("submission_status") + @Schema(name = "submission_status", requiredMode = Schema.RequiredMode.REQUIRED) + private final SubmissionStatus submissionStatus; + + @JsonProperty("expiry_date") + @Schema(name = "expiry_date", format = "date") + private final LocalDate expiryDate; + + @JsonProperty("version") + @Schema(name = "version", description = "PATCH 요청의 expected_version 기준값", requiredMode = Schema.RequiredMode.REQUIRED) + private final long version; + + @JsonProperty("file_id") + @Schema(name = "file_id", format = "uuid") + private final UUID fileId; + + @JsonProperty("file_name") + @Schema(name = "file_name", description = "연결된 파일의 표시 파일명 (파일 없으면 null)") + private final String fileName; + + @JsonProperty("file_mime_type") + @Schema(name = "file_mime_type", description = "연결된 파일의 MIME 타입 (파일 없으면 null)") + private final String fileMimeType; + + @JsonProperty("file_size") + @Schema(name = "file_size", description = "연결된 파일의 크기(byte) (파일 없으면 null)") + private final Long fileSize; + + @JsonProperty("file_scan_status") + @Schema(name = "file_scan_status", description = "연결된 파일의 검사 상태 (파일 없으면 null)") + private final ScanStatus fileScanStatus; + + private DocumentDetailResponse( + UUID workerDocumentId, + UUID workerId, + UUID taskId, + String displayName, + DocumentType documentType, + SubmissionStatus submissionStatus, + LocalDate expiryDate, + long version, + UUID fileId, + String fileName, + String fileMimeType, + Long fileSize, + ScanStatus fileScanStatus + ) { + this.workerDocumentId = workerDocumentId; + this.workerId = workerId; + this.taskId = taskId; + this.displayName = displayName; + this.documentType = documentType; + this.submissionStatus = submissionStatus; + this.expiryDate = expiryDate; + this.version = version; + this.fileId = fileId; + this.fileName = fileName; + this.fileMimeType = fileMimeType; + this.fileSize = fileSize; + this.fileScanStatus = fileScanStatus; + } + + public static DocumentDetailResponse from(DocumentDetailResult result) { + var document = result.document(); + var storedFile = result.storedFile(); + return new DocumentDetailResponse( + document.workerDocumentId(), + document.workerId(), + document.taskId(), + result.workerDisplayName(), + document.documentType(), + document.submissionStatus(), + document.expiryDate(), + document.version(), + document.fileId(), + storedFile == null ? null : storedFile.name(), + storedFile == null ? null : storedFile.mimeType(), + storedFile == null ? null : storedFile.size(), + storedFile == null ? null : storedFile.scanStatus() + ); + } + + public UUID getWorkerDocumentId() { + return workerDocumentId; + } + + public UUID getWorkerId() { + return workerId; + } + + public UUID getTaskId() { + return taskId; + } + + public String getWorkerDisplayName() { + return displayName; + } + + public DocumentType getDocumentType() { + return documentType; + } + + public SubmissionStatus getSubmissionStatus() { + return submissionStatus; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public long getVersion() { + return version; + } + + public UUID getFileId() { + return fileId; + } + + public String getFileName() { + return fileName; + } + + public String getFileMimeType() { + return fileMimeType; + } + + public Long getFileSize() { + return fileSize; + } + + public ScanStatus getFileScanStatus() { + return fileScanStatus; + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java index c8cb486..3586641 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java @@ -19,6 +19,10 @@ public final class DocumentItemResponse { @Schema(name = "worker_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) private final UUID workerId; + @JsonProperty("task_id") + @Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID") + private final UUID taskId; + @JsonProperty("display_name") @Schema( name = "display_name", @@ -43,33 +47,43 @@ public final class DocumentItemResponse { @Schema(name = "file_id", format = "uuid") private final UUID fileId; + @JsonProperty("version") + @Schema(name = "version", description = "PATCH 요청의 expected_version 기준값", requiredMode = Schema.RequiredMode.REQUIRED) + private final long version; + private DocumentItemResponse( UUID workerDocumentId, UUID workerId, + UUID taskId, String displayName, DocumentType documentType, SubmissionStatus submissionStatus, LocalDate expiryDate, - UUID fileId + UUID fileId, + long version ) { this.workerDocumentId = workerDocumentId; this.workerId = workerId; + this.taskId = taskId; this.displayName = displayName; this.documentType = documentType; this.submissionStatus = submissionStatus; this.expiryDate = expiryDate; this.fileId = fileId; + this.version = version; } public static DocumentItemResponse from(WorkerDocument document, String displayName) { return new DocumentItemResponse( document.workerDocumentId(), document.workerId(), + document.taskId(), displayName, document.documentType(), document.submissionStatus(), document.expiryDate(), - document.fileId() + document.fileId(), + document.version() ); } @@ -81,6 +95,10 @@ public UUID getWorkerId() { return workerId; } + public UUID getTaskId() { + return taskId; + } + public String getWorkerDisplayName() { return displayName; } @@ -100,4 +118,8 @@ public LocalDate getExpiryDate() { public UUID getFileId() { return fileId; } + + public long getVersion() { + return version; + } } diff --git a/src/main/java/com/fowoco/server/document/application/DocumentDetailResult.java b/src/main/java/com/fowoco/server/document/application/DocumentDetailResult.java new file mode 100644 index 0000000..c6db6c1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentDetailResult.java @@ -0,0 +1,7 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.worker.domain.WorkerDocument; + +public record DocumentDetailResult(WorkerDocument document, String workerDisplayName, StoredFile storedFile) { +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentService.java b/src/main/java/com/fowoco/server/document/application/DocumentService.java index 93c13b6..427f1f3 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentService.java @@ -1,7 +1,11 @@ package com.fowoco.server.document.application; import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.document.application.error.DocumentErrorCode; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; @@ -21,18 +25,38 @@ public class DocumentService { private final WorkerDocumentRepository workerDocumentRepository; private final WorkerRepository workerRepository; + private final StoredFileRepository storedFileRepository; private final TenantDatabaseContext tenantDatabaseContext; public DocumentService( WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, + StoredFileRepository storedFileRepository, TenantDatabaseContext tenantDatabaseContext ) { this.workerDocumentRepository = workerDocumentRepository; this.workerRepository = workerRepository; + this.storedFileRepository = storedFileRepository; this.tenantDatabaseContext = tenantDatabaseContext; } + @Transactional(readOnly = true) + public DocumentDetailResult findById(UUID workerDocumentId, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); + WorkerDocument document = workerDocumentRepository + .findByIdAndCompanyId(workerDocumentId, companyId) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_NOT_FOUND)); + String displayName = workerRepository + .findByWorkerIdAndCompanyId(document.workerId(), companyId) + .map(Worker::displayName) + .orElse(null); + StoredFile storedFile = document.fileId() == null + ? null + : storedFileRepository.findByIdAndCompanyId(document.fileId(), companyId).orElse(null); + return new DocumentDetailResult(document, displayName, storedFile); + } + @Transactional(readOnly = true) public DocumentPageResult findPage(ActorContext actor, WorkerDocumentSearchQuery query) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); diff --git a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java index 312f1c8..a361e8f 100644 --- a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java +++ b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java @@ -7,6 +7,10 @@ public enum DocumentErrorCode implements ApiErrorCode { DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT( HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요." + ), + DOCUMENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "문서를 찾을 수 없습니다." ); private final HttpStatus status; diff --git a/src/main/java/com/fowoco/server/file/application/FileService.java b/src/main/java/com/fowoco/server/file/application/FileService.java index 1a9b0b1..4a70971 100644 --- a/src/main/java/com/fowoco/server/file/application/FileService.java +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -14,6 +14,7 @@ import com.fowoco.server.file.application.error.FileErrorCode; import com.fowoco.server.file.application.port.FileStorage; import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.application.validation.HwpSignatureValidator; import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; @@ -41,10 +42,13 @@ public class FileService { "image/jpeg", "image/png", "image/webp", - "application/pdf" + "application/pdf", + "application/hwp+zip" ); + private static final String HWP_EXTENSION = ".hwp"; private final StoredFileRepository storedFileRepository; + private final HwpSignatureValidator hwpSignatureValidator; private final FileStorage fileStorage; private final TaskRepository taskRepository; private final WorkerRepository workerRepository; @@ -55,6 +59,7 @@ public class FileService { public FileService( StoredFileRepository storedFileRepository, + HwpSignatureValidator hwpSignatureValidator, FileStorage fileStorage, TaskRepository taskRepository, WorkerRepository workerRepository, @@ -64,6 +69,7 @@ public FileService( Clock clock ) { this.storedFileRepository = storedFileRepository; + this.hwpSignatureValidator = hwpSignatureValidator; this.fileStorage = fileStorage; this.taskRepository = taskRepository; this.workerRepository = workerRepository; @@ -80,7 +86,12 @@ public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestM if (command.size() > MAX_FILE_SIZE_BYTES) { throw new ApiException(FileErrorCode.FILE_TOO_LARGE); } - if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { + byte[] contentBytes = readAllBytes(command.content()); + if (isHwpExtension(command.name())) { + if (!hwpSignatureValidator.isValidHwp(contentBytes)) { + throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); + } + } else if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); } if (command.taskId() != null) { @@ -109,7 +120,7 @@ public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestM now ); - fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); + fileStorage.store(storageKey, new java.io.ByteArrayInputStream(contentBytes), command.size(), command.mimeType()); storedFileRepository.insert(storedFile); appendAudit( @@ -164,4 +175,16 @@ private int rolePriority(UserRole role) { case VIEWER -> 2; }; } + + private boolean isHwpExtension(String name) { + return name != null && name.toLowerCase(java.util.Locale.ROOT).endsWith(HWP_EXTENSION); + } + + private byte[] readAllBytes(java.io.InputStream content) { + try { + return content.readAllBytes(); + } catch (java.io.IOException exception) { + throw new IllegalStateException("파일 내용을 읽을 수 없습니다.", exception); + } + } } diff --git a/src/main/java/com/fowoco/server/file/application/validation/HwpSignatureValidator.java b/src/main/java/com/fowoco/server/file/application/validation/HwpSignatureValidator.java new file mode 100644 index 0000000..79b7f02 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/validation/HwpSignatureValidator.java @@ -0,0 +1,36 @@ +package com.fowoco.server.file.application.validation; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.springframework.stereotype.Component; + +/** + * HWP 파일은 OLE Compound File 구조이며, 정식 MIME 타입이 없다. + * 파일 내부의 "FileHeader" 스트림 앞부분에 있는 "HWP Document File" 문자열로 + * 실제 HWP 문서인지 확인한다. + */ +@Component +public class HwpSignatureValidator { + + private static final String FILE_HEADER_STREAM_NAME = "FileHeader"; + private static final String HWP_SIGNATURE = "HWP Document File"; + + public boolean isValidHwp(byte[] content) { + try (POIFSFileSystem fileSystem = new POIFSFileSystem(new ByteArrayInputStream(content))) { + if (!fileSystem.getRoot().hasEntry(FILE_HEADER_STREAM_NAME)) { + return false; + } + byte[] header = fileSystem.createDocumentInputStream(FILE_HEADER_STREAM_NAME) + .readAllBytes(); + if (header.length < HWP_SIGNATURE.length()) { + return false; + } + String signature = new String(header, 0, HWP_SIGNATURE.length(), StandardCharsets.US_ASCII); + return HWP_SIGNATURE.equals(signature); + } catch (IOException | RuntimeException exception) { + return false; + } + } +} diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java index 7991d9f..62d3c28 100644 --- a/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java @@ -16,6 +16,8 @@ Optional findByIdAndWorkerIdAndCompanyId( UUID companyId ); + Optional findByIdAndCompanyId(UUID workerDocumentId, UUID companyId); + WorkerDocument update(WorkerDocument document); List findPage(UUID companyId, WorkerDocumentSearchQuery query); diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java index 56be632..5e24c35 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java @@ -55,6 +55,26 @@ public Optional findByIdAndWorkerIdAndCompanyId( .map(WorkerDocumentJpaEntity::toDomain); } + @Override + public Optional findByIdAndCompanyId(UUID workerDocumentId, UUID companyId) { + Objects.requireNonNull(workerDocumentId, "workerDocumentId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select document + from WorkerDocumentJpaEntity document + where document.workerDocumentId = :workerDocumentId + and document.companyId = :companyId + """, + WorkerDocumentJpaEntity.class + ) + .setParameter("workerDocumentId", workerDocumentId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(WorkerDocumentJpaEntity::toDomain); + } + @Override public WorkerDocument update(WorkerDocument document) { Objects.requireNonNull(document, "document must not be null"); diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java index 5de3af7..944bee0 100644 --- a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -88,6 +88,11 @@ void resetFileState() { jdbcTemplate.update("DELETE FROM stored_file"); } + @org.junit.jupiter.api.AfterAll + void cleanupFileState() { + jdbcTemplate.update("DELETE FROM stored_file"); + } + @Test void uploadSucceedsAndAppendsAuditEvent() throws Exception { String token = accessToken(login(HR_A_EMAIL)); @@ -121,6 +126,57 @@ void uploadRejectsUnsupportedMimeType() throws Exception { assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); } + @Test + void uploadAcceptsHwpxMimeType() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile( + token, "contract.hwpx", "application/hwp+zip", "hwpx content".getBytes(StandardCharsets.UTF_8), "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("contract.hwpx"); + } + + @Test + void uploadAcceptsValidHwpFileBySignature() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + byte[] hwpContent = buildValidHwpOleFile(); + + HttpResponse response = uploadFile( + token, "contract.hwp", "application/octet-stream", hwpContent, "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("contract.hwp"); + } + + @Test + void uploadRejectsHwpExtensionWithInvalidSignature() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile( + token, "fake.hwp", "application/octet-stream", + "this is not a real hwp file".getBytes(StandardCharsets.UTF_8), "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); + } + + private byte[] buildValidHwpOleFile() throws Exception { + try (org.apache.poi.poifs.filesystem.POIFSFileSystem fileSystem = + new org.apache.poi.poifs.filesystem.POIFSFileSystem()) { + byte[] header = new byte[256]; + byte[] signatureBytes = "HWP Document File".getBytes(StandardCharsets.US_ASCII); + System.arraycopy(signatureBytes, 0, header, 0, signatureBytes.length); + fileSystem.createDocument(new java.io.ByteArrayInputStream(header), "FileHeader"); + + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + fileSystem.writeFilesystem(out); + return out.toByteArray(); + } + } + @Test void uploadRejectsNonExistentTaskId() throws Exception { String token = accessToken(login(HR_A_EMAIL)); diff --git a/src/test/java/com/fowoco/server/file/application/validation/HwpSignatureValidatorTest.java b/src/test/java/com/fowoco/server/file/application/validation/HwpSignatureValidatorTest.java new file mode 100644 index 0000000..459fe94 --- /dev/null +++ b/src/test/java/com/fowoco/server/file/application/validation/HwpSignatureValidatorTest.java @@ -0,0 +1,52 @@ +package com.fowoco.server.file.application.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.junit.jupiter.api.Test; + +class HwpSignatureValidatorTest { + + private final HwpSignatureValidator validator = new HwpSignatureValidator(); + + @Test + void acceptsValidHwpSignature() throws Exception { + byte[] content = buildOleFile("HWP Document File"); + + assertThat(validator.isValidHwp(content)).isTrue(); + } + + @Test + void rejectsOleFileWithoutHwpSignature() throws Exception { + byte[] content = buildOleFile("Not A HWP Document"); + + assertThat(validator.isValidHwp(content)).isFalse(); + } + + @Test + void rejectsNonOleFile() { + byte[] content = "plain text content, not an OLE file".getBytes(StandardCharsets.UTF_8); + + assertThat(validator.isValidHwp(content)).isFalse(); + } + + @Test + void rejectsEmptyContent() { + assertThat(validator.isValidHwp(new byte[0])).isFalse(); + } + + private byte[] buildOleFile(String signatureText) throws Exception { + try (POIFSFileSystem fileSystem = new POIFSFileSystem()) { + byte[] header = new byte[256]; + byte[] signatureBytes = signatureText.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(signatureBytes, 0, header, 0, signatureBytes.length); + fileSystem.createDocument(new java.io.ByteArrayInputStream(header), "FileHeader"); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + fileSystem.writeFilesystem(out); + return out.toByteArray(); + } + } +} diff --git a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java index e52fe64..53e3225 100644 --- a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java @@ -174,6 +174,31 @@ void listDocumentsFiltersByTaskId() throws Exception { assertThat(ids).contains(documentIdWithTask); assertThat(ids).doesNotContain(documentIdWithoutTask); } + + @Test + void getDocumentReturnsDetailWithVersionAndFileInfo() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String documentId = registerDocument(accessToken, workerIdInCompanyA); + + HttpResponse response = getJson("/api/v1/documents/" + documentId, accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.worker_document_id")).isEqualTo(documentId); + assertThat(JsonPath.read(response.body(), "$.worker_id")).isEqualTo(workerIdInCompanyA); + assertThat(JsonPath.read(response.body(), "$.version").longValue()).isZero(); + assertThat(JsonPath.read(response.body(), "$.file_id")).isNull(); + } + + @Test + void getDocumentFromAnotherCompanyReturnsNotFound() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + String documentId = registerDocument(companyAToken, workerIdInCompanyA); + + HttpResponse response = getJson("/api/v1/documents/" + documentId, companyBToken); + + assertThat(response.statusCode()).isEqualTo(404); + } @Test void registerRejectsTaskOwnedByAnotherWorkerInSameCompany() throws Exception {