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 || \