From 3dd9c52df07746486320cfe2460e907329e3ec7b Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 12 Jun 2026 19:11:12 +0400 Subject: [PATCH] =?UTF-8?q?test(transitions,approvals):=20seeded=20fuzz=20?= =?UTF-8?q?=E2=80=94=20graph-confinement,=20reducer=20totality,=20policy-d?= =?UTF-8?q?enial=20terminality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/kotlin/ApprovalFuzzTest.kt | 202 ++++++++++++++++++ .../src/test/kotlin/TransitionFuzzTest.kt | 124 +++++++++++ 2 files changed, 326 insertions(+) create mode 100644 testing/approvals/src/test/kotlin/ApprovalFuzzTest.kt create mode 100644 testing/transitions/src/test/kotlin/TransitionFuzzTest.kt diff --git a/testing/approvals/src/test/kotlin/ApprovalFuzzTest.kt b/testing/approvals/src/test/kotlin/ApprovalFuzzTest.kt new file mode 100644 index 00000000..03c8a1c8 --- /dev/null +++ b/testing/approvals/src/test/kotlin/ApprovalFuzzTest.kt @@ -0,0 +1,202 @@ +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalProjector +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.DefaultApprovalReducer +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ApprovalDecisionResolvedEvent +import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.ApprovalGrantExpiredEvent +import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.ApprovalDecisionId +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.ValidationReportId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail + +class ApprovalFuzzTest { + + private companion object { + const val FUZZ_ITERATIONS = 500 + const val MAX_STEPS = 100 + val SEED: Long = System.getProperty("fuzz.seed")?.toLong() ?: System.currentTimeMillis() + val BASE_TIMESTAMP: Instant = Instant.parse("2026-01-01T00:00:00Z") + val SESSION_ID: SessionId = SessionId("fuzz-session") + } + + private val reducer = DefaultApprovalReducer() + private val projector = ApprovalProjector(reducer) + + @Test + fun `reducer is total and policy-denial is terminal`() { + val rng = kotlin.random.Random(SEED) + repeat(FUZZ_ITERATIONS) { iteration -> + val iterSeed = rng.nextLong() + val sequence1 = randomApprovalSequence(kotlin.random.Random(iterSeed)) + val sequence2 = randomApprovalSequence(kotlin.random.Random(iterSeed)) + + val state1 = runCatching { applySequence(sequence1) }.getOrElse { ex -> + fail( + "Totality violated: reducer threw on a random ordering. " + + "error=${ex.message} seed=$SEED iterSeed=$iterSeed iteration=$iteration" + ) + } + val state2 = runCatching { applySequence(sequence2) }.getOrElse { ex -> + fail( + "Totality violated: reducer threw on the replayed ordering. " + + "error=${ex.message} seed=$SEED iterSeed=$iterSeed iteration=$iteration" + ) + } + + if (state1 != state2) { + fail( + "Determinism violated: same seed produced different final states. " + + "seed=$SEED iterSeed=$iterSeed iteration=$iteration" + ) + } + + assertPolicyDenialTerminal(state1, sequence1, SEED, iterSeed, iteration) + } + } + + private fun applySequence(events: List): com.correx.core.approvals.model.ApprovalState { + var state = projector.initial() + events.forEach { state = projector.apply(state, it) } + return state + } + + // Reducer-level terminality: for each decisionId, the *last* event wins (map put-semantics). + // If the last event for a decisionId carries REJECTED, the final state must show REJECTED — + // not APPROVED. This is the reducer guarantee; cross-requestId sequencing is an engine concern + // (invariant #4) enforced above the reducer layer. + private fun assertPolicyDenialTerminal( + state: com.correx.core.approvals.model.ApprovalState, + events: List, + seed: Long, + iterSeed: Long, + iteration: Int, + ) { + val lastOutcomeByDecisionId = events + .map { it.payload } + .filterIsInstance() + .groupBy { it.decisionId } + .mapValues { (_, decisions) -> decisions.last().outcome } + + lastOutcomeByDecisionId.forEach { (decisionId, lastOutcome) -> + if (lastOutcome == ApprovalOutcome.REJECTED) { + val finalDecision = state.decisions[decisionId] + if (finalDecision != null && finalDecision.isApproved) { + fail( + "Denial terminality violated: decision $decisionId last seen REJECTED " + + "but final state is approved. " + + "seed=$seed iterSeed=$iterSeed iteration=$iteration" + ) + } + } + } + } + + private fun randomApprovalSequence(rng: kotlin.random.Random): List { + val requestCount = rng.nextInt(1, 5) + val requestIds = (0 until requestCount).map { ApprovalRequestId("req-$it-${rng.nextInt(1000)}") } + val payloads = mutableListOf() + + requestIds.forEach { requestId -> + payloads.add(randomRequestedEvent(requestId, rng)) + } + + val extraPayloadCount = rng.nextInt(0, requestCount * 2 + 1) + repeat(extraPayloadCount) { + payloads.add(randomExtraPayload(requestIds, rng)) + } + + payloads.shuffle(java.util.Random(rng.nextLong())) + + return payloads.take(MAX_STEPS).mapIndexed { idx, payload -> + storedEvent(payload, idx + 1L) + } + } + + private fun randomRequestedEvent(requestId: ApprovalRequestId, rng: kotlin.random.Random): ApprovalRequestedEvent { + val tiers = Tier.entries.toList() + return ApprovalRequestedEvent( + requestId = requestId, + tier = tiers[rng.nextInt(tiers.size)], + validationReportId = ValidationReportId("vr-${rng.nextInt(1000)}"), + riskSummaryId = null, + sessionId = SESSION_ID, + stageId = null, + projectId = null, + ) + } + + private fun randomExtraPayload(requestIds: List, rng: kotlin.random.Random): EventPayload { + val requestId = requestIds[rng.nextInt(requestIds.size)] + return when (rng.nextInt(4)) { + 0 -> rejectedDecisionEvent(requestId, rng) + 1 -> approvedDecisionEvent(requestId, rng) + 2 -> createdGrantEvent(rng) + else -> expiredGrantEvent(rng) + } + } + + private fun rejectedDecisionEvent(requestId: ApprovalRequestId, rng: kotlin.random.Random): ApprovalDecisionResolvedEvent = + ApprovalDecisionResolvedEvent( + decisionId = ApprovalDecisionId("dec-rejected-${rng.nextInt(10000)}"), + requestId = requestId, + outcome = ApprovalOutcome.REJECTED, + status = ApprovalStatus.COMPLETED, + tier = Tier.T2, + resolutionTimestamp = BASE_TIMESTAMP, + reason = "policy denial", + ) + + private fun approvedDecisionEvent(requestId: ApprovalRequestId, rng: kotlin.random.Random): ApprovalDecisionResolvedEvent = + ApprovalDecisionResolvedEvent( + decisionId = ApprovalDecisionId("dec-approved-${rng.nextInt(10000)}"), + requestId = requestId, + outcome = ApprovalOutcome.APPROVED, + status = ApprovalStatus.COMPLETED, + tier = Tier.T2, + resolutionTimestamp = BASE_TIMESTAMP, + reason = null, + ) + + private fun createdGrantEvent(rng: kotlin.random.Random): ApprovalGrantCreatedEvent = + ApprovalGrantCreatedEvent( + grantId = GrantId("grant-${rng.nextInt(10000)}"), + scope = GrantScope.SESSION(toolName = "shell"), + permittedTiers = setOf(Tier.entries[rng.nextInt(Tier.entries.size)]), + reason = "fuzz grant", + expiresAt = null, + sessionId = SESSION_ID, + stageId = null, + projectId = null, + toolName = "shell", + ) + + private fun expiredGrantEvent(rng: kotlin.random.Random): ApprovalGrantExpiredEvent = + ApprovalGrantExpiredEvent(grantId = GrantId("grant-${rng.nextInt(10000)}")) + + private fun storedEvent(payload: EventPayload, sequence: Long): StoredEvent = + StoredEvent( + metadata = EventMetadata( + eventId = EventId("event-$sequence"), + sessionId = SESSION_ID, + timestamp = BASE_TIMESTAMP, + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = sequence, + sessionSequence = sequence, + payload = payload, + ) +} diff --git a/testing/transitions/src/test/kotlin/TransitionFuzzTest.kt b/testing/transitions/src/test/kotlin/TransitionFuzzTest.kt new file mode 100644 index 00000000..bd7164e0 --- /dev/null +++ b/testing/transitions/src/test/kotlin/TransitionFuzzTest.kt @@ -0,0 +1,124 @@ +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.transitions.evaluation.EvaluationContext +import com.correx.core.transitions.evaluation.TransitionConditionEvaluator +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionCondition +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.DefaultTransitionResolver +import com.correx.core.transitions.resolution.TransitionDecision +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail + +class TransitionFuzzTest { + + private companion object { + const val FUZZ_ITERATIONS = 500 + const val MAX_STEPS = 100 + val SEED: Long = System.getProperty("fuzz.seed")?.toLong() ?: System.currentTimeMillis() + } + + private val evaluator = TransitionConditionEvaluator { condition, context -> condition.evaluate(context) } + private val resolver = DefaultTransitionResolver(evaluator) + + @Test + fun `graph-walk stays within declared edges and terminates`() { + val rng = kotlin.random.Random(SEED) + repeat(FUZZ_ITERATIONS) { iteration -> + val iterSeed = rng.nextLong() + val graph = randomGraph(kotlin.random.Random(iterSeed)) + val path1 = walk(graph, kotlin.random.Random(iterSeed)) + val path2 = walk(graph, kotlin.random.Random(iterSeed)) + val declaredEdgeIds = graph.transitions.map { it.id }.toSet() + + path1.forEach { transitionId -> + if (transitionId !in declaredEdgeIds) { + fail( + "Invariant 1 violated: transition $transitionId not in declared graph. " + + "seed=$SEED iterSeed=$iterSeed iteration=$iteration" + ) + } + } + + if (path1.size >= MAX_STEPS) { + fail( + "Invariant 2 violated: walk did not terminate within $MAX_STEPS steps. " + + "seed=$SEED iterSeed=$iterSeed iteration=$iteration" + ) + } + + if (path1 != path2) { + fail( + "Invariant 3 violated: same seed produced different paths. " + + "path1=$path1 path2=$path2 seed=$SEED iterSeed=$iterSeed iteration=$iteration" + ) + } + } + } + + private fun walk(graph: WorkflowGraph, rng: kotlin.random.Random): List { + val path = mutableListOf() + var currentStage = graph.start + repeat(MAX_STEPS) { + val context = randomContext(currentStage, rng) + val graphWithConditions = randomizeConditions(graph, rng) + val decision = resolver.resolve(graphWithConditions, context) + if (decision is TransitionDecision.Move) { + path.add(decision.transitionId) + currentStage = decision.to + } else { + return path + } + } + return path + } + + private fun randomGraph(rng: kotlin.random.Random): WorkflowGraph { + val stageCount = rng.nextInt(3, 9) + val stageIds = (0 until stageCount).map { StageId("stage-$it") } + val stages = stageIds.associateWith { StageConfig() } + val start = stageIds.first() + + val edgeCount = rng.nextInt(stageCount, stageCount * 2 + 1) + val edges = mutableSetOf() + repeat(edgeCount) { idx -> + val from = stageIds[rng.nextInt(stageIds.size)] + val to = stageIds[rng.nextInt(stageIds.size)] + edges.add( + TransitionEdge( + id = TransitionId("t-$idx"), + from = from, + to = to, + condition = TransitionCondition { true }, + ) + ) + } + + return WorkflowGraph( + id = "fuzz-graph", + start = start, + stages = stages, + transitions = edges, + ) + } + + private fun randomizeConditions(graph: WorkflowGraph, rng: kotlin.random.Random): WorkflowGraph { + val newEdges = graph.transitions.map { edge -> + val outcome = rng.nextBoolean() + edge.copy(condition = TransitionCondition { outcome }) + }.toSet() + return graph.copy(transitions = newEdges) + } + + private fun randomContext(currentStage: StageId, rng: kotlin.random.Random): EvaluationContext { + val varCount = rng.nextInt(0, 4) + val variables = (0 until varCount).associate { "var$it" to rng.nextInt(100).toString() } + return EvaluationContext( + sessionId = SessionId("fuzz-session"), + currentStage = currentStage, + variables = variables, + ) + } +}