docs(01-data-integrity): create phase 1 plans

This commit is contained in:
2026-03-23 20:04:57 +01:00
parent 19d757d060
commit b89e607493
3 changed files with 684 additions and 2 deletions

View File

@@ -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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-data-integrity/01-RESEARCH.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Replace INSERT OR REPLACE with UPSERT in UpdateEvent() and add PRAGMA FK enforcement in InitDB()</name>
<files>pkg/diunwebhook/diunwebhook.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook.go — read the entire file before touching it; understand current InitDB() structure (lines 58-104) and UpdateEvent() structure (lines 106-118)
</read_first>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestDismissHandler_ReappearsAfterNewWebhook|TestDeleteTagHandler_CascadesAssignment" ./pkg/diunwebhook/</automated>
</verify>
<done>
- 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
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add regression test TestUpdateEvent_PreservesTagOnUpsert</name>
<files>pkg/diunwebhook/diunwebhook_test.go</files>
<read_first>
- 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
</read_first>
<behavior>
- 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
</behavior>
<action>
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
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestUpdateEvent_PreservesTagOnUpsert" ./pkg/diunwebhook/</automated>
</verify>
<done>
- 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
</done>
</task>
</tasks>
<verification>
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
```
</verification>
<success_criteria>
- `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
</success_criteria>
<output>
After completion, create `.planning/phases/01-data-integrity/01-01-SUMMARY.md` following the summary template at `@$HOME/.claude/get-shit-done/templates/summary.md`.
</output>

View File

@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add request body size limits to WebhookHandler, TagsHandler, and TagAssignmentHandler</name>
<files>pkg/diunwebhook/diunwebhook.go</files>
<read_first>
- 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
</read_first>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./pkg/diunwebhook/ && go test -v -run "TestWebhookHandler_BadRequest|TestCreateTagHandler_EmptyName|TestTagAssignmentHandler_Assign" ./pkg/diunwebhook/</automated>
</verify>
<done>
- `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
</done>
</task>
<task type="auto">
<name>Task 2: Replace silent returns with t.Fatalf at 6 test setup call sites; add 3 oversized-body tests</name>
<files>pkg/diunwebhook/diunwebhook_test.go</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestWebhookHandler_OversizedBody|TestTagsHandler_OversizedBody|TestTagAssignmentHandler_OversizedBody" ./pkg/diunwebhook/</automated>
</verify>
<done>
- `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
</done>
</task>
</tasks>
<verification>
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
```
</verification>
<success_criteria>
- `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
</success_criteria>
<output>
After completion, create `.planning/phases/01-data-integrity/01-02-SUMMARY.md` following the summary template at `@$HOME/.claude/get-shit-done/templates/summary.md`.
</output>