diff --git a/.gitignore b/.gitignore index f9815c5..13a8666 100644 --- a/.gitignore +++ b/.gitignore @@ -66,5 +66,8 @@ frontend/**/pnpm-lock.yaml # Git worktrees .worktrees/ +# Jazzer fuzzing corpus (regenerierbar, nicht committen) +.cifuzz-corpus/ + # Legacy bin directory bin/ diff --git a/backend/pom.xml b/backend/pom.xml index b5ed8df..fe8db07 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -32,6 +32,7 @@ 0.8.12 1.17.4 1.2.1 + 0.24.0 @@ -120,6 +121,13 @@ h2 test + + + com.code-intelligence + jazzer-junit + ${jazzer.version} + test + @@ -147,6 +155,20 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*FuzzTest.java + + + -XX:+EnableDynamicAgentLoading -noverify + + + org.jacoco jacoco-maven-plugin @@ -176,6 +198,52 @@ + + fuzz + + + + org.apache.maven.plugins + maven-surefire-plugin + + + none + + + **/*FuzzTest.java + + false + -XX:+EnableDynamicAgentLoading -noverify + + 1 + + + + + + + + + fuzz-regression + + + + org.apache.maven.plugins + maven-surefire-plugin + + + none + + + **/*FuzzTest.java + + -XX:+EnableDynamicAgentLoading -noverify + + + + + + mutation diff --git a/backend/src/test/java/de/effigenix/domain/production/BatchFuzzTest.java b/backend/src/test/java/de/effigenix/domain/production/BatchFuzzTest.java new file mode 100644 index 0000000..505d66d --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/BatchFuzzTest.java @@ -0,0 +1,79 @@ +package de.effigenix.domain.production; + +import com.code_intelligence.jazzer.api.FuzzedDataProvider; +import com.code_intelligence.jazzer.junit.FuzzTest; +import de.effigenix.shared.common.Result; + +import java.time.LocalDate; + +/** + * Fuzz test for the Batch aggregate. + * + * Exercises plan() with fuzzed drafts, then applies a random sequence of + * state-mutating operations (startProduction, recordConsumption, complete, cancel). + * Verifies that no input combination causes an unhandled exception – + * all invalid inputs must be caught and returned as Result.Failure. + * + * Run: make fuzz | make fuzz/single TEST=BatchFuzzTest + */ +class BatchFuzzTest { + + @FuzzTest(maxDuration = "5m") + void fuzzBatch(FuzzedDataProvider data) { + var draft = new BatchDraft( + data.consumeString(50), + data.consumeString(30), + data.consumeString(20), + consumeLocalDate(data), + consumeLocalDate(data) + ); + + BatchNumber batchNumber; + try { + batchNumber = BatchNumber.generate(LocalDate.now(), 1); + } catch (Exception e) { + return; + } + + switch (Batch.plan(draft, batchNumber)) { + case Result.Failure(var err) -> { } + case Result.Success(var batch) -> { + int ops = data.consumeInt(1, 10); + for (int i = 0; i < ops; i++) { + int op = data.consumeInt(0, 3); + switch (op) { + case 0 -> batch.startProduction(); + case 1 -> { + batch.recordConsumption(new ConsumptionDraft( + data.consumeString(50), + data.consumeString(50), + data.consumeString(30), + data.consumeString(20) + )); + } + case 2 -> { + batch.complete(new CompleteBatchDraft( + data.consumeString(30), + data.consumeString(20), + data.consumeString(30), + data.consumeString(20), + data.consumeString(100) + )); + } + case 3 -> batch.cancel(new CancelBatchDraft(data.consumeString(600))); + } + } + } + } + } + + private static LocalDate consumeLocalDate(FuzzedDataProvider data) { + if (data.consumeBoolean()) { + return null; + } + int year = data.consumeInt(1900, 2100); + int month = data.consumeInt(1, 12); + int day = data.consumeInt(1, 28); + return LocalDate.of(year, month, day); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderFuzzTest.java b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderFuzzTest.java new file mode 100644 index 0000000..271bc81 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderFuzzTest.java @@ -0,0 +1,65 @@ +package de.effigenix.domain.production; + +import com.code_intelligence.jazzer.api.FuzzedDataProvider; +import com.code_intelligence.jazzer.junit.FuzzTest; +import de.effigenix.shared.common.Result; + +import java.time.LocalDate; + +/** + * Fuzz test for the ProductionOrder aggregate. + * + * Exercises create() with fuzzed drafts, then applies a random sequence of + * state-mutating operations (release, startProduction, complete, cancel, reschedule). + * Verifies that no input combination causes an unhandled exception – + * all invalid inputs must be caught and returned as Result.Failure. + * + * Run: make fuzz | make fuzz/single TEST=ProductionOrderFuzzTest + */ +class ProductionOrderFuzzTest { + + @FuzzTest(maxDuration = "5m") + void fuzzProductionOrder(FuzzedDataProvider data) { + var draft = new ProductionOrderDraft( + data.consumeString(50), + data.consumeString(30), + data.consumeString(20), + consumeLocalDate(data), + data.consumeString(10), + data.consumeString(100) + ); + + switch (ProductionOrder.create(draft)) { + case Result.Failure(var err) -> { + // Invalid draft – expected + } + case Result.Success(var order) -> { + int ops = data.consumeInt(1, 10); + for (int i = 0; i < ops; i++) { + int op = data.consumeInt(0, 4); + switch (op) { + case 0 -> order.release(); + case 1 -> { + try { + order.startProduction(BatchId.of(data.consumeString(50))); + } catch (Exception ignored) { } + } + case 2 -> order.complete(); + case 3 -> order.cancel(data.consumeString(50)); + case 4 -> order.reschedule(consumeLocalDate(data)); + } + } + } + } + } + + private static LocalDate consumeLocalDate(FuzzedDataProvider data) { + if (data.consumeBoolean()) { + return null; + } + int year = data.consumeInt(1900, 2100); + int month = data.consumeInt(1, 12); + int day = data.consumeInt(1, 28); + return LocalDate.of(year, month, day); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/RecipeFuzzTest.java b/backend/src/test/java/de/effigenix/domain/production/RecipeFuzzTest.java new file mode 100644 index 0000000..bdf20ca --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/RecipeFuzzTest.java @@ -0,0 +1,86 @@ +package de.effigenix.domain.production; + +import com.code_intelligence.jazzer.api.FuzzedDataProvider; +import com.code_intelligence.jazzer.junit.FuzzTest; +import de.effigenix.shared.common.Result; + +/** + * Fuzz test for the Recipe aggregate. + * + * Exercises create() with fuzzed drafts, then applies a random sequence of + * state-mutating operations (addIngredient, addProductionStep, removeIngredient, + * removeProductionStep, activate, archive). + * Verifies that no input combination causes an unhandled exception – + * all invalid inputs must be caught and returned as Result.Failure. + * + * Run: make fuzz | make fuzz/single TEST=RecipeFuzzTest + */ +class RecipeFuzzTest { + + private static final RecipeType[] RECIPE_TYPES = RecipeType.values(); + + @FuzzTest(maxDuration = "5m") + void fuzzRecipe(FuzzedDataProvider data) { + var draft = new RecipeDraft( + data.consumeString(200), + data.consumeInt(0, 100), + data.consumeBoolean() ? data.pickValue(RECIPE_TYPES) : null, + data.consumeString(200), + data.consumeInt(0, 300), + data.consumeBoolean() ? data.consumeInt(-10, 400) : null, + data.consumeString(30), + data.consumeString(20), + data.consumeString(40) + ); + + switch (Recipe.create(draft)) { + case Result.Failure(var err) -> { } + case Result.Success(var recipe) -> { + int ops = data.consumeInt(1, 12); + for (int i = 0; i < ops; i++) { + int op = data.consumeInt(0, 5); + switch (op) { + case 0 -> { + var ingredientDraft = consumeIngredientDraft(data); + if (ingredientDraft != null) recipe.addIngredient(ingredientDraft); + } + case 1 -> { + recipe.addProductionStep(new ProductionStepDraft( + data.consumeInt(), data.consumeString(100), + data.consumeBoolean() ? data.consumeInt() : null, + data.consumeBoolean() ? data.consumeInt() : null + )); + } + case 2 -> recipe.activate(); + case 3 -> recipe.archive(); + case 4 -> { + if (!recipe.ingredients().isEmpty()) { + recipe.removeIngredient(recipe.ingredients().getFirst().id()); + } + } + case 5 -> { + if (!recipe.productionSteps().isEmpty()) { + recipe.removeProductionStep(recipe.productionSteps().getFirst().stepNumber()); + } + } + } + } + } + } + } + + private static IngredientDraft consumeIngredientDraft(FuzzedDataProvider data) { + try { + return new IngredientDraft( + data.consumeInt(), + data.consumeString(50), + data.consumeString(30), + data.consumeString(20), + data.consumeBoolean() ? data.consumeString(40) : null, + data.consumeBoolean() + ); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/makefile b/makefile index 33e9114..9b5c28c 100644 --- a/makefile +++ b/makefile @@ -1,5 +1,5 @@ -.PHONY: frontend-dev backend/run generate/openapi bugsink seed-testdata +.PHONY: frontend-dev backend/run generate/openapi bugsink seed-testdata fuzz fuzz/regression fuzz/single frontend/run: cd frontend/apps/cli && node --env-file=../../../.env --import tsx src/index.tsx @@ -13,6 +13,16 @@ generate/openapi: seed-testdata: PGPASSWORD=effigenix nix shell nixpkgs#postgresql --command psql -f backend/src/main/resources/db/changelog/changes/099-seed-testdata.sql -h localhost -p 5432 -U effigenix -d effigenix +fuzz: + cd backend && mvn test -Pfuzz + +fuzz/regression: + cd backend && mvn test -Pfuzz-regression + +fuzz/single: + @test -n "$(TEST)" || (echo "Usage: make fuzz/single TEST=BatchFuzzTest" && exit 1) + cd backend && mvn test -Pfuzz -Dtest=$(TEST) + bugsink: @. ./.env 2>/dev/null || true; \ docker start bugsink 2>/dev/null || \