diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index f19c8e1..0868a02 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -28,7 +28,11 @@ Decimal phases appear between their surrounding integers in numeric order.
2. Deleting a tag removes all associated tag assignments (foreign key cascade enforced)
3. An oversized webhook payload is rejected with a 413 response, not processed silently
4. A failing assertion in a test causes the test run to report failure, not pass silently
-**Plans**: TBD
+**Plans**: 2 plans
+
+Plans:
+- [ ] 01-01-PLAN.md — Fix INSERT OR REPLACE → UPSERT in UpdateEvent(); enable PRAGMA foreign_keys = ON in InitDB(); add regression test
+- [ ] 01-02-PLAN.md — Add http.MaxBytesReader body limits to 3 handlers (413 on oversized); replace 6 silent test returns with t.Fatalf
### Phase 2: Backend Refactor
**Goal**: The codebase has a clean Store interface and Server struct so the SQLite implementation can be swapped without touching HTTP handlers, enabling parallel test execution and PostgreSQL support
@@ -74,7 +78,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
-| 1. Data Integrity | 0/? | Not started | - |
+| 1. Data Integrity | 0/2 | Not started | - |
| 2. Backend Refactor | 0/? | Not started | - |
| 3. PostgreSQL Support | 0/? | Not started | - |
| 4. UX Improvements | 0/? | Not started | - |
diff --git a/.planning/phases/01-data-integrity/01-01-PLAN.md b/.planning/phases/01-data-integrity/01-01-PLAN.md
new file mode 100644
index 0000000..92cd7ae
--- /dev/null
+++ b/.planning/phases/01-data-integrity/01-01-PLAN.md
@@ -0,0 +1,264 @@
+---
+phase: 01-data-integrity
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - pkg/diunwebhook/diunwebhook.go
+ - pkg/diunwebhook/diunwebhook_test.go
+autonomous: true
+requirements:
+ - DATA-01
+ - DATA-02
+
+must_haves:
+ truths:
+ - "A second DIUN event for an already-tagged image does not remove its tag assignment"
+ - "Deleting a tag removes all associated tag_assignments rows (ON DELETE CASCADE fires)"
+ - "The full test suite passes with no new failures introduced"
+ artifacts:
+ - path: "pkg/diunwebhook/diunwebhook.go"
+ provides: "UPSERT in UpdateEvent(); PRAGMA foreign_keys = ON in InitDB()"
+ contains: "ON CONFLICT(image) DO UPDATE SET"
+ - path: "pkg/diunwebhook/diunwebhook_test.go"
+ provides: "Regression test TestUpdateEvent_PreservesTagOnUpsert"
+ contains: "TestUpdateEvent_PreservesTagOnUpsert"
+ key_links:
+ - from: "InitDB()"
+ to: "PRAGMA foreign_keys = ON"
+ via: "db.Exec immediately after db.SetMaxOpenConns(1)"
+ pattern: "PRAGMA foreign_keys = ON"
+ - from: "UpdateEvent()"
+ to: "INSERT INTO updates ... ON CONFLICT(image) DO UPDATE SET"
+ via: "db.Exec with named column list"
+ pattern: "ON CONFLICT\\(image\\) DO UPDATE SET"
+---
+
+
+Fix the two data-destruction bugs that are silently corrupting tag assignments today.
+
+Bug 1 (DATA-01): `UpdateEvent()` uses `INSERT OR REPLACE` which SQLite implements as DELETE + INSERT. The DELETE fires the `ON DELETE CASCADE` on `tag_assignments.image`, destroying the child row. Every new DIUN event for an already-tagged image loses its tag.
+
+Bug 2 (DATA-02): `PRAGMA foreign_keys = ON` is never executed. SQLite disables FK enforcement by default. The `ON DELETE CASCADE` on `tag_assignments.tag_id` does not fire when a tag is deleted.
+
+These two bugs are fixed in the same plan because fixing DATA-01 without DATA-02 causes `TestDeleteTagHandler_CascadesAssignment` to break (tag assignments now survive UPSERT but FK cascades still do not fire on tag deletion).
+
+Purpose: Users can trust that tagging an image is permanent until they explicitly remove it, and that deleting a tag group cleans up all assignments.
+Output: Updated `diunwebhook.go` with UPSERT + FK pragma; new regression test `TestUpdateEvent_PreservesTagOnUpsert` in `diunwebhook_test.go`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-data-integrity/01-RESEARCH.md
+
+
+
+
+
+ Task 1: Replace INSERT OR REPLACE with UPSERT in UpdateEvent() and add PRAGMA FK enforcement in InitDB()
+ pkg/diunwebhook/diunwebhook.go
+
+
+ - pkg/diunwebhook/diunwebhook.go — read the entire file before touching it; understand current InitDB() structure (lines 58-104) and UpdateEvent() structure (lines 106-118)
+
+
+
+ - Test 1 (existing, must still pass): TestDismissHandler_ReappearsAfterNewWebhook — a new webhook event resets acknowledged_at to NULL
+ - Test 2 (existing, must still pass): TestDeleteTagHandler_CascadesAssignment — deleting a tag removes the tag_assignment row (requires both UPSERT and PRAGMA fixes)
+ - Test 3 (new, added in Task 2): TestUpdateEvent_PreservesTagOnUpsert — tag survives a second UpdateEvent() for the same image
+
+
+
+ Make exactly two changes to pkg/diunwebhook/diunwebhook.go:
+
+ CHANGE 1 — Add PRAGMA to InitDB():
+ After line 64 (`db.SetMaxOpenConns(1)`), insert:
+ ```go
+ if _, err = db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
+ return err
+ }
+ ```
+ This must appear before any CREATE TABLE statement. The error must not be swallowed.
+
+ CHANGE 2 — Replace INSERT OR REPLACE in UpdateEvent():
+ Replace the entire db.Exec call at lines 109-116 (the `INSERT OR REPLACE INTO updates VALUES (...)` statement and its argument list) with:
+ ```go
+ _, err := db.Exec(`
+ INSERT INTO updates (
+ image, diun_version, hostname, status, provider,
+ hub_link, mime_type, digest, created, platform,
+ ctn_name, ctn_id, ctn_state, ctn_status,
+ received_at, acknowledged_at
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)
+ ON CONFLICT(image) DO UPDATE SET
+ diun_version = excluded.diun_version,
+ hostname = excluded.hostname,
+ status = excluded.status,
+ provider = excluded.provider,
+ hub_link = excluded.hub_link,
+ mime_type = excluded.mime_type,
+ digest = excluded.digest,
+ created = excluded.created,
+ platform = excluded.platform,
+ ctn_name = excluded.ctn_name,
+ ctn_id = excluded.ctn_id,
+ ctn_state = excluded.ctn_state,
+ ctn_status = excluded.ctn_status,
+ received_at = excluded.received_at,
+ acknowledged_at = NULL`,
+ event.Image, event.DiunVersion, event.Hostname, event.Status, event.Provider,
+ event.HubLink, event.MimeType, event.Digest,
+ event.Created.Format(time.RFC3339), event.Platform,
+ event.Metadata.ContainerName, event.Metadata.ContainerID,
+ event.Metadata.State, event.Metadata.Status,
+ time.Now().Format(time.RFC3339),
+ )
+ ```
+
+ The column count (15 named columns + NULL for acknowledged_at = 16 positional `?` placeholders) must match the 15 bound arguments (acknowledged_at is hardcoded NULL, not a bound arg).
+
+ No other changes to diunwebhook.go in this task. Do not add imports — `errors` is not needed here.
+
+
+
+ cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestDismissHandler_ReappearsAfterNewWebhook|TestDeleteTagHandler_CascadesAssignment" ./pkg/diunwebhook/
+
+
+
+ - pkg/diunwebhook/diunwebhook.go contains the string `PRAGMA foreign_keys = ON`
+ - pkg/diunwebhook/diunwebhook.go contains the string `ON CONFLICT(image) DO UPDATE SET`
+ - pkg/diunwebhook/diunwebhook.go does NOT contain `INSERT OR REPLACE INTO updates`
+ - TestDismissHandler_ReappearsAfterNewWebhook passes
+ - TestDeleteTagHandler_CascadesAssignment passes
+
+
+
+
+ Task 2: Add regression test TestUpdateEvent_PreservesTagOnUpsert
+ pkg/diunwebhook/diunwebhook_test.go
+
+
+ - pkg/diunwebhook/diunwebhook_test.go — read the entire file before touching it; the new test must follow the established patterns (httptest.NewRequest, diun.UpdatesReset(), postTagAndGetID helper, diun.GetUpdatesMap())
+ - pkg/diunwebhook/export_test.go — verify GetUpdatesMap() and UpdatesReset() signatures
+
+
+
+ - Test: First UpdateEvent() for "nginx:latest" → assign tag "webservers" via TagAssignmentHandler → second UpdateEvent() for "nginx:latest" with Status "update" → GetUpdatesMap()["nginx:latest"].Tag must be non-nil → Tag.ID must equal tagID → Acknowledged must be false
+
+
+
+ Add the following test function to pkg/diunwebhook/diunwebhook_test.go, appended after the existing TestGetUpdates_IncludesTag function (at the end of the file):
+
+ ```go
+ func TestUpdateEvent_PreservesTagOnUpsert(t *testing.T) {
+ diun.UpdatesReset()
+
+ // Insert image
+ if err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest", Status: "new"}); err != nil {
+ t.Fatalf("first UpdateEvent failed: %v", err)
+ }
+
+ // Assign tag
+ tagID := postTagAndGetID(t, "webservers")
+ body, _ := json.Marshal(map[string]interface{}{"image": "nginx:latest", "tag_id": tagID})
+ req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body))
+ rec := httptest.NewRecorder()
+ diun.TagAssignmentHandler(rec, req)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("tag assignment failed: got %d", rec.Code)
+ }
+
+ // Dismiss (acknowledge) the image — second event must reset this
+ req = httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil)
+ rec = httptest.NewRecorder()
+ diun.DismissHandler(rec, req)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("dismiss failed: got %d", rec.Code)
+ }
+
+ // Receive a second event for the same image
+ if err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest", Status: "update"}); err != nil {
+ t.Fatalf("second UpdateEvent failed: %v", err)
+ }
+
+ // Tag must survive the second event
+ m := diun.GetUpdatesMap()
+ entry, ok := m["nginx:latest"]
+ if !ok {
+ t.Fatal("nginx:latest missing from updates after second event")
+ }
+ if entry.Tag == nil {
+ t.Error("tag was lost after second UpdateEvent — UPSERT bug not fixed")
+ }
+ if entry.Tag != nil && entry.Tag.ID != tagID {
+ t.Errorf("tag ID changed: expected %d, got %d", tagID, entry.Tag.ID)
+ }
+ // Acknowledged state must be reset by the new event
+ if entry.Acknowledged {
+ t.Error("acknowledged state must be reset by new event")
+ }
+ // Status must reflect the new event
+ if entry.Event.Status != "update" {
+ t.Errorf("expected status 'update', got %q", entry.Event.Status)
+ }
+ }
+ ```
+
+ This test verifies all three observable behaviors from DATA-01:
+ 1. Tag survives the UPSERT (the primary bug)
+ 2. acknowledged_at is reset to NULL by the new event
+ 3. Event fields (Status) are updated by the new event
+
+
+
+ cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestUpdateEvent_PreservesTagOnUpsert" ./pkg/diunwebhook/
+
+
+
+ - pkg/diunwebhook/diunwebhook_test.go contains the function `TestUpdateEvent_PreservesTagOnUpsert`
+ - TestUpdateEvent_PreservesTagOnUpsert passes (tag non-nil, ID matches, Acknowledged false, Status "update")
+ - Full test suite still passes: `go test ./pkg/diunwebhook/` exits 0
+
+
+
+
+
+
+Run the full test suite after both tasks are complete:
+
+```bash
+cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -coverprofile=coverage.out -coverpkg=./... ./...
+```
+
+Expected outcome:
+- All existing tests pass (no regressions)
+- TestUpdateEvent_PreservesTagOnUpsert passes
+- TestDeleteTagHandler_CascadesAssignment passes (proves DATA-02)
+
+Spot-check the fixes with grep:
+```bash
+grep -n "PRAGMA foreign_keys" pkg/diunwebhook/diunwebhook.go
+grep -n "ON CONFLICT(image) DO UPDATE SET" pkg/diunwebhook/diunwebhook.go
+grep -c "INSERT OR REPLACE INTO updates" pkg/diunwebhook/diunwebhook.go # must output 0
+```
+
+
+
+- `grep -c "INSERT OR REPLACE INTO updates" pkg/diunwebhook/diunwebhook.go` outputs `0`
+- `grep -c "PRAGMA foreign_keys = ON" pkg/diunwebhook/diunwebhook.go` outputs `1`
+- `grep -c "ON CONFLICT(image) DO UPDATE SET" pkg/diunwebhook/diunwebhook.go` outputs `1`
+- `go test ./pkg/diunwebhook/` exits 0
+- `TestUpdateEvent_PreservesTagOnUpsert` exists in diunwebhook_test.go and passes
+
+
+
diff --git a/.planning/phases/01-data-integrity/01-02-PLAN.md b/.planning/phases/01-data-integrity/01-02-PLAN.md
new file mode 100644
index 0000000..eb58620
--- /dev/null
+++ b/.planning/phases/01-data-integrity/01-02-PLAN.md
@@ -0,0 +1,414 @@
+---
+phase: 01-data-integrity
+plan: 02
+type: execute
+wave: 2
+depends_on:
+ - 01-01
+files_modified:
+ - pkg/diunwebhook/diunwebhook.go
+ - pkg/diunwebhook/diunwebhook_test.go
+autonomous: true
+requirements:
+ - DATA-03
+ - DATA-04
+
+must_haves:
+ truths:
+ - "An oversized webhook payload (>1MB) is rejected with HTTP 413, not processed"
+ - "A failing test setup call (UpdateEvent error, DB error) causes the test run to report FAIL, not pass silently"
+ - "The full test suite passes with no regressions from Plan 01"
+ artifacts:
+ - path: "pkg/diunwebhook/diunwebhook.go"
+ provides: "maxBodyBytes constant; MaxBytesReader + errors.As pattern in WebhookHandler, TagsHandler POST, TagAssignmentHandler PUT and DELETE"
+ contains: "maxBodyBytes"
+ - path: "pkg/diunwebhook/diunwebhook_test.go"
+ provides: "New tests TestWebhookHandler_OversizedBody, TestTagsHandler_OversizedBody, TestTagAssignmentHandler_OversizedBody; t.Fatalf replacements at 6 call sites"
+ contains: "TestWebhookHandler_OversizedBody"
+ key_links:
+ - from: "WebhookHandler"
+ to: "http.StatusRequestEntityTooLarge (413)"
+ via: "r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) then errors.As(err, &maxBytesErr)"
+ pattern: "MaxBytesReader"
+ - from: "diunwebhook_test.go setup calls"
+ to: "t.Fatalf"
+ via: "replace `if err != nil { return }` with `t.Fatalf(...)`"
+ pattern: "t\\.Fatalf"
+---
+
+
+Fix two remaining bugs: unbounded request body reads (DATA-03) and silently swallowed test failures (DATA-04).
+
+Bug 3 (DATA-03): `WebhookHandler`, `TagsHandler` POST branch, and `TagAssignmentHandler` PUT/DELETE branches decode JSON directly from `r.Body` with no size limit. A malicious or buggy DIUN installation could POST a multi-GB payload causing OOM. The fix applies `http.MaxBytesReader` before each decode and returns HTTP 413 when the limit is exceeded.
+
+Bug 4 (DATA-04): Six test call sites use `if err != nil { return }` instead of `t.Fatalf(...)`. When test setup fails (e.g., InitDB fails, UpdateEvent fails), the test silently exits with PASS, hiding the real failure from CI.
+
+These two bugs are fixed in the same plan because they are independent of Plan 01's changes and both small enough to fit comfortably together.
+
+Purpose: Webhook endpoint is safe from OOM attacks; test failures are always visible to the developer and CI.
+Output: Updated `diunwebhook.go` with MaxBytesReader in three handlers; updated `diunwebhook_test.go` with t.Fatalf at 6 sites and 3 new 413 tests.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-data-integrity/01-RESEARCH.md
+@.planning/phases/01-data-integrity/01-01-SUMMARY.md
+
+
+
+
+
+ Task 1: Add request body size limits to WebhookHandler, TagsHandler, and TagAssignmentHandler
+ pkg/diunwebhook/diunwebhook.go
+
+
+ - pkg/diunwebhook/diunwebhook.go — read the entire file before touching it; locate the exact lines for each handler's JSON decode call; the Plan 01 changes (UPSERT, PRAGMA) are already present — do not revert them
+
+
+
+ - Test (new): POST /webhook with a body of 1MB + 1 byte returns HTTP 413
+ - Test (new): POST /api/tags with a body of 1MB + 1 byte returns HTTP 413
+ - Test (new): PUT /api/tag-assignments with a body of 1MB + 1 byte returns HTTP 413
+ - Test (existing): POST /webhook with valid JSON still returns HTTP 200
+ - Test (existing): POST /api/tags with valid JSON still returns HTTP 201
+
+
+
+ Make the following changes to pkg/diunwebhook/diunwebhook.go:
+
+ CHANGE 1 — Add package-level constant after the import block, before the type declarations:
+ ```go
+ const maxBodyBytes = 1 << 20 // 1 MB
+ ```
+
+ CHANGE 2 — Add `"errors"` to the import block (it is not currently imported in diunwebhook.go; it is imported in the test file but not the production file).
+ The import block becomes:
+ ```go
+ import (
+ "crypto/subtle"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ _ "modernc.org/sqlite"
+ )
+ ```
+
+ CHANGE 3 — In WebhookHandler, BEFORE the `var event DiunEvent` line (currently line ~177), add:
+ ```go
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+ ```
+ Then update the decode error handling block to distinguish 413 from 400:
+ ```go
+ if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
+ http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ log.Printf("WebhookHandler: failed to decode request: %v", err)
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ ```
+
+ CHANGE 4 — In TagsHandler POST branch, BEFORE `var req struct { Name string }`, add:
+ ```go
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+ ```
+ Then update the decode error handling — the current code is:
+ ```go
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
+ http.Error(w, "bad request: name required", http.StatusBadRequest)
+ return
+ }
+ ```
+ Replace with:
+ ```go
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
+ http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "bad request: name required", http.StatusBadRequest)
+ return
+ }
+ if req.Name == "" {
+ http.Error(w, "bad request: name required", http.StatusBadRequest)
+ return
+ }
+ ```
+ (The `req.Name == ""` check must remain, now as a separate if-block after the decode succeeds.)
+
+ CHANGE 5 — In TagAssignmentHandler PUT branch, BEFORE `var req struct { Image string; TagID int }`, add:
+ ```go
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+ ```
+ Then update the decode error handling — the current code is:
+ ```go
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ ```
+ Replace with:
+ ```go
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
+ http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.Image == "" {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ ```
+
+ CHANGE 6 — In TagAssignmentHandler DELETE branch, BEFORE `var req struct { Image string }`, add:
+ ```go
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+ ```
+ Then update the decode error handling — the current code is:
+ ```go
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ ```
+ Replace with:
+ ```go
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
+ http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.Image == "" {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ ```
+
+ No other changes to diunwebhook.go in this task.
+
+
+
+ cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./pkg/diunwebhook/ && go test -v -run "TestWebhookHandler_BadRequest|TestCreateTagHandler_EmptyName|TestTagAssignmentHandler_Assign" ./pkg/diunwebhook/
+
+
+
+ - `grep -c "maxBodyBytes" pkg/diunwebhook/diunwebhook.go` outputs `5` (1 constant definition + 4 MaxBytesReader calls)
+ - `grep -c "MaxBytesReader" pkg/diunwebhook/diunwebhook.go` outputs `4`
+ - `grep -c "errors.As" pkg/diunwebhook/diunwebhook.go` outputs `4`
+ - `go build ./pkg/diunwebhook/` exits 0
+ - All pre-existing handler tests still pass
+
+
+
+
+ Task 2: Replace silent returns with t.Fatalf at 6 test setup call sites; add 3 oversized-body tests
+ pkg/diunwebhook/diunwebhook_test.go
+
+
+ - pkg/diunwebhook/diunwebhook_test.go — read the entire file; locate the exact 6 `if err != nil { return }` call sites at lines 38-40, 153-154, 228-231, 287-289, 329-331, 350-351 and verify they still exist after Plan 01 (which only appended to the file)
+
+
+
+ CHANGE 1 — Replace the 6 silent-return call sites with t.Fatalf. Each replacement follows this pattern:
+
+ OLD (line ~38-40, in TestUpdateEventAndGetUpdates):
+ ```go
+ err := diun.UpdateEvent(event)
+ if err != nil {
+ return
+ }
+ ```
+ NEW:
+ ```go
+ if err := diun.UpdateEvent(event); err != nil {
+ t.Fatalf("test setup: UpdateEvent failed: %v", err)
+ }
+ ```
+
+ OLD (line ~153-154, in TestUpdatesHandler):
+ ```go
+ err := diun.UpdateEvent(event)
+ if err != nil {
+ return
+ }
+ ```
+ NEW:
+ ```go
+ if err := diun.UpdateEvent(event); err != nil {
+ t.Fatalf("test setup: UpdateEvent failed: %v", err)
+ }
+ ```
+
+ OLD (line ~228-231, in TestConcurrentUpdateEvent goroutine):
+ ```go
+ err := diun.UpdateEvent(diun.DiunEvent{Image: fmt.Sprintf("image:%d", i)})
+ if err != nil {
+ return
+ }
+ ```
+ NEW (note: in a goroutine, t.Fatalf is safe — testing.T.Fatalf calls runtime.Goexit which unwinds the goroutine cleanly):
+ ```go
+ if err := diun.UpdateEvent(diun.DiunEvent{Image: fmt.Sprintf("image:%d", i)}); err != nil {
+ t.Fatalf("test setup: UpdateEvent[%d] failed: %v", i, err)
+ }
+ ```
+
+ OLD (line ~287-289, in TestDismissHandler_Success):
+ ```go
+ err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"})
+ if err != nil {
+ return
+ }
+ ```
+ NEW:
+ ```go
+ if err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"}); err != nil {
+ t.Fatalf("test setup: UpdateEvent failed: %v", err)
+ }
+ ```
+
+ OLD (line ~329-331, in TestDismissHandler_SlashInImageName):
+ ```go
+ err := diun.UpdateEvent(diun.DiunEvent{Image: "ghcr.io/user/image:tag"})
+ if err != nil {
+ return
+ }
+ ```
+ NEW:
+ ```go
+ if err := diun.UpdateEvent(diun.DiunEvent{Image: "ghcr.io/user/image:tag"}); err != nil {
+ t.Fatalf("test setup: UpdateEvent failed: %v", err)
+ }
+ ```
+
+ OLD (line ~350-351, in TestDismissHandler_ReappearsAfterNewWebhook — note: line 350 is `diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"})` with no error check at all):
+ ```go
+ diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"})
+ ```
+ NEW:
+ ```go
+ if err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"}); err != nil {
+ t.Fatalf("test setup: UpdateEvent failed: %v", err)
+ }
+ ```
+
+ CHANGE 2 — Add three new test functions after all existing tests (at the end of the file, after TestUpdateEvent_PreservesTagOnUpsert which was added in Plan 01):
+
+ ```go
+ func TestWebhookHandler_OversizedBody(t *testing.T) {
+ // Generate a body that exceeds 1 MB (maxBodyBytes = 1<<20 = 1,048,576 bytes)
+ oversized := make([]byte, 1<<20+1)
+ for i := range oversized {
+ oversized[i] = 'x'
+ }
+ req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
+ rec := httptest.NewRecorder()
+ diun.WebhookHandler(rec, req)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("expected 413 for oversized body, got %d", rec.Code)
+ }
+ }
+
+ func TestTagsHandler_OversizedBody(t *testing.T) {
+ oversized := make([]byte, 1<<20+1)
+ for i := range oversized {
+ oversized[i] = 'x'
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(oversized))
+ rec := httptest.NewRecorder()
+ diun.TagsHandler(rec, req)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("expected 413 for oversized body, got %d", rec.Code)
+ }
+ }
+
+ func TestTagAssignmentHandler_OversizedBody(t *testing.T) {
+ oversized := make([]byte, 1<<20+1)
+ for i := range oversized {
+ oversized[i] = 'x'
+ }
+ req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(oversized))
+ rec := httptest.NewRecorder()
+ diun.TagAssignmentHandler(rec, req)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("expected 413 for oversized body, got %d", rec.Code)
+ }
+ }
+ ```
+
+ No new imports are needed — `bytes`, `net/http`, `net/http/httptest`, and `testing` are already imported.
+
+
+
+ cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestWebhookHandler_OversizedBody|TestTagsHandler_OversizedBody|TestTagAssignmentHandler_OversizedBody" ./pkg/diunwebhook/
+
+
+
+ - `grep -c "if err != nil {" pkg/diunwebhook/diunwebhook_test.go` is reduced by 6 compared to before this task (the 6 setup-path returns are gone; other `if err != nil {` blocks with t.Fatal/t.Fatalf remain)
+ - `grep -c "return$" pkg/diunwebhook/diunwebhook_test.go` no longer contains bare `return` in error-check positions (the 6 silent returns are gone)
+ - TestWebhookHandler_OversizedBody passes (413)
+ - TestTagsHandler_OversizedBody passes (413)
+ - TestTagAssignmentHandler_OversizedBody passes (413)
+ - Full test suite passes: `go test ./pkg/diunwebhook/` exits 0
+
+
+
+
+
+
+Run the full test suite after both tasks are complete:
+
+```bash
+cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -coverprofile=coverage.out -coverpkg=./... ./...
+```
+
+Expected outcome:
+- All tests pass (no regressions from Plan 01 or Plan 02)
+- Three new 413 tests pass (proves DATA-03)
+- Six `if err != nil { return }` patterns replaced with t.Fatalf (proves DATA-04)
+
+Spot-check the fixes:
+```bash
+grep -n "maxBodyBytes\|MaxBytesReader\|errors.As" pkg/diunwebhook/diunwebhook.go
+grep -n "t.Fatalf" pkg/diunwebhook/diunwebhook_test.go | wc -l # should be >= 6 more than before
+```
+
+
+
+- `grep -c "MaxBytesReader" pkg/diunwebhook/diunwebhook.go` outputs `4`
+- `grep -c "maxBodyBytes" pkg/diunwebhook/diunwebhook.go` outputs `5`
+- `grep -c "StatusRequestEntityTooLarge" pkg/diunwebhook/diunwebhook.go` outputs `4`
+- TestWebhookHandler_OversizedBody, TestTagsHandler_OversizedBody, TestTagAssignmentHandler_OversizedBody all exist and pass
+- `grep -c "if err != nil {$" pkg/diunwebhook/diunwebhook_test.go` followed by `return` no longer appears at the 6 original sites
+- `go test -coverprofile=coverage.out -coverpkg=./... ./...` exits 0
+
+
+