diff --git a/systems/configuration/configuration-domain/BUILD.bazel b/systems/configuration/configuration-domain/BUILD.bazel index ebccba16..655086cf 100644 --- a/systems/configuration/configuration-domain/BUILD.bazel +++ b/systems/configuration/configuration-domain/BUILD.bazel @@ -17,5 +17,14 @@ kt_jvm_test( javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", test_class = "hs.kr.entrydsm.configuration.domain.ConfigurationDomainModuleTest", - deps = MODULE_DEPS + TEST_DEPS, + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "document_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.configuration.domain.document.DocumentDomainTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], ) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/DownloadUrl.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/DownloadUrl.kt new file mode 100644 index 00000000..011d7462 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/DownloadUrl.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.configuration.domain.document + +data class DownloadUrl( + val fileName: String, + val downloadUrl: String, + val expiresIn: Long, +) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt new file mode 100644 index 00000000..d76abe92 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt @@ -0,0 +1,25 @@ +package hs.kr.entrydsm.configuration.domain.document + +private const val MAX_DOCUMENT_SIZE_BYTES = 10L * 1024 * 1024 +private const val MAX_PHOTO_SIZE_BYTES = 5L * 1024 * 1024 +private const val MAX_ATTACHMENT_SIZE_BYTES = 20L * 1024 * 1024 + +enum class FileCategory( + val prefix: String, + val allowedExtensions: Set, + val maxSizeBytes: Long, +) { + APPLICATION("application", FileExtension.documentFormats, MAX_DOCUMENT_SIZE_BYTES), + ADMISSION_TICKET("admission-ticket", FileExtension.documentFormats, MAX_DOCUMENT_SIZE_BYTES), + APPLICANT_LIST("applicant-list", setOf(FileExtension.XLSX), MAX_DOCUMENT_SIZE_BYTES), + PHOTO("photo", FileExtension.imageFormats, MAX_PHOTO_SIZE_BYTES), + ATTACHMENT("attachment", FileExtension.attachmentFormats, MAX_ATTACHMENT_SIZE_BYTES), + GUIDELINE("guideline", FileExtension.attachmentFormats, MAX_ATTACHMENT_SIZE_BYTES), + ; + + fun supports(extension: FileExtension): Boolean = extension in allowedExtensions + + fun exceedsMaxSize(sizeBytes: Long): Boolean = sizeBytes > maxSizeBytes + + fun objectKeyOf(fileName: String): String = "$prefix/${FileNaming.requireSafeFileName(fileName)}" +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileDocument.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileDocument.kt new file mode 100644 index 00000000..8a93cc01 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileDocument.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.configuration.domain.document + +import java.time.Instant + +data class FileDocument( + val id: Long? = null, + val originalName: String, + val objectKey: String, + val bucket: String, + val contentType: String, + val sizeBytes: Long, + val checksum: String, + val createdAt: Instant? = null, +) { + val fileName: String + get() = objectKey.substringAfterLast('/') +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt new file mode 100644 index 00000000..2482f9b1 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt @@ -0,0 +1,35 @@ +package hs.kr.entrydsm.configuration.domain.document + +enum class FileExtension( + val value: String, + val contentType: String, + private val aliases: Set = emptySet(), +) { + PDF("pdf", "application/pdf"), + HWP("hwp", "application/x-hwp"), + XLSX("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + DOCX("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + JPG("jpg", "image/jpeg", setOf("jpeg")), + PNG("png", "image/png"), + WEBP("webp", "image/webp"), + ; + + companion object { + val documentFormats = setOf(PDF, HWP) + + val imageFormats = setOf(JPG, PNG, WEBP) + + val attachmentFormats = documentFormats + imageFormats + setOf(XLSX, DOCX) + + fun fromFileName(fileName: String): FileExtension? { + val extension = fileName.substringAfterLast('.', "") + if (extension.isEmpty()) return null + return fromExtension(extension) + } + + fun fromExtension(extension: String): FileExtension? { + val normalized = extension.removePrefix(".").lowercase() + return entries.firstOrNull { it.value == normalized || normalized in it.aliases } + } + } +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt new file mode 100644 index 00000000..6657c6b0 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt @@ -0,0 +1,51 @@ +package hs.kr.entrydsm.configuration.domain.document + +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.UUID + +private val safeIdentifier = Regex("[A-Za-z0-9_-]+") +private val unsafeCharacters = Regex("[^A-Za-z0-9._-]") +private val applicantListDate = DateTimeFormatter.ofPattern("yyyyMMdd") + +object FileNaming { + + fun applicationFileName(receiptCode: String, extension: FileExtension): String = + "application_${requireIdentifier(receiptCode)}.${extension.value}" + + fun admissionTicketFileName(receiptCode: String, extension: FileExtension): String = + "admission_ticket_${requireIdentifier(receiptCode)}.${extension.value}" + + fun applicantListFileName(date: LocalDate): String = + "applicants_${applicantListDate.format(date)}.${FileExtension.XLSX.value}" + + fun photoFileName(extension: FileExtension): String = + "photo_${randomToken()}.${extension.value}" + + fun attachmentFileName(originalName: String): String = + "${randomToken()}_${sanitizeOriginalName(originalName)}" + + fun requireIdentifier(value: String): String { + if (!safeIdentifier.matches(value)) throw InvalidFileNameException(value) + return value + } + + fun requireSafeFileName(fileName: String): String { + if (sanitizeOriginalName(fileName) != fileName) throw InvalidFileNameException(fileName) + return fileName + } + + fun sanitizeOriginalName(originalName: String): String { + val baseName = originalName + .substringAfterLast('/') + .substringAfterLast('\\') + .trim() + val sanitized = unsafeCharacters.replace(baseName, "_").trimStart('.') + if (sanitized.isEmpty() || sanitized == "_") throw InvalidFileNameException(originalName) + return sanitized + } + + private fun randomToken(): String = + UUID.randomUUID().toString().replace("-", "") +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt new file mode 100644 index 00000000..dc443a76 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.configuration.domain.document + +data class StoredObject( + val bucket: String, + val objectKey: String, + val checksum: String, +) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/IssueDownloadUrlCommand.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/IssueDownloadUrlCommand.kt new file mode 100644 index 00000000..e79e30dc --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/IssueDownloadUrlCommand.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.configuration.domain.document.command + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +data class IssueDownloadUrlCommand( + val category: FileCategory, + val fileName: String, +) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt new file mode 100644 index 00000000..604d1dbf --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.configuration.domain.document.command + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +data class UploadFileCommand( + val category: FileCategory, + val originalName: String, + val sizeBytes: Long, + val fileName: String? = null, +) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileDocumentNotFoundException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileDocumentNotFoundException.kt new file mode 100644 index 00000000..32340a4d --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileDocumentNotFoundException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class FileDocumentNotFoundException(objectKey: String) : + RuntimeException("File not found: $objectKey") diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileTooLargeException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileTooLargeException.kt new file mode 100644 index 00000000..135db72b --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileTooLargeException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class FileTooLargeException(sizeBytes: Long, maxSizeBytes: Long) : + RuntimeException("File size $sizeBytes bytes exceeds limit of $maxSizeBytes bytes") diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileFormatException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileFormatException.kt new file mode 100644 index 00000000..4a2b262f --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileFormatException.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +class InvalidFileFormatException(fileName: String, category: FileCategory) : + RuntimeException( + "Unsupported file format for ${category.name}: $fileName " + + "(allowed: ${category.allowedExtensions.joinToString { it.value }})" + ) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileNameException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileNameException.kt new file mode 100644 index 00000000..e1a58354 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileNameException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class InvalidFileNameException(fileName: String) : + RuntimeException("Invalid file name: $fileName") diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/PresignFailedException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/PresignFailedException.kt new file mode 100644 index 00000000..68ff8f41 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/PresignFailedException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class PresignFailedException(objectKey: String, cause: Throwable? = null) : + RuntimeException("Failed to issue download URL: $objectKey", cause) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUploadFailedException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUploadFailedException.kt new file mode 100644 index 00000000..c49cb5b9 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUploadFailedException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class StorageUploadFailedException(objectKey: String, cause: Throwable? = null) : + RuntimeException("Failed to upload file to storage: $objectKey", cause) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/IssueDownloadUrlUseCase.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/IssueDownloadUrlUseCase.kt new file mode 100644 index 00000000..91b99443 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/IssueDownloadUrlUseCase.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.configuration.domain.document.port.`in` + +import hs.kr.entrydsm.configuration.domain.document.DownloadUrl +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand + +interface IssueDownloadUrlUseCase { + fun issueByCommand(command: IssueDownloadUrlCommand): DownloadUrl + fun issueById(id: Long): DownloadUrl +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/ReadFileUseCase.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/ReadFileUseCase.kt new file mode 100644 index 00000000..aaa97840 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/ReadFileUseCase.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.configuration.domain.document.port.`in` + +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileDocument + +interface ReadFileUseCase { + fun findById(id: Long): FileDocument + fun findByFileName(category: FileCategory, fileName: String): FileDocument? + fun existsById(id: Long): Boolean +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/UploadFileUseCase.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/UploadFileUseCase.kt new file mode 100644 index 00000000..ed4bdc4a --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/UploadFileUseCase.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.configuration.domain.document.port.`in` + +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import hs.kr.entrydsm.configuration.domain.document.command.UploadFileCommand +import java.io.InputStream + +interface UploadFileUseCase { + fun upload(command: UploadFileCommand, content: InputStream): FileDocument +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt new file mode 100644 index 00000000..c3c4b406 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.configuration.domain.document.port.out + +import hs.kr.entrydsm.configuration.domain.document.FileDocument + +interface FileDocumentRepository { + fun save(fileDocument: FileDocument): FileDocument + fun findById(id: Long): FileDocument? + fun findByObjectKey(objectKey: String): FileDocument? + fun existsById(id: Long): Boolean + fun deleteByObjectKey(objectKey: String) +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt new file mode 100644 index 00000000..ceed8aac --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.configuration.domain.document.port.out + +import hs.kr.entrydsm.configuration.domain.document.StoredObject +import java.io.InputStream + +interface StoragePort { + fun upload( + objectKey: String, + contentType: String, + sizeBytes: Long, + content: InputStream, + ): StoredObject + + fun issueDownloadUrl(objectKey: String, expiresInSeconds: Long): String + + fun exists(objectKey: String): Boolean + + fun delete(objectKey: String) +} diff --git a/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt b/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt new file mode 100644 index 00000000..f7068128 --- /dev/null +++ b/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt @@ -0,0 +1,105 @@ +package hs.kr.entrydsm.configuration.domain.document + +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +class DocumentDomainTest { + + @Test + fun `확장자를 대소문자 구분 없이 인식한다`() { + assertEquals(FileExtension.PDF, FileExtension.fromFileName("application_1001.PDF")) + assertEquals(FileExtension.XLSX, FileExtension.fromExtension(".xlsx")) + } + + @Test + fun `jpeg는 jpg의 별칭으로 처리한다`() { + assertEquals(FileExtension.JPG, FileExtension.fromFileName("photo.jpeg")) + assertEquals("image/jpeg", FileExtension.JPG.contentType) + } + + @Test + fun `확장자가 없으면 인식하지 않는다`() { + assertNull(FileExtension.fromFileName("noextension")) + assertNull(FileExtension.fromFileName("unknown.exe")) + } + + @Test + fun `카테고리마다 허용 확장자가 다르다`() { + assertTrue(FileCategory.APPLICATION.supports(FileExtension.PDF)) + assertFalse(FileCategory.APPLICATION.supports(FileExtension.JPG)) + assertEquals(setOf(FileExtension.XLSX), FileCategory.APPLICANT_LIST.allowedExtensions) + assertTrue(FileCategory.PHOTO.supports(FileExtension.WEBP)) + assertFalse(FileCategory.PHOTO.supports(FileExtension.PDF)) + assertTrue(FileCategory.ATTACHMENT.supports(FileExtension.DOCX)) + } + + @Test + fun `카테고리별 용량 한도를 넘으면 초과로 판정한다`() { + assertFalse(FileCategory.PHOTO.exceedsMaxSize(FileCategory.PHOTO.maxSizeBytes)) + assertTrue(FileCategory.PHOTO.exceedsMaxSize(FileCategory.PHOTO.maxSizeBytes + 1)) + } + + @Test + fun `object key는 카테고리 prefix를 붙인다`() { + assertEquals( + "admission-ticket/admission_ticket_1001.pdf", + FileCategory.ADMISSION_TICKET.objectKeyOf("admission_ticket_1001.pdf"), + ) + } + + @Test(expected = InvalidFileNameException::class) + fun `object key에 상위 경로 참조가 들어오면 거부한다`() { + FileCategory.APPLICANT_LIST.objectKeyOf("../../etc/passwd") + } + + @Test + fun `명세 예시와 같은 파일명을 만든다`() { + assertEquals( + "application_1001.pdf", + FileNaming.applicationFileName("1001", FileExtension.PDF), + ) + assertEquals( + "admission_ticket_1001.pdf", + FileNaming.admissionTicketFileName("1001", FileExtension.PDF), + ) + assertEquals( + "applicants_20260726.xlsx", + FileNaming.applicantListFileName(LocalDate.of(2026, 7, 26)), + ) + } + + @Test + fun `증명사진과 첨부파일 이름에 랜덤 토큰을 붙인다`() { + assertTrue(FileNaming.photoFileName(FileExtension.JPG).matches(Regex("photo_[0-9a-f]{32}\\.jpg"))) + assertTrue(FileNaming.attachmentFileName("guide.pdf").matches(Regex("[0-9a-f]{32}_guide\\.pdf"))) + } + + @Test + fun `원본 파일명에서 경로를 제거하고 허용 외 문자를 치환한다`() { + assertEquals("pas_swd.pdf", FileNaming.sanitizeOriginalName("../../etc/pas swd.pdf")) + assertEquals("report.xlsx", FileNaming.sanitizeOriginalName("C:\\temp\\report.xlsx")) + } + + @Test(expected = InvalidFileNameException::class) + fun `수험번호에 경로 문자가 들어오면 거부한다`() { + FileNaming.requireIdentifier("../1001") + } + + @Test(expected = InvalidFileNameException::class) + fun `다운로드 파일명에 상위 경로 참조가 들어오면 거부한다`() { + FileNaming.requireSafeFileName("../../etc/passwd") + } + + @Test + fun `정상 다운로드 파일명은 그대로 통과시킨다`() { + assertEquals( + "applicants_20260726.xlsx", + FileNaming.requireSafeFileName("applicants_20260726.xlsx"), + ) + } +}