diff --git a/services/decision/src/integrationTest/kotlin/com/fincore/decision/store/api/DecisionReplayApiIT.kt b/services/decision/src/integrationTest/kotlin/com/fincore/decision/store/api/DecisionReplayApiIT.kt new file mode 100644 index 0000000..affa908 --- /dev/null +++ b/services/decision/src/integrationTest/kotlin/com/fincore/decision/store/api/DecisionReplayApiIT.kt @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.decision.store.persistence.DecisionLogRepository +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.time.Instant +import java.util.UUID + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(PostgresContainerExtension::class) +@Import(DecisionReplayApiIT.TestSecurity::class) +class DecisionReplayApiIT( + @Autowired private val rest: TestRestTemplate, + @Autowired private val objectMapper: ObjectMapper, + @Autowired private val decisionLogRepository: DecisionLogRepository, +) { + private val matchRule = """{"condition":{"attr":"amount","op":"gte","value":100},"outcome":{"label":"approve"}}""" + private val candidateRule = """{"condition":{"attr":"amount","op":"gte","value":1000},"outcome":{"label":"approve"}}""" + private val redosRule = """{"condition":{"attr":"s","op":"matches","value":"(.*,){1,100}Z"},"outcome":{"label":"x"}}""" + + @TestConfiguration + class TestSecurity { + @Bean + fun jwtDecoder(): JwtDecoder = + JwtDecoder { token -> + val scope = if (token == WRITER) "decision:read decision:write" else "decision:read" + Jwt + .withTokenValue(token) + .header("alg", "none") + .subject("api-user") + .claim("scope", scope) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) + .build() + } + } + + @Test + fun `should diff a candidate against a recorded decision and write nothing`() { + val key = newKey() + publishRule(key, matchRule) + evaluate(key, """{"amount":150}""").statusCode shouldBe HttpStatus.OK + val auditCountBefore = decisionLogRepository.count() + + val response = replay("""{"candidate":$candidateRule,"inputs":[{"amount":150}]}""") + + response.statusCode shouldBe HttpStatus.OK + val report = objectMapper.readTree(response.body) + report.get("changed").asInt() shouldBe 1 + report + .get("diffs") + .get(0) + .get("status") + .asText() shouldBe "CHANGED" + decisionLogRepository.count() shouldBe auditCountBefore + } + + @Test + fun `should return no baseline for an input never evaluated`() { + val response = replay("""{"candidate":$matchRule,"inputs":[{"amount":${UUID.randomUUID().hashCode()}}]}""") + + response.statusCode shouldBe HttpStatus.OK + objectMapper.readTree(response.body).get("noBaseline").asInt() shouldBe 1 + } + + @Test + fun `should return 503 when a candidate pattern catastrophically backtracks`() { + val response = replay("""{"candidate":$redosRule,"inputs":[{"s":"${"a,".repeat(REDOS_REPEATS)}"}]}""") + + response.statusCode shouldBe HttpStatus.SERVICE_UNAVAILABLE + } + + @Test + fun `should reject replay carrying only the read scope`() { + val response = + rest.exchange( + "/v1/decision/replay", + HttpMethod.POST, + HttpEntity("""{"candidate":$matchRule,"inputs":[{"amount":1}]}""", headers(READER)), + String::class.java, + ) + + response.statusCode shouldBe HttpStatus.FORBIDDEN + } + + private fun publishRule( + key: String, + dsl: String, + ) { + rest + .postForEntity("/v1/decision/rules", HttpEntity("""{"ruleKey":"$key"}""", headers(WRITER)), String::class.java) + .statusCode shouldBe HttpStatus.CREATED + rest + .postForEntity("/v1/decision/rules/$key/versions", HttpEntity(dsl, headers(WRITER)), String::class.java) + .statusCode shouldBe HttpStatus.CREATED + } + + private fun evaluate( + key: String, + input: String, + ) = rest.postForEntity("/v1/decision/rules/$key/evaluate", HttpEntity(input, headers(WRITER)), String::class.java) + + private fun replay(body: String) = rest.postForEntity("/v1/decision/replay", HttpEntity(body, headers(WRITER)), String::class.java) + + private fun headers(token: String): HttpHeaders = + HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + setBearerAuth(token) + } + + private fun newKey(): String = "rule-${UUID.randomUUID()}" + + companion object { + private const val WRITER = "writer" + private const val READER = "reader" + private const val EXPIRY_SECONDS = 300L + private const val REDOS_REPEATS = 25 + + @JvmStatic + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl } + registry.add("spring.datasource.username") { PostgresContainerExtension.username } + registry.add("spring.datasource.password") { PostgresContainerExtension.password } + registry.add("spring.jpa.hibernate.ddl-auto") { "none" } + registry.add("fincore.decision.api.evaluation-timeout-millis") { "100" } + } + } +} diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/api/ReplayController.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/api/ReplayController.kt new file mode 100644 index 0000000..33d58ff --- /dev/null +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/api/ReplayController.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.decision.store.api.dto.request.ReplayRequest +import com.fincore.decision.store.api.dto.response.ReplayReportResponse +import com.fincore.decision.store.api.mapper.DecisionApiMapper +import com.fincore.decision.store.application.ReplayService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Decision replay", description = "Diff a candidate ruleset against recorded decisions for historical inputs") +@RestController +@RequestMapping("/v1/decision/replay") +class ReplayController( + private val replayService: ReplayService, + private val mapper: DecisionApiMapper, + private val objectMapper: ObjectMapper, +) { + @Operation( + summary = "Replay a candidate ruleset", + description = "Evaluates a candidate against each input and diffs the outcome against the recorded decision. Writes nothing.", + ) + @PostMapping + fun replay( + @Valid @RequestBody request: ReplayRequest, + ): ReplayReportResponse = mapper.toResponse(replayService.replay(objectMapper.writeValueAsString(request.candidate), request.inputs)) +} diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/api/dto/request/ReplayRequest.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/api/dto/request/ReplayRequest.kt new file mode 100644 index 0000000..f3474b1 --- /dev/null +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/api/dto/request/ReplayRequest.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.api.dto.request + +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import com.fasterxml.jackson.databind.JsonNode +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull + +data class ReplayRequest( + @field:NotNull + @JsonSetter(nulls = Nulls.FAIL) + val candidate: JsonNode, + @field:NotEmpty + @JsonSetter(contentNulls = Nulls.FAIL) + val inputs: List>, +) diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/api/dto/response/ReplayReportResponse.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/api/dto/response/ReplayReportResponse.kt new file mode 100644 index 0000000..9649e9a --- /dev/null +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/api/dto/response/ReplayReportResponse.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.api.dto.response + +data class ReplayReportResponse( + val total: Int, + val unchanged: Int, + val changed: Int, + val noBaseline: Int, + val diffs: List, +) + +data class ReplayDiffResponse( + val inputHash: String, + val recorded: OutcomeSummaryResponse?, + val candidate: OutcomeSummaryResponse, + val status: String, +) + +data class OutcomeSummaryResponse( + val matched: Boolean, + val outcomeLabel: String?, +) diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/api/mapper/DecisionApiMapper.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/api/mapper/DecisionApiMapper.kt index 41e4660..6c391a7 100644 --- a/services/decision/src/main/kotlin/com/fincore/decision/store/api/mapper/DecisionApiMapper.kt +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/api/mapper/DecisionApiMapper.kt @@ -8,11 +8,16 @@ import com.fincore.decision.store.api.dto.response.ActiveVersionResponse import com.fincore.decision.store.api.dto.response.DecisionLogResponse import com.fincore.decision.store.api.dto.response.DecisionResponse import com.fincore.decision.store.api.dto.response.OutcomeResponse +import com.fincore.decision.store.api.dto.response.OutcomeSummaryResponse +import com.fincore.decision.store.api.dto.response.ReplayDiffResponse +import com.fincore.decision.store.api.dto.response.ReplayReportResponse import com.fincore.decision.store.api.dto.response.RuleDetailResponse import com.fincore.decision.store.api.dto.response.RuleResponse import com.fincore.decision.store.api.dto.response.VersionResponse import com.fincore.decision.store.application.DecisionLogView import com.fincore.decision.store.application.EvaluationOutcome +import com.fincore.decision.store.application.ReplayDiff +import com.fincore.decision.store.application.ReplayReport import com.fincore.decision.store.application.RuleDetailView import com.fincore.decision.store.application.RuleView import com.fincore.decision.store.application.VersionView @@ -56,4 +61,21 @@ class DecisionApiMapper( matched = view.matched, outcomeLabel = view.outcomeLabel, ) + + fun toResponse(report: ReplayReport): ReplayReportResponse = + ReplayReportResponse( + total = report.total, + unchanged = report.unchanged, + changed = report.changed, + noBaseline = report.noBaseline, + diffs = report.diffs.map(::toDiffResponse), + ) + + private fun toDiffResponse(diff: ReplayDiff): ReplayDiffResponse = + ReplayDiffResponse( + inputHash = diff.inputHash, + recorded = diff.recordedMatched?.let { OutcomeSummaryResponse(it, diff.recordedLabel) }, + candidate = OutcomeSummaryResponse(diff.candidateMatched, diff.candidateLabel), + status = diff.status.name, + ) } diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/application/EvaluationServiceImpl.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/application/EvaluationServiceImpl.kt index 1c9ed1a..1fa1c17 100644 --- a/services/decision/src/main/kotlin/com/fincore/decision/store/application/EvaluationServiceImpl.kt +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/application/EvaluationServiceImpl.kt @@ -4,15 +4,7 @@ package com.fincore.decision.store.application import com.fasterxml.jackson.databind.JsonNode -import com.fincore.decision.domain.AttrValue -import com.fincore.decision.domain.BoolValue -import com.fincore.decision.domain.DecimalValue -import com.fincore.decision.domain.EvaluationInput -import com.fincore.decision.domain.StringValue import com.fincore.decision.parser.RuleParser -import com.fincore.decision.store.config.DecisionApiProperties -import com.fincore.decision.store.exception.InputNotMappableException -import com.fincore.decision.store.exception.InputTooLargeException import com.fincore.decision.store.exception.RuleNotActiveException import com.fincore.decision.store.exception.RuleNotFoundException import com.fincore.decision.store.persistence.DecisionRuleRepository @@ -28,8 +20,8 @@ class EvaluationServiceImpl( private val ruleParser: RuleParser, private val boundedEvaluator: BoundedEvaluator, private val inputHasher: InputHasher, + private val inputMapper: InputMapper, private val logWriter: DecisionLogWriter, - private val properties: DecisionApiProperties, ) : EvaluationService { @Transactional override fun evaluate( @@ -38,7 +30,7 @@ class EvaluationServiceImpl( ): EvaluationOutcome { val version = loadActiveVersion(ruleKey) val rule = ruleParser.parse(version.dsl) - val input = toEvaluationInput(attributes) + val input = inputMapper.toEvaluationInput(attributes) val hash = inputHasher.hash(input) val result = boundedEvaluator.evaluate(rule, input) val logId = logWriter.write(version.id, hash, result) @@ -50,22 +42,4 @@ class EvaluationServiceImpl( val versionId = rule.activeVersionId ?: throw RuleNotActiveException(ruleKey) return versionRepository.findById(versionId).orElseThrow { RuleNotActiveException(ruleKey) } } - - private fun toEvaluationInput(attributes: Map): EvaluationInput { - if (attributes.size > properties.maxInputAttributes) throw InputTooLargeException(properties.maxInputAttributes) - return EvaluationInput(attributes.mapValues { (_, node) -> toAttrValue(node) }) - } - - private fun toAttrValue(node: JsonNode): AttrValue = - when { - node.isTextual -> StringValue(boundedText(node.textValue())) - node.isBoolean -> BoolValue(node.booleanValue()) - node.isNumber -> DecimalValue(node.decimalValue()) - else -> throw InputNotMappableException() - } - - private fun boundedText(value: String): String { - if (value.length > properties.maxInputValueChars) throw InputTooLargeException(properties.maxInputValueChars) - return value - } } diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/application/InputMapper.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/application/InputMapper.kt new file mode 100644 index 0000000..4020a57 --- /dev/null +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/application/InputMapper.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.application + +import com.fasterxml.jackson.databind.JsonNode +import com.fincore.decision.domain.AttrValue +import com.fincore.decision.domain.BoolValue +import com.fincore.decision.domain.DecimalValue +import com.fincore.decision.domain.EvaluationInput +import com.fincore.decision.domain.StringValue +import com.fincore.decision.store.config.DecisionApiProperties +import com.fincore.decision.store.exception.InputNotMappableException +import com.fincore.decision.store.exception.InputTooLargeException +import org.springframework.stereotype.Component + +/** + * Maps a JSON input object to typed evaluation attributes, enforcing the attribute-count and per-value + * string-length caps before the input reaches the engine. Shared by the evaluate and replay paths so the + * caps are defined once. + */ +@Component +class InputMapper( + private val properties: DecisionApiProperties, +) { + fun toEvaluationInput(attributes: Map): EvaluationInput { + if (attributes.size > properties.maxInputAttributes) throw InputTooLargeException(properties.maxInputAttributes) + return EvaluationInput(attributes.mapValues { (_, node) -> toAttrValue(node) }) + } + + private fun toAttrValue(node: JsonNode): AttrValue = + when { + node.isTextual -> StringValue(boundedText(node.textValue())) + node.isBoolean -> BoolValue(node.booleanValue()) + node.isNumber -> DecimalValue(node.decimalValue()) + else -> throw InputNotMappableException() + } + + private fun boundedText(value: String): String { + if (value.length > properties.maxInputValueChars) throw InputTooLargeException(properties.maxInputValueChars) + return value + } +} diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/application/ReplayService.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/application/ReplayService.kt new file mode 100644 index 0000000..6419a9d --- /dev/null +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/application/ReplayService.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.application + +import com.fasterxml.jackson.databind.JsonNode + +interface ReplayService { + fun replay( + candidateDsl: String, + inputs: List>, + ): ReplayReport +} + +enum class DiffStatus { UNCHANGED, CHANGED, NO_BASELINE } + +data class ReplayReport( + val total: Int, + val unchanged: Int, + val changed: Int, + val noBaseline: Int, + val diffs: List, +) + +data class ReplayDiff( + val inputHash: String, + val recordedMatched: Boolean?, + val recordedLabel: String?, + val candidateMatched: Boolean, + val candidateLabel: String?, + val status: DiffStatus, +) diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/application/ReplayServiceImpl.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/application/ReplayServiceImpl.kt new file mode 100644 index 0000000..d2d2232 --- /dev/null +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/application/ReplayServiceImpl.kt @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.application + +import com.fasterxml.jackson.databind.JsonNode +import com.fincore.decision.domain.DecisionDslException +import com.fincore.decision.domain.DecisionRule +import com.fincore.decision.parser.RuleParser +import com.fincore.decision.store.config.DecisionApiProperties +import com.fincore.decision.store.exception.DslTooLargeException +import com.fincore.decision.store.exception.InputTooLargeException +import com.fincore.decision.store.exception.InvalidRuleDslException +import com.fincore.decision.store.persistence.DecisionLogEntity +import com.fincore.decision.store.persistence.DecisionLogRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ReplayServiceImpl( + private val ruleParser: RuleParser, + private val boundedEvaluator: BoundedEvaluator, + private val inputMapper: InputMapper, + private val inputHasher: InputHasher, + private val decisionLogRepository: DecisionLogRepository, + private val properties: DecisionApiProperties, +) : ReplayService { + @Transactional(readOnly = true) + override fun replay( + candidateDsl: String, + inputs: List>, + ): ReplayReport { + if (inputs.size > properties.maxReplayInputs) throw InputTooLargeException(properties.maxReplayInputs) + val candidate = validate(candidateDsl) + return report(inputs.map { diffOne(candidate, it) }) + } + + private fun validate(candidateDsl: String): DecisionRule { + if (candidateDsl.length > properties.maxDslChars) throw DslTooLargeException(properties.maxDslChars) + return try { + ruleParser.parse(candidateDsl) + } catch (ex: DecisionDslException) { + throw InvalidRuleDslException(ex.code, ex.message, ex) + } + } + + private fun diffOne( + candidate: DecisionRule, + attributes: Map, + ): ReplayDiff { + val input = inputMapper.toEvaluationInput(attributes) + val hash = inputHasher.hash(input) + val recorded = decisionLogRepository.findFirstByInputHashOrderByEvaluatedAtDesc(hash) + val result = boundedEvaluator.evaluate(candidate, input) + val candidateLabel = result.outcome?.label + return ReplayDiff( + hash, + recorded?.matched, + recorded?.outcomeLabel, + result.matched, + candidateLabel, + statusOf(recorded, result.matched, candidateLabel), + ) + } + + private fun statusOf( + recorded: DecisionLogEntity?, + candidateMatched: Boolean, + candidateLabel: String?, + ): DiffStatus = + when { + recorded == null -> DiffStatus.NO_BASELINE + recorded.matched == candidateMatched && recorded.outcomeLabel == candidateLabel -> DiffStatus.UNCHANGED + else -> DiffStatus.CHANGED + } + + private fun report(diffs: List): ReplayReport = + ReplayReport( + total = diffs.size, + unchanged = diffs.count { it.status == DiffStatus.UNCHANGED }, + changed = diffs.count { it.status == DiffStatus.CHANGED }, + noBaseline = diffs.count { it.status == DiffStatus.NO_BASELINE }, + diffs = diffs, + ) +} diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/config/DecisionApiProperties.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/config/DecisionApiProperties.kt index 963bf03..b4a66fb 100644 --- a/services/decision/src/main/kotlin/com/fincore/decision/store/config/DecisionApiProperties.kt +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/config/DecisionApiProperties.kt @@ -12,6 +12,7 @@ data class DecisionApiProperties( val maxInputAttributes: Int = DEFAULT_MAX_INPUT_ATTRIBUTES, val maxInputValueChars: Int = DEFAULT_MAX_INPUT_VALUE_CHARS, val maxLogPageSize: Int = DEFAULT_MAX_LOG_PAGE_SIZE, + val maxReplayInputs: Int = DEFAULT_MAX_REPLAY_INPUTS, ) { private companion object { const val DEFAULT_MAX_DSL_CHARS = 8192 @@ -19,5 +20,6 @@ data class DecisionApiProperties( const val DEFAULT_MAX_INPUT_ATTRIBUTES = 64 const val DEFAULT_MAX_INPUT_VALUE_CHARS = 4096 const val DEFAULT_MAX_LOG_PAGE_SIZE = 100 + const val DEFAULT_MAX_REPLAY_INPUTS = 200 } } diff --git a/services/decision/src/main/kotlin/com/fincore/decision/store/persistence/DecisionLogRepository.kt b/services/decision/src/main/kotlin/com/fincore/decision/store/persistence/DecisionLogRepository.kt index 809b858..7732530 100644 --- a/services/decision/src/main/kotlin/com/fincore/decision/store/persistence/DecisionLogRepository.kt +++ b/services/decision/src/main/kotlin/com/fincore/decision/store/persistence/DecisionLogRepository.kt @@ -21,4 +21,6 @@ interface DecisionLogRepository : JpaRepository { inputHash: String, pageable: Pageable, ): List + + fun findFirstByInputHashOrderByEvaluatedAtDesc(inputHash: String): DecisionLogEntity? } diff --git a/services/decision/src/main/resources/application.yml b/services/decision/src/main/resources/application.yml index 5d62443..6f87fc0 100644 --- a/services/decision/src/main/resources/application.yml +++ b/services/decision/src/main/resources/application.yml @@ -25,3 +25,4 @@ fincore: max-input-attributes: 64 max-input-value-chars: 4096 max-log-page-size: 100 + max-replay-inputs: 200 diff --git a/services/decision/src/test/kotlin/com/fincore/decision/store/api/ReplayControllerTest.kt b/services/decision/src/test/kotlin/com/fincore/decision/store/api/ReplayControllerTest.kt new file mode 100644 index 0000000..b8f1a0e --- /dev/null +++ b/services/decision/src/test/kotlin/com/fincore/decision/store/api/ReplayControllerTest.kt @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.api + +import com.fincore.decision.domain.DslErrorCode +import com.fincore.decision.store.api.mapper.DecisionApiMapper +import com.fincore.decision.store.application.DiffStatus +import com.fincore.decision.store.application.ReplayDiff +import com.fincore.decision.store.application.ReplayReport +import com.fincore.decision.store.application.ReplayService +import com.fincore.decision.store.config.SecurityConfig +import com.fincore.decision.store.exception.GlobalExceptionHandler +import com.fincore.decision.store.exception.InvalidRuleDslException +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(ReplayController::class) +@Import(SecurityConfig::class, DecisionApiMapper::class, GlobalExceptionHandler::class, ReplayControllerTest.Mocks::class) +class ReplayControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val replayService: ReplayService, +) { + private val candidate = """{"condition":{"attr":"amount","op":"gte","value":100},"outcome":{"label":"approve"}}""" + private val body = """{"candidate":$candidate,"inputs":[{"amount":150}]}""" + private val report = ReplayReport(1, 0, 1, 0, listOf(ReplayDiff("h", true, "approve", false, null, DiffStatus.CHANGED))) + + @TestConfiguration + class Mocks { + @Bean fun replayService(): ReplayService = mockk() + + @Bean fun jwtDecoder(): JwtDecoder = mockk() + } + + @BeforeEach + fun resetMocks() { + clearMocks(replayService) + } + + @Test + fun `should reject an unauthenticated replay with 401`() { + mockMvc + .perform(post("/v1/decision/replay").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isUnauthorized) + } + + @Test + fun `should reject a replay carrying only the read scope with 403`() { + mockMvc + .perform( + post("/v1/decision/replay") + .with(jwt().authorities(SimpleGrantedAuthority(SCOPE_READ))) + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ).andExpect(status().isForbidden) + } + + @Test + fun `should return the diff report when the write scope is present`() { + every { replayService.replay(any(), any()) } returns report + + mockMvc + .perform( + post("/v1/decision/replay") + .with(jwt().authorities(SimpleGrantedAuthority(SCOPE_WRITE))) + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.changed").value(1)) + .andExpect(jsonPath("$.diffs[0].status").value("CHANGED")) + .andExpect(jsonPath("$.diffs[0].recorded.matched").value(true)) + .andExpect(jsonPath("$.diffs[0].candidate.matched").value(false)) + } + + @Test + fun `should reject a null input element with 400 rather than 500`() { + mockMvc + .perform( + post("/v1/decision/replay") + .with(jwt().authorities(SimpleGrantedAuthority(SCOPE_WRITE))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"candidate":$candidate,"inputs":[null]}"""), + ).andExpect(status().isBadRequest) + } + + @Test + fun `should reject a null candidate with 400 rather than 500`() { + mockMvc + .perform( + post("/v1/decision/replay") + .with(jwt().authorities(SimpleGrantedAuthority(SCOPE_WRITE))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"candidate":null,"inputs":[{"amount":1}]}"""), + ).andExpect(status().isBadRequest) + } + + @Test + fun `should map an invalid candidate to a 422 problem document`() { + every { replayService.replay(any(), any()) } throws InvalidRuleDslException(DslErrorCode.UNKNOWN_OPERATOR, "unknown operator") + + mockMvc + .perform( + post("/v1/decision/replay") + .with(jwt().authorities(SimpleGrantedAuthority(SCOPE_WRITE))) + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ).andExpect(status().isUnprocessableEntity) + .andExpect(content().contentTypeCompatibleWith(PROBLEM_JSON)) + .andExpect(jsonPath("$.code").value("INVALID_DSL")) + } + + private companion object { + const val SCOPE_READ = "SCOPE_decision:read" + const val SCOPE_WRITE = "SCOPE_decision:write" + const val PROBLEM_JSON = "application/problem+json" + } +} diff --git a/services/decision/src/test/kotlin/com/fincore/decision/store/application/EvaluationServiceImplTest.kt b/services/decision/src/test/kotlin/com/fincore/decision/store/application/EvaluationServiceImplTest.kt index 76b8e78..0b0d573 100644 --- a/services/decision/src/test/kotlin/com/fincore/decision/store/application/EvaluationServiceImplTest.kt +++ b/services/decision/src/test/kotlin/com/fincore/decision/store/application/EvaluationServiceImplTest.kt @@ -14,8 +14,6 @@ import com.fincore.decision.domain.EvaluationInput import com.fincore.decision.parser.RuleParser import com.fincore.decision.store.config.DecisionApiProperties import com.fincore.decision.store.exception.EvaluationTimeoutException -import com.fincore.decision.store.exception.InputNotMappableException -import com.fincore.decision.store.exception.InputTooLargeException import com.fincore.decision.store.exception.RuleNotActiveException import com.fincore.decision.store.exception.RuleNotFoundException import com.fincore.decision.store.persistence.DecisionRuleEntity @@ -41,7 +39,15 @@ class EvaluationServiceImplTest { private val matched = DecisionResult(true, DecisionOutcome("approve", listOf("LOW_RISK")), listOf(ConditionTrace("amount gte", true))) private fun service(props: DecisionApiProperties = DecisionApiProperties()) = - EvaluationServiceImpl(ruleRepository, versionRepository, RuleParser(), boundedEvaluator, InputHasher(mapper), logWriter, props) + EvaluationServiceImpl( + ruleRepository, + versionRepository, + RuleParser(), + boundedEvaluator, + InputHasher(mapper), + InputMapper(props), + logWriter, + ) private fun attrs(json: String): Map = (mapper.readTree(json) as ObjectNode).fields().asSequence().associate { it.key to it.value } @@ -80,41 +86,6 @@ class EvaluationServiceImplTest { shouldThrow { service().evaluate("k", attrs("""{"amount":1}""")) } } - @Test - fun `should reject an input with a nested object value`() { - val versionId = UUID.randomUUID() - every { ruleRepository.findByRuleKey("k") } returns activeRule(versionId) - every { versionRepository.findById(versionId) } returns - Optional.of(RuleVersionEntity(id = versionId, ruleId = UUID.randomUUID(), versionNo = 1, dsl = validDsl)) - - shouldThrow { service().evaluate("k", attrs("""{"amount":{"x":1}}""")) } - verify(exactly = 0) { logWriter.write(any(), any(), any()) } - } - - @Test - fun `should reject an input with too many attributes`() { - val versionId = UUID.randomUUID() - every { ruleRepository.findByRuleKey("k") } returns activeRule(versionId) - every { versionRepository.findById(versionId) } returns - Optional.of(RuleVersionEntity(id = versionId, ruleId = UUID.randomUUID(), versionNo = 1, dsl = validDsl)) - - shouldThrow { - service(DecisionApiProperties(maxInputAttributes = 1)).evaluate("k", attrs("""{"a":1,"b":2}""")) - } - } - - @Test - fun `should reject an input value over the length cap`() { - val versionId = UUID.randomUUID() - every { ruleRepository.findByRuleKey("k") } returns activeRule(versionId) - every { versionRepository.findById(versionId) } returns - Optional.of(RuleVersionEntity(id = versionId, ruleId = UUID.randomUUID(), versionNo = 1, dsl = validDsl)) - - shouldThrow { - service(DecisionApiProperties(maxInputValueChars = 2)).evaluate("k", attrs("""{"name":"abc"}""")) - } - } - @Test fun `should not write a log when evaluation times out`() { val versionId = UUID.randomUUID() diff --git a/services/decision/src/test/kotlin/com/fincore/decision/store/application/InputMapperTest.kt b/services/decision/src/test/kotlin/com/fincore/decision/store/application/InputMapperTest.kt new file mode 100644 index 0000000..5dfaf21 --- /dev/null +++ b/services/decision/src/test/kotlin/com/fincore/decision/store/application/InputMapperTest.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.application + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.decision.domain.BoolValue +import com.fincore.decision.domain.DecimalValue +import com.fincore.decision.domain.StringValue +import com.fincore.decision.store.config.DecisionApiProperties +import com.fincore.decision.store.exception.InputNotMappableException +import com.fincore.decision.store.exception.InputTooLargeException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.Test + +class InputMapperTest { + private val json = ObjectMapper() + + private fun node(raw: String): JsonNode = json.readTree(raw) + + @Test + fun `should map string number and boolean to typed attribute values`() { + val input = + InputMapper(DecisionApiProperties()).toEvaluationInput( + mapOf("s" to node(""""x""""), "n" to node("1.5"), "b" to node("true")), + ) + + input.get("s").shouldBeInstanceOf().value shouldBe "x" + input.get("n").shouldBeInstanceOf() + input.get("b").shouldBeInstanceOf().value shouldBe true + } + + @Test + fun `should reject a nested object value`() { + shouldThrow { + InputMapper(DecisionApiProperties()).toEvaluationInput(mapOf("o" to node("""{"x":1}"""))) + } + } + + @Test + fun `should reject more attributes than the cap`() { + shouldThrow { + InputMapper(DecisionApiProperties(maxInputAttributes = 1)).toEvaluationInput(mapOf("a" to node("1"), "b" to node("2"))) + } + } + + @Test + fun `should reject a value over the length cap`() { + shouldThrow { + InputMapper(DecisionApiProperties(maxInputValueChars = 2)).toEvaluationInput(mapOf("s" to node(""""abc""""))) + } + } +} diff --git a/services/decision/src/test/kotlin/com/fincore/decision/store/application/ReplayServiceImplTest.kt b/services/decision/src/test/kotlin/com/fincore/decision/store/application/ReplayServiceImplTest.kt new file mode 100644 index 0000000..50714aa --- /dev/null +++ b/services/decision/src/test/kotlin/com/fincore/decision/store/application/ReplayServiceImplTest.kt @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.decision.store.application + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.decision.eval.RuleEvaluator +import com.fincore.decision.parser.RuleParser +import com.fincore.decision.store.config.DecisionApiProperties +import com.fincore.decision.store.exception.DslTooLargeException +import com.fincore.decision.store.exception.InputTooLargeException +import com.fincore.decision.store.exception.InvalidRuleDslException +import com.fincore.decision.store.persistence.DecisionLogEntity +import com.fincore.decision.store.persistence.DecisionLogRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.UUID +import java.util.concurrent.Executors + +class ReplayServiceImplTest { + private val json = ObjectMapper() + private val props = DecisionApiProperties() + private val watchdog = Executors.newSingleThreadScheduledExecutor() + private val repository = mockk() + private val service = + ReplayServiceImpl( + RuleParser(), + BoundedEvaluator(RuleEvaluator(), watchdog, props), + InputMapper(props), + InputHasher(json), + repository, + props, + ) + private val approveAtHundred = """{"condition":{"attr":"amount","op":"gte","value":100},"outcome":{"label":"approve"}}""" + private val approveAtThousand = """{"condition":{"attr":"amount","op":"gte","value":1000},"outcome":{"label":"approve"}}""" + + @AfterEach + fun tearDown() { + watchdog.shutdownNow() + } + + private fun inputs(vararg raw: String): List> = + raw.map { + (json.readTree(it) as com.fasterxml.jackson.databind.node.ObjectNode).fields().asSequence().associate { f -> + f.key to + f.value + } + } + + private fun recorded( + matched: Boolean, + label: String?, + ): DecisionLogEntity = + DecisionLogEntity( + id = UUID.randomUUID(), + evaluatedAt = Instant.now(), + ruleVersionId = UUID.randomUUID(), + inputHash = "h", + matched = matched, + outcomeLabel = label, + trace = "[]", + ) + + @Test + fun `should report changed when the candidate outcome differs from the recorded decision`() { + every { repository.findFirstByInputHashOrderByEvaluatedAtDesc(any()) } returns recorded(true, "approve") + + val report = service.replay(approveAtThousand, inputs("""{"amount":150}""")) + + report.changed shouldBe 1 + report.diffs.single().status shouldBe DiffStatus.CHANGED + } + + @Test + fun `should report unchanged when the candidate reproduces the recorded decision`() { + every { repository.findFirstByInputHashOrderByEvaluatedAtDesc(any()) } returns recorded(true, "approve") + + val report = service.replay(approveAtHundred, inputs("""{"amount":150}""")) + + report.unchanged shouldBe 1 + report.diffs.single().status shouldBe DiffStatus.UNCHANGED + } + + @Test + fun `should report unchanged when both recorded and candidate do not match`() { + every { repository.findFirstByInputHashOrderByEvaluatedAtDesc(any()) } returns recorded(false, null) + + val report = service.replay(approveAtThousand, inputs("""{"amount":50}""")) + + report.diffs.single().status shouldBe DiffStatus.UNCHANGED + } + + @Test + fun `should report no baseline when the input has no recorded decision`() { + every { repository.findFirstByInputHashOrderByEvaluatedAtDesc(any()) } returns null + + val report = service.replay(approveAtHundred, inputs("""{"amount":150}""")) + + report.noBaseline shouldBe 1 + report.diffs.single().status shouldBe DiffStatus.NO_BASELINE + } + + @Test + fun `should aggregate counts that sum to the total and never write a row`() { + every { repository.findFirstByInputHashOrderByEvaluatedAtDesc(any()) } returns recorded(true, "approve") + + val report = service.replay(approveAtHundred, inputs("""{"amount":150}""", """{"amount":50}""")) + + report.total shouldBe 2 + (report.unchanged + report.changed + report.noBaseline) shouldBe report.total + verify(exactly = 0) { repository.save(any()) } + } + + @Test + fun `should reject an invalid candidate dsl`() { + shouldThrow { service.replay("""{"outcome":{"label":"x"}}""", inputs("""{"amount":1}""")) } + } + + @Test + fun `should reject a candidate over the size cap`() { + val tiny = + ReplayServiceImpl( + RuleParser(), + BoundedEvaluator(RuleEvaluator(), watchdog, props), + InputMapper(props), + InputHasher(json), + repository, + DecisionApiProperties(maxDslChars = 2), + ) + + shouldThrow { tiny.replay(approveAtHundred, inputs("""{"amount":1}""")) } + } + + @Test + fun `should reject more inputs than the replay cap`() { + val capped = + ReplayServiceImpl( + RuleParser(), + BoundedEvaluator(RuleEvaluator(), watchdog, props), + InputMapper(props), + InputHasher(json), + repository, + DecisionApiProperties(maxReplayInputs = 1), + ) + + shouldThrow { capped.replay(approveAtHundred, inputs("""{"amount":1}""", """{"amount":2}""")) } + } +}