test(transitions,approvals): seeded fuzz — graph-confinement, reducer totality, policy-denial terminality
This commit is contained in:
@@ -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<StoredEvent>): 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<StoredEvent>,
|
||||||
|
seed: Long,
|
||||||
|
iterSeed: Long,
|
||||||
|
iteration: Int,
|
||||||
|
) {
|
||||||
|
val lastOutcomeByDecisionId = events
|
||||||
|
.map { it.payload }
|
||||||
|
.filterIsInstance<ApprovalDecisionResolvedEvent>()
|
||||||
|
.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<StoredEvent> {
|
||||||
|
val requestCount = rng.nextInt(1, 5)
|
||||||
|
val requestIds = (0 until requestCount).map { ApprovalRequestId("req-$it-${rng.nextInt(1000)}") }
|
||||||
|
val payloads = mutableListOf<EventPayload>()
|
||||||
|
|
||||||
|
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<ApprovalRequestId>, 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TransitionId> {
|
||||||
|
val path = mutableListOf<TransitionId>()
|
||||||
|
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<TransitionEdge>()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user