diff --git a/api.yaml b/api.yaml index 59e0563..651c295 100644 --- a/api.yaml +++ b/api.yaml @@ -1451,7 +1451,7 @@ paths: tags: - DangerZone summary: Danger Zone パスワード照合 - description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する + description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する。接続元ごとに連続失敗を数え、上限に達している間は429を返す operationId: verifyDangerZonePassword requestBody: required: true @@ -1472,6 +1472,18 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '429': + description: 連続失敗が上限に達したため一時的に照合を受け付けない + headers: + Retry-After: + description: 再試行できるまでの秒数 + schema: + type: integer + example: 60 + content: + application/json: + schema: + $ref: '#/components/schemas/DangerZoneVerifyResponse' components: diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt index e958357..7cb473e 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiController.kt @@ -7,9 +7,12 @@ import info.nukoneko.kidspos.server.controller.dto.response.DangerZonePasswordRe import info.nukoneko.kidspos.server.controller.dto.response.DangerZoneStatusResponse import info.nukoneko.kidspos.server.controller.dto.response.DangerZoneVerifyResponse import info.nukoneko.kidspos.server.service.DangerZonePasswordService +import info.nukoneko.kidspos.server.service.DangerZoneVerifyRateLimiter import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -28,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "DangerZone", description = "危険操作を保護するパスワードAPI") class DangerZoneApiController( private val dangerZonePasswordService: DangerZonePasswordService, + private val verifyRateLimiter: DangerZoneVerifyRateLimiter, ) { @GetMapping("/status") @Operation(summary = "設定状態取得", description = "Danger Zone パスワードが設定済みかを取得します") @@ -55,11 +59,36 @@ class DangerZoneApiController( @Operation(summary = "パスワード照合", description = "入力されたパスワードがDanger Zone パスワードと一致するかを判定します") fun verify( @Valid @RequestBody request: VerifyDangerZonePasswordRequest, - ): DangerZoneVerifyResponse { + httpRequest: HttpServletRequest, + ): ResponseEntity { + val clientKey = clientKeyOf(httpRequest) + val retryAfter = verifyRateLimiter.retryAfterSeconds(clientKey) + if (retryAfter > 0) { + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, retryAfter.toString()) + .body( + DangerZoneVerifyResponse( + valid = false, + configured = dangerZonePasswordService.isConfigured(), + message = "試行回数が多すぎます。${retryAfter}秒後にもう一度お試しください", + ), + ) + } + val result = dangerZonePasswordService.verify(request.password) - return DangerZoneVerifyResponse(result.valid, result.configured, result.message) + when { + result.valid -> verifyRateLimiter.recordSuccess(clientKey) + result.configured -> verifyRateLimiter.recordFailure(clientKey) + } + return ResponseEntity.ok(DangerZoneVerifyResponse(result.valid, result.configured, result.message)) } + /** + * イントラネット内で直接受けるため、詐称できるX-Forwarded-Forは見ずに接続元アドレスだけを使う。 + */ + private fun clientKeyOf(request: HttpServletRequest): String = request.remoteAddr ?: "unknown" + private fun toResponse(result: DangerZonePasswordService.ChangeResult): ResponseEntity { val status = if (result.succeeded) HttpStatus.OK else HttpStatus.BAD_REQUEST return ResponseEntity diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt index 132da86..70f1f1a 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiController.kt @@ -7,7 +7,6 @@ import info.nukoneko.kidspos.server.controller.dto.response.ItemResponse import info.nukoneko.kidspos.server.domain.exception.InvalidBarcodeException import info.nukoneko.kidspos.server.domain.exception.ItemNotFoundException import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -42,7 +41,6 @@ class ItemApiController( private val itemService: ItemService, private val itemMapper: ItemMapper, private val validationService: ValidationService, - private val barcodeService: BarcodeService, private val barcodePdfService: BarcodePdfService, ) { private val logger = LoggerFactory.getLogger(ItemApiController::class.java) @@ -279,7 +277,7 @@ class ItemApiController( throw ItemNotFoundException() } - val pdfBytes = barcodeService.generateBarcodePdf(items, showBorders) + val pdfBytes = barcodePdfService.getSelectedItemsPdf(items, showBorders) logger.info("Selected barcode PDF generated successfully with {} items", items.size) diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt index 7015b33..f05c9b6 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfService.kt @@ -1,20 +1,26 @@ package info.nukoneko.kidspos.server.service import info.nukoneko.kidspos.server.entity.ItemEntity +import jakarta.annotation.PreDestroy import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.event.EventListener import org.springframework.stereotype.Service +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener import java.security.MessageDigest +import java.util.Collections import java.util.concurrent.ConcurrentHashMap -import kotlin.concurrent.thread +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException /** - * 全商品のバーコードPDFを保持するサービス + * バーコードPDFを保持するサービス * * 商品一覧が変わらない限り生成済みのバイト列を返す。 - * 起動直後にバックグラウンドで生成しておくことで、 + * 起動直後と商品変更後にバックグラウンドで生成しておくことで、 * 端末側のリクエストがタイムアウトする事態を避ける。 */ @Service @@ -27,6 +33,20 @@ class BarcodePdfService( private val logger = LoggerFactory.getLogger(BarcodePdfService::class.java) private val cache = ConcurrentHashMap() private val locks = ConcurrentHashMap() + private val selectedLock = Any() + + private val selectedCache: MutableMap = + Collections.synchronizedMap( + object : LinkedHashMap(SELECTED_CACHE_CAPACITY, LOAD_FACTOR, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > SELECTED_CACHE_CAPACITY + }, + ) + + private val warmUpExecutor: ExecutorService by lazy { + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "barcode-pdf-warmup").apply { isDaemon = true } + } + } fun getAllItemsPdf(showBorders: Boolean = false): ByteArray { val items = itemService.findAll() @@ -59,13 +79,49 @@ class BarcodePdfService( } } + /** + * 選択された商品のPDFを返す。 + * + * 署名をそのままキャッシュキーにしているため、商品名や価格が変われば別のキーになり + * 古いエントリはLRUで押し出される。生成を直列化して、同じ選択の同時要求で + * 非力な端末のCPUを何度も使わないようにする。 + */ + fun getSelectedItemsPdf( + items: List, + showBorders: Boolean, + ): ByteArray { + val signature = signatureOf(items, showBorders) + + selectedCache[signature]?.let { cached -> + logger.debug("Reusing cached selected barcode PDF (showBorders={})", showBorders) + return cached + } + + return synchronized(selectedLock) { + selectedCache[signature]?.let { return@synchronized it } + + val startedAt = System.currentTimeMillis() + val bytes = barcodeService.generateBarcodePdf(items, showBorders) + selectedCache[signature] = bytes + logger.info( + "Generated selected barcode PDF for {} items in {} ms (showBorders={})", + items.size, + System.currentTimeMillis() - startedAt, + showBorders, + ) + bytes + } + } + fun isCached(showBorders: Boolean = false): Boolean = cache.containsKey(showBorders) fun warmUp() { - try { - getAllItemsPdf(false) - } catch (e: Exception) { - logger.warn("Failed to warm up barcode PDF cache: {}", e.message) + BORDER_VARIANTS.forEach { showBorders -> + try { + getAllItemsPdf(showBorders) + } catch (e: Exception) { + logger.warn("Failed to warm up barcode PDF cache (showBorders={}): {}", showBorders, e.message) + } } } @@ -75,8 +131,25 @@ class BarcodePdfService( logger.debug("Barcode PDF warm-up on startup is disabled") return } - thread(start = true, isDaemon = true, name = "barcode-pdf-warmup") { - warmUp() + scheduleWarmUp() + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + fun onItemsChanged(event: ItemsChangedEvent) { + logger.debug("Rebuilding barcode PDF cache after item change (itemId={})", event.itemId) + scheduleWarmUp() + } + + @PreDestroy + fun shutdown() { + warmUpExecutor.shutdownNow() + } + + private fun scheduleWarmUp() { + try { + warmUpExecutor.execute { warmUp() } + } catch (e: RejectedExecutionException) { + logger.debug("Barcode PDF warm-up was not scheduled: {}", e.message) } } @@ -96,4 +169,10 @@ class BarcodePdfService( val signature: String, val bytes: ByteArray, ) + + private companion object { + val BORDER_VARIANTS = listOf(false, true) + const val SELECTED_CACHE_CAPACITY = 8 + const val LOAD_FACTOR = 0.75f + } } diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiter.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiter.kt new file mode 100644 index 0000000..33e0f23 --- /dev/null +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiter.kt @@ -0,0 +1,95 @@ +package info.nukoneko.kidspos.server.service + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap + +internal const val DEFAULT_VERIFY_MAX_FAILURES = 5 +internal const val DEFAULT_VERIFY_BLOCK_SECONDS = 60L + +/** + * Danger Zone パスワード照合の連打を抑えるレートリミッター + * + * 照合は PBKDF2 を10万回反復するため、総当たりは推測される危険に加えて + * Raspberry Pi の CPU を占有し他の会計処理まで巻き添えにする。 + * クライアントごとに失敗回数を数え、上限に達した間は照合そのものを行わせない。 + */ +@Component +class DangerZoneVerifyRateLimiter( + @Value("\${app.danger-zone.verify.max-failures:$DEFAULT_VERIFY_MAX_FAILURES}") + private val maxFailures: Int = DEFAULT_VERIFY_MAX_FAILURES, + @Value("\${app.danger-zone.verify.block-seconds:$DEFAULT_VERIFY_BLOCK_SECONDS}") + private val blockSeconds: Long = DEFAULT_VERIFY_BLOCK_SECONDS, +) { + private val logger = LoggerFactory.getLogger(DangerZoneVerifyRateLimiter::class.java) + private val attempts = ConcurrentHashMap() + + @Volatile + internal var timeSource: () -> Long = System::currentTimeMillis + + /** + * ブロック中なら解除までの残り秒数を、そうでなければ0を返す。 + */ + fun retryAfterSeconds(clientKey: String): Long { + val attempt = attempts[clientKey] ?: return 0 + val remaining = attempt.blockedUntil - timeSource() + if (remaining <= 0) { + return 0 + } + return (remaining + MILLIS_PER_SECOND - 1) / MILLIS_PER_SECOND + } + + fun recordFailure(clientKey: String) { + val now = timeSource() + val blockMillis = blockSeconds * MILLIS_PER_SECOND + purgeExpired(now, blockMillis) + + val updated = + attempts.compute(clientKey) { _, current -> + val continued = current != null && now - current.lastFailureAt <= blockMillis + val failures = if (continued) current.failures + 1 else 1 + if (failures >= maxFailures) { + Attempt(failures = 0, blockedUntil = now + blockMillis, lastFailureAt = now) + } else { + Attempt(failures = failures, blockedUntil = 0, lastFailureAt = now) + } + } + + if (updated != null && updated.blockedUntil > now) { + logger.warn("Danger zone verification blocked for {} seconds (client={})", blockSeconds, clientKey) + } + } + + fun recordSuccess(clientKey: String) { + attempts.remove(clientKey) + } + + internal fun trackedClientCount(): Int = attempts.size + + /** + * 失敗した端末の分だけエントリが残るため、上限を超えたら期限切れの分を捨てる。 + */ + private fun purgeExpired( + now: Long, + blockMillis: Long, + ) { + if (attempts.size < MAX_TRACKED_CLIENTS) { + return + } + attempts.entries.removeIf { (_, attempt) -> + attempt.blockedUntil <= now && now - attempt.lastFailureAt > blockMillis + } + } + + private data class Attempt( + val failures: Int, + val blockedUntil: Long, + val lastFailureAt: Long, + ) + + private companion object { + const val MILLIS_PER_SECOND = 1_000L + const val MAX_TRACKED_CLIENTS = 1_000 + } +} diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt index 24c61c1..5095260 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemService.kt @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory import org.springframework.cache.annotation.CacheEvict import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Caching +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -39,12 +40,14 @@ import org.springframework.transaction.annotation.Transactional * @constructor Creates ItemService with required dependencies * @param repository Repository for item data access * @param idGenerationService Service for generating unique item IDs + * @param eventPublisher Publisher used to notify listeners about item changes */ @Service @Transactional class ItemService( private val repository: ItemRepository, private val idGenerationService: IdGenerationService, + private val eventPublisher: ApplicationEventPublisher, ) { private val logger = LoggerFactory.getLogger(ItemService::class.java) @@ -95,6 +98,7 @@ class ItemService( val item = ItemEntity(generatedId, finalBarcode, itemBean.name, itemBean.price) val savedItem = repository.save(item) logger.info("Item created successfully with ID: {}, barcode: {}", savedItem.id, savedItem.barcode) + eventPublisher.publishEvent(ItemsChangedEvent(savedItem.id)) return savedItem } @@ -110,6 +114,7 @@ class ItemService( if (item != null) { repository.delete(item) logger.info("Item deleted successfully with ID: {}", id) + eventPublisher.publishEvent(ItemsChangedEvent(id)) } } } diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemsChangedEvent.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemsChangedEvent.kt new file mode 100644 index 0000000..f7883c4 --- /dev/null +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/ItemsChangedEvent.kt @@ -0,0 +1,11 @@ +package info.nukoneko.kidspos.server.service + +/** + * 商品マスタが変更されたことを表すイベント + * + * バーコードPDFのキャッシュを作り直すために使う。 + * BarcodePdfService は ItemService に依存しているため、逆向きの直接参照を避けてイベントで通知する。 + */ +class ItemsChangedEvent( + val itemId: Int, +) diff --git a/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt b/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt index e64343e..a2b0909 100644 --- a/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt +++ b/src/main/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdater.kt @@ -1,14 +1,30 @@ package info.nukoneko.kidspos.server.service +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +import java.io.File import java.util.concurrent.TimeUnit +internal const val DEFAULT_FAKE_HWCLOCK_PATHS = "/sbin/fake-hwclock,/usr/sbin/fake-hwclock,/usr/bin/fake-hwclock" + /** * OSのシステム時刻を変更するコンポーネント */ @Component -class SystemClockUpdater { - fun setTime(epochMillis: Long): Result = runCommand(buildCommand(epochMillis)) +class SystemClockUpdater( + @Value("\${app.system-time.fake-hwclock-paths:$DEFAULT_FAKE_HWCLOCK_PATHS}") + private val fakeHwclockPaths: List, +) { + private val logger = LoggerFactory.getLogger(SystemClockUpdater::class.java) + + fun setTime(epochMillis: Long): Result { + val result = runCommand(buildCommand(epochMillis)) + if (result.succeeded) { + persistToFakeHwclock() + } + return result + } internal fun buildCommand(epochMillis: Long): List { val seconds = epochMillis / MILLIS_PER_SECOND @@ -16,6 +32,31 @@ class SystemClockUpdater { return listOf("date", "-s", "@$seconds.${"%03d".format(millis)}") } + internal fun findFakeHwclock(): String? = + fakeHwclockPaths + .map { it.trim() } + .firstOrNull { it.isNotEmpty() && File(it).canExecute() } + + /** + * Raspberry Pi には RTC がなく、起動直後の時刻は /etc/fake-hwclock.data から復元される。 + * このファイルは毎時の cron と正常シャットダウン時にしか保存されないため、同期した直後に + * 電源を落とすと最大 1 時間巻き戻る。同期のたびに保存して巻き戻りを防ぐ。 + * 保存に失敗してもシステム時刻そのものの変更結果には影響させない。 + */ + internal fun persistToFakeHwclock(): Result { + val executable = + findFakeHwclock() + ?: return Result(succeeded = false, output = "fake-hwclock が見つかりません") + + val result = runCommand(listOf(executable, "save")) + if (result.succeeded) { + logger.info("fake-hwclock に現在時刻を保存しました") + } else { + logger.warn("fake-hwclock の保存に失敗しました: {}", result.output.ifBlank { "詳細不明" }) + } + return result + } + internal fun runCommand(command: List): Result { var process: Process? = null return try { diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt index ba11027..4490946 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/advice/GlobalExceptionHandlerTest.kt @@ -5,7 +5,6 @@ import info.nukoneko.kidspos.server.controller.api.ItemApiController import info.nukoneko.kidspos.server.controller.dto.request.CreateItemRequest import info.nukoneko.kidspos.server.domain.exception.ItemNotFoundException import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -52,9 +51,6 @@ class GlobalExceptionHandlerTest { @MockBean private lateinit var validationService: ValidationService - @MockBean - private lateinit var barcodeService: BarcodeService - @MockBean private lateinit var barcodePdfService: BarcodePdfService diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt index f75533a..9718c3e 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/DangerZoneApiControllerTest.kt @@ -1,6 +1,7 @@ package info.nukoneko.kidspos.server.controller.api import info.nukoneko.kidspos.server.service.DangerZonePasswordService +import info.nukoneko.kidspos.server.service.DangerZoneVerifyRateLimiter import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -17,6 +18,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -30,6 +32,9 @@ class DangerZoneApiControllerTest { @MockBean private lateinit var dangerZonePasswordService: DangerZonePasswordService + @MockBean + private lateinit var verifyRateLimiter: DangerZoneVerifyRateLimiter + @Test fun `未設定なら設定状態はfalseを返す`() { whenever(dangerZonePasswordService.isConfigured()).thenReturn(false) @@ -196,4 +201,76 @@ class DangerZoneApiControllerTest { verify(dangerZonePasswordService, never()).verify(any()) } + + @Test + fun `連打が上限を超えると429を返し照合しない`() { + whenever(verifyRateLimiter.retryAfterSeconds(any())).thenReturn(30) + whenever(dangerZonePasswordService.isConfigured()).thenReturn(true) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"wrong"}"""), + ).andExpect(status().isTooManyRequests) + .andExpect(header().string("Retry-After", "30")) + .andExpect(jsonPath("$.valid").value(false)) + .andExpect(jsonPath("$.configured").value(true)) + .andExpect(jsonPath("$.message").value("試行回数が多すぎます。30秒後にもう一度お試しください")) + + verify(dangerZonePasswordService, never()).verify(any()) + } + + @Test + fun `照合に失敗したら失敗として記録する`() { + whenever(dangerZonePasswordService.verify(eq("wrong"))) + .thenReturn(DangerZonePasswordService.VerifyResult(false, configured = true, message = "パスワードが違います")) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"wrong"}"""), + ).andExpect(status().isOk) + + verify(verifyRateLimiter).recordFailure(any()) + verify(verifyRateLimiter, never()).recordSuccess(any()) + } + + @Test + fun `照合に成功したら記録を消す`() { + whenever(dangerZonePasswordService.verify(eq("kidspos1234"))) + .thenReturn(DangerZonePasswordService.VerifyResult(true, configured = true, message = "認証しました")) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"kidspos1234"}"""), + ).andExpect(status().isOk) + + verify(verifyRateLimiter).recordSuccess(any()) + verify(verifyRateLimiter, never()).recordFailure(any()) + } + + @Test + fun `パスワード未設定の照合は失敗として数えない`() { + whenever(dangerZonePasswordService.verify(eq("kidspos1234"))) + .thenReturn( + DangerZonePasswordService.VerifyResult( + false, + configured = false, + message = "サーバーにパスワードが設定されていません", + ), + ) + + mockMvc + .perform( + post("/api/setting/danger-zone/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"password":"kidspos1234"}"""), + ).andExpect(status().isOk) + + verify(verifyRateLimiter, never()).recordFailure(any()) + } } diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt index 48633d8..65bd632 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerTest.kt @@ -6,7 +6,6 @@ import info.nukoneko.kidspos.server.controller.dto.request.ItemBean import info.nukoneko.kidspos.server.controller.dto.response.ItemResponse import info.nukoneko.kidspos.server.entity.ItemEntity import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -49,9 +48,6 @@ class ItemApiControllerTest { @MockBean private lateinit var validationService: ValidationService - @MockBean - private lateinit var barcodeService: BarcodeService - @MockBean private lateinit var barcodePdfService: BarcodePdfService @@ -372,7 +368,7 @@ class ItemApiControllerTest { @Test fun `should generate selected barcode pdf via get request`() { `when`(itemService.findItem(1)).thenReturn(testItem) - `when`(barcodeService.generateBarcodePdf(listOf(testItem), false)).thenReturn(byteArrayOf(9)) + `when`(barcodePdfService.getSelectedItemsPdf(listOf(testItem), false)).thenReturn(byteArrayOf(9)) mockMvc .perform(get("/api/item/barcode-pdf/selected").param("ids", "1")) @@ -380,7 +376,7 @@ class ItemApiControllerTest { .andExpect(content().contentType(MediaType.APPLICATION_PDF)) .andExpect(header().string("Content-Disposition", "attachment; filename=\"selected_barcodes.pdf\"")) - verify(barcodeService).generateBarcodePdf(listOf(testItem), false) + verify(barcodePdfService).getSelectedItemsPdf(listOf(testItem), false) } @Test diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt index 8f34298..64a5fa5 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/controller/api/ItemApiControllerUnitTest.kt @@ -5,7 +5,6 @@ import info.nukoneko.kidspos.server.controller.dto.request.ItemBean import info.nukoneko.kidspos.server.controller.dto.response.ItemResponse import info.nukoneko.kidspos.server.entity.ItemEntity import info.nukoneko.kidspos.server.service.BarcodePdfService -import info.nukoneko.kidspos.server.service.BarcodeService import info.nukoneko.kidspos.server.service.ItemService import info.nukoneko.kidspos.server.service.ValidationService import info.nukoneko.kidspos.server.service.mapper.ItemMapper @@ -30,9 +29,6 @@ class ItemApiControllerUnitTest { @Mock private lateinit var validationService: ValidationService - @Mock - private lateinit var barcodeService: BarcodeService - @Mock private lateinit var barcodePdfService: BarcodePdfService diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt index 46b3e4e..4eb2b32 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/BarcodePdfServiceTest.kt @@ -9,6 +9,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock +import org.mockito.Mockito.timeout import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -18,6 +19,7 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @ExtendWith(MockitoExtension::class) @DisplayName("BarcodePdfService") @@ -132,14 +134,19 @@ class BarcodePdfServiceTest { } @Test - @DisplayName("ウォームアップ前後でキャッシュの有無が変わる") + @DisplayName("ウォームアップは罫線ありとなしの両方を作る") fun warmUpFillsCache() { whenever(itemService.findAll()).thenReturn(items) whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) assertFalse(service.isCached(false)) + assertFalse(service.isCached(true)) + service.warmUp() + assertTrue(service.isCached(false)) + assertTrue(service.isCached(true)) } @Test @@ -168,13 +175,123 @@ class BarcodePdfServiceTest { fun warmsUpOnStartupWhenEnabled() { whenever(itemService.findAll()).thenReturn(items) whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) service.warmUpInBackground() + awaitCached(false) + awaitCached(true) + assertTrue(service.isCached(false)) + assertTrue(service.isCached(true)) + } + + @Test + @DisplayName("商品が変わったらキャッシュを作り直す") + fun rebuildsCacheOnItemsChanged() { + val current = AtomicReference(items) + whenever(itemService.findAll()).thenAnswer { current.get() } + whenever(barcodeService.generateBarcodePdf(any(), eq(false))) + .thenReturn(byteArrayOf(1)) + .thenReturn(byteArrayOf(9)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) + + service.warmUp() + assertArrayEquals(byteArrayOf(1), service.getAllItemsPdf(false)) + + current.set(items + ItemEntity(3, "A01000003A", "ぶどう", 200)) + service.onItemsChanged(ItemsChangedEvent(3)) + + verify(barcodeService, timeout(5_000).times(2)).generateBarcodePdf(any(), eq(false)) + assertArrayEquals(byteArrayOf(9), service.getAllItemsPdf(false)) + } + + @Test + @DisplayName("同じ選択の再要求では生成し直さない") + fun reusesCachedSelectedPdf() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(7)) + + val first = service.getSelectedItemsPdf(items, false) + val second = service.getSelectedItemsPdf(items, false) + + assertArrayEquals(byteArrayOf(7), first) + assertArrayEquals(first, second) + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(false)) + } + + @Test + @DisplayName("選択の中身が変われば別のPDFを作る") + fun regeneratesSelectedPdfWhenSelectionChanges() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))) + .thenReturn(byteArrayOf(1)) + .thenReturn(byteArrayOf(2)) + + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + assertArrayEquals(byteArrayOf(2), service.getSelectedItemsPdf(listOf(items[0]), false)) + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + + verify(barcodeService, times(2)).generateBarcodePdf(any(), eq(false)) + } + + @Test + @DisplayName("選択PDFの罫線ありとなしは別々に保持される") + fun cachesSelectedPdfPerShowBordersFlag() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + whenever(barcodeService.generateBarcodePdf(any(), eq(true))).thenReturn(byteArrayOf(2)) + + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + assertArrayEquals(byteArrayOf(2), service.getSelectedItemsPdf(items, true)) + assertArrayEquals(byteArrayOf(1), service.getSelectedItemsPdf(items, false)) + + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(false)) + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(true)) + } + + @Test + @DisplayName("選択PDFのキャッシュは上限を超えると古いものから捨てる") + fun evictsOldestSelectedPdf() { + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenReturn(byteArrayOf(1)) + + val selections = (1..SELECTED_CACHE_CAPACITY + 1).map { listOf(ItemEntity(it, "A0100000${it}A", "商品$it", it * 10)) } + selections.forEach { service.getSelectedItemsPdf(it, false) } + + service.getSelectedItemsPdf(selections.first(), false) + + verify(barcodeService, times(SELECTED_CACHE_CAPACITY + 2)).generateBarcodePdf(any(), eq(false)) + } + + @Test + @DisplayName("同じ選択が同時に要求されても生成は一度だけ行われる") + fun generatesSelectedPdfOnceUnderConcurrentAccess() { + val threadCount = 8 + val start = CountDownLatch(1) + val done = CountDownLatch(threadCount) + + whenever(barcodeService.generateBarcodePdf(any(), eq(false))).thenAnswer { + Thread.sleep(20) + byteArrayOf(1) + } + + repeat(threadCount) { + Thread { + start.await() + service.getSelectedItemsPdf(items, false) + done.countDown() + }.also { it.isDaemon = true }.start() + } + + start.countDown() + assertTrue(done.await(10, TimeUnit.SECONDS)) + verify(barcodeService, times(1)).generateBarcodePdf(any(), eq(false)) + } + + private fun awaitCached(showBorders: Boolean) { val deadline = System.currentTimeMillis() + 5_000 - while (!service.isCached(false) && System.currentTimeMillis() < deadline) { + while (!service.isCached(showBorders) && System.currentTimeMillis() < deadline) { Thread.sleep(10) } - assertTrue(service.isCached(false)) + } + + private companion object { + const val SELECTED_CACHE_CAPACITY = 8 } } diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt index e41cf50..36dd683 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/ConstructorInjectionTest.kt @@ -10,6 +10,7 @@ import org.mockito.Mockito.mock import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationEventPublisher @SpringBootTest class ConstructorInjectionTest { @@ -21,9 +22,10 @@ class ConstructorInjectionTest { // Given - Create mocks val itemRepository = mock(ItemRepository::class.java) val idGenerationService = mock(IdGenerationService::class.java) + val eventPublisher = mock(ApplicationEventPublisher::class.java) // When - Create service with constructor injection - val itemService = ItemService(itemRepository, idGenerationService) + val itemService = ItemService(itemRepository, idGenerationService, eventPublisher) // Then - Service should be properly initialized assertThat(itemService).isNotNull diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiterTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiterTest.kt new file mode 100644 index 0000000..7919fbe --- /dev/null +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/DangerZoneVerifyRateLimiterTest.kt @@ -0,0 +1,101 @@ +package info.nukoneko.kidspos.server.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("DangerZoneVerifyRateLimiter") +class DangerZoneVerifyRateLimiterTest { + private lateinit var limiter: DangerZoneVerifyRateLimiter + private var now = 1_700_000_000_000L + + @BeforeEach + fun setUp() { + limiter = DangerZoneVerifyRateLimiter(maxFailures = 3, blockSeconds = 60) + limiter.timeSource = { now } + } + + @Test + fun `失敗が上限未満ならブロックしない`() { + repeat(2) { limiter.recordFailure(CLIENT) } + + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `失敗が上限に達したらブロックする`() { + repeat(3) { limiter.recordFailure(CLIENT) } + + assertEquals(60, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `ブロックは時間経過で解除される`() { + repeat(3) { limiter.recordFailure(CLIENT) } + + now += 59_000 + assertEquals(1, limiter.retryAfterSeconds(CLIENT)) + + now += 1_000 + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `解除後は改めて上限まで試行できる`() { + repeat(3) { limiter.recordFailure(CLIENT) } + now += 60_000 + + repeat(2) { limiter.recordFailure(CLIENT) } + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + + limiter.recordFailure(CLIENT) + assertEquals(60, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `成功すると失敗回数が消える`() { + repeat(2) { limiter.recordFailure(CLIENT) } + limiter.recordSuccess(CLIENT) + + repeat(2) { limiter.recordFailure(CLIENT) } + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `間隔が空いた失敗は数え直す`() { + repeat(2) { limiter.recordFailure(CLIENT) } + + now += 60_001 + limiter.recordFailure(CLIENT) + + assertEquals(0, limiter.retryAfterSeconds(CLIENT)) + } + + @Test + fun `クライアントごとに別々に数える`() { + repeat(3) { limiter.recordFailure(CLIENT) } + + assertEquals(60, limiter.retryAfterSeconds(CLIENT)) + assertEquals(0, limiter.retryAfterSeconds("192.168.0.99")) + } + + @Test + fun `未知のクライアントはブロックされない`() { + assertEquals(0, limiter.retryAfterSeconds("192.168.0.50")) + } + + @Test + fun `古いエントリは掃除される`() { + repeat(1_000) { index -> limiter.recordFailure("10.0.0.$index") } + now += 60_001 + + limiter.recordFailure(CLIENT) + + assertEquals(1, limiter.trackedClientCount()) + } + + private companion object { + const val CLIENT = "192.168.0.10" + } +} diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt index 619eb73..3a21ede 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/ItemServiceTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito.* import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher import java.util.* @SpringBootTest @@ -20,11 +21,14 @@ class ItemServiceTest { @MockBean private lateinit var idGenerationService: IdGenerationService + private lateinit var eventPublisher: ApplicationEventPublisher + private lateinit var itemService: ItemService @BeforeEach fun setup() { - itemService = ItemService(itemRepository, idGenerationService) + eventPublisher = mock(ApplicationEventPublisher::class.java) + itemService = ItemService(itemRepository, idGenerationService, eventPublisher) } @Test diff --git a/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt b/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt index 2e11f12..3bdad9f 100644 --- a/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt +++ b/src/test/kotlin/info/nukoneko/kidspos/server/service/SystemClockUpdaterTest.kt @@ -2,11 +2,14 @@ package info.nukoneko.kidspos.server.service import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File class SystemClockUpdaterTest { - private val updater = SystemClockUpdater() + private val updater = SystemClockUpdater(DEFAULT_FAKE_HWCLOCK_PATHS.split(",")) @Test fun `エポックミリ秒を秒とミリ秒に分けたdateコマンドを組み立てる`() { @@ -41,4 +44,73 @@ class SystemClockUpdaterTest { assertFalse(result.succeeded) assertTrue(result.output.isNotBlank()) } + + @Test + fun `実行可能なfake-hwclockが無ければnullを返す`( + @TempDir tempDir: File, + ) { + val missing = File(tempDir, "fake-hwclock") + val updaterWithoutHwclock = SystemClockUpdater(listOf(missing.absolutePath, " ")) + + assertNull(updaterWithoutHwclock.findFakeHwclock()) + } + + @Test + fun `候補のうち実行可能な最初のパスを選ぶ`( + @TempDir tempDir: File, + ) { + val missing = File(tempDir, "missing") + val executable = writeExecutable(tempDir, "fake-hwclock", "exit 0") + + val updaterWithHwclock = SystemClockUpdater(listOf(missing.absolutePath, executable.absolutePath)) + + assertEquals(executable.absolutePath, updaterWithHwclock.findFakeHwclock()) + } + + @Test + fun `fake-hwclockが無い場合は保存に失敗した結果を返す`( + @TempDir tempDir: File, + ) { + val updaterWithoutHwclock = SystemClockUpdater(listOf(File(tempDir, "missing").absolutePath)) + + val result = updaterWithoutHwclock.persistToFakeHwclock() + + assertFalse(result.succeeded) + assertTrue(result.output.contains("fake-hwclock")) + } + + @Test + fun `fake-hwclockにsaveを渡して実行する`( + @TempDir tempDir: File, + ) { + val marker = File(tempDir, "marker") + val executable = writeExecutable(tempDir, "fake-hwclock", "echo \"\$1\" > ${marker.absolutePath}") + + val result = SystemClockUpdater(listOf(executable.absolutePath)).persistToFakeHwclock() + + assertTrue(result.succeeded) + assertEquals("save", marker.readText().trim()) + } + + @Test + fun `fake-hwclockの保存に失敗しても失敗理由を返す`( + @TempDir tempDir: File, + ) { + val executable = writeExecutable(tempDir, "fake-hwclock", "echo 'permission denied' >&2; exit 1") + + val result = SystemClockUpdater(listOf(executable.absolutePath)).persistToFakeHwclock() + + assertFalse(result.succeeded) + assertEquals("permission denied", result.output) + } + + private fun writeExecutable( + directory: File, + name: String, + body: String, + ): File = + File(directory, name).apply { + writeText("#!/bin/sh\n$body\n") + setExecutable(true) + } }