package diunwebhook_test import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "sync" "testing" "time" diun "awesomeProject/pkg/diunwebhook" ) func TestMain(m *testing.M) { diun.UpdatesReset() os.Exit(m.Run()) } func TestUpdateEventAndGetUpdates(t *testing.T) { diun.UpdatesReset() event := diun.DiunEvent{ DiunVersion: "1.0", Hostname: "host", Status: "new", Provider: "docker", Image: "nginx:latest", HubLink: "https://hub.docker.com/nginx", MimeType: "application/json", Digest: "sha256:abc", Created: time.Now(), Platform: "linux/amd64", } err := diun.UpdateEvent(event) if err != nil { return } got, err := diun.GetUpdates() if err != nil { t.Fatalf("GetUpdates error: %v", err) } if len(got) != 1 { t.Fatalf("expected 1 update, got %d", len(got)) } if got["nginx:latest"].Event.DiunVersion != "1.0" { t.Errorf("unexpected DiunVersion: %s", got["nginx:latest"].Event.DiunVersion) } } func TestWebhookHandler(t *testing.T) { diun.UpdatesReset() event := diun.DiunEvent{ DiunVersion: "2.0", Hostname: "host2", Status: "updated", Provider: "docker", Image: "alpine:latest", HubLink: "https://hub.docker.com/alpine", MimeType: "application/json", Digest: "sha256:def", Created: time.Now(), Platform: "linux/amd64", } body, _ := json.Marshal(event) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.WebhookHandler(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rec.Code) } if len(diun.GetUpdatesMap()) != 1 { t.Errorf("expected 1 update, got %d", len(diun.GetUpdatesMap())) } } func TestWebhookHandler_BadRequest(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte("not-json"))) rec := httptest.NewRecorder() diun.WebhookHandler(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("expected 400 for bad JSON, got %d", rec.Code) } } func TestUpdatesHandler(t *testing.T) { diun.UpdatesReset() event := diun.DiunEvent{Image: "busybox:latest"} err := diun.UpdateEvent(event) if err != nil { return } req := httptest.NewRequest(http.MethodGet, "/api/updates", nil) rec := httptest.NewRecorder() diun.UpdatesHandler(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rec.Code) } var got map[string]diun.UpdateEntry if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if _, ok := got["busybox:latest"]; !ok { t.Errorf("expected busybox:latest in updates") } } // Helper for simulating a broken ResponseWriter // Used in TestUpdatesHandler_EncodeError type failWriter struct{ http.ResponseWriter } func (f failWriter) Header() http.Header { return http.Header{} } func (f failWriter) Write([]byte) (int, error) { return 0, errors.New("forced error") } func (f failWriter) WriteHeader(_ int) {} func TestUpdatesHandler_EncodeError(t *testing.T) { rec := failWriter{httptest.NewRecorder()} diun.UpdatesHandler(rec, httptest.NewRequest(http.MethodGet, "/api/updates", nil)) // No panic = pass } func TestWebhookHandler_MethodNotAllowed(t *testing.T) { methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete} for _, method := range methods { req := httptest.NewRequest(method, "/webhook", nil) rec := httptest.NewRecorder() diun.WebhookHandler(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("method %s: expected 405, got %d", method, rec.Code) } } // POST should not return 405 event := diun.DiunEvent{Image: "nginx:latest"} body, _ := json.Marshal(event) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.WebhookHandler(rec, req) if rec.Code == http.StatusMethodNotAllowed { t.Errorf("POST should not return 405") } } func TestWebhookHandler_EmptyImage(t *testing.T) { diun.UpdatesReset() body, _ := json.Marshal(diun.DiunEvent{Image: ""}) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.WebhookHandler(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("expected 400 for empty image, got %d", rec.Code) } if len(diun.GetUpdatesMap()) != 0 { t.Errorf("expected map to stay empty, got %d entries", len(diun.GetUpdatesMap())) } } func TestConcurrentUpdateEvent(t *testing.T) { diun.UpdatesReset() const n = 100 var wg sync.WaitGroup wg.Add(n) for i := range n { go func(i int) { defer wg.Done() err := diun.UpdateEvent(diun.DiunEvent{Image: fmt.Sprintf("image:%d", i)}) if err != nil { return } }(i) } wg.Wait() if got := len(diun.GetUpdatesMap()); got != n { t.Errorf("expected %d entries, got %d", n, got) } } func TestMainHandlerIntegration(t *testing.T) { diun.UpdatesReset() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/webhook" { diun.WebhookHandler(w, r) } else if r.URL.Path == "/api/updates" { diun.UpdatesHandler(w, r) } else { w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() event := diun.DiunEvent{Image: "integration:latest"} body, _ := json.Marshal(event) resp, err := http.Post(ts.URL+"/webhook", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("webhook POST failed: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("webhook POST returned status: %d", resp.StatusCode) } if cerr := resp.Body.Close(); cerr != nil { t.Errorf("failed to close response body: %v", cerr) } resp, err = http.Get(ts.URL + "/api/updates") if err != nil { t.Fatalf("GET /api/updates failed: %v", err) } defer func() { if cerr := resp.Body.Close(); cerr != nil { t.Errorf("failed to close response body: %v", cerr) } }() var got map[string]diun.UpdateEntry if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("decode failed: %v", err) } if _, ok := got["integration:latest"]; !ok { t.Errorf("expected integration:latest in updates") } } func TestDismissHandler_Success(t *testing.T) { diun.UpdatesReset() err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"}) if err != nil { return } req := httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil) rec := httptest.NewRecorder() diun.DismissHandler(rec, req) if rec.Code != http.StatusNoContent { t.Errorf("expected 204, got %d", rec.Code) } m := diun.GetUpdatesMap() if len(m) != 1 { t.Errorf("expected entry to remain after acknowledge, got %d entries", len(m)) } if !m["nginx:latest"].Acknowledged { t.Errorf("expected entry to be acknowledged") } } func TestDismissHandler_NotFound(t *testing.T) { diun.UpdatesReset() req := httptest.NewRequest(http.MethodPatch, "/api/updates/does-not-exist:latest", nil) rec := httptest.NewRecorder() diun.DismissHandler(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", rec.Code) } } func TestDismissHandler_EmptyImage(t *testing.T) { req := httptest.NewRequest(http.MethodPatch, "/api/updates/", nil) rec := httptest.NewRecorder() diun.DismissHandler(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", rec.Code) } } func TestDismissHandler_SlashInImageName(t *testing.T) { diun.UpdatesReset() err := diun.UpdateEvent(diun.DiunEvent{Image: "ghcr.io/user/image:tag"}) if err != nil { return } req := httptest.NewRequest(http.MethodPatch, "/api/updates/ghcr.io/user/image:tag", nil) rec := httptest.NewRecorder() diun.DismissHandler(rec, req) if rec.Code != http.StatusNoContent { t.Errorf("expected 204, got %d", rec.Code) } m := diun.GetUpdatesMap() if len(m) != 1 { t.Errorf("expected entry to remain after acknowledge, got %d entries", len(m)) } if !m["ghcr.io/user/image:tag"].Acknowledged { t.Errorf("expected entry to be acknowledged") } } func TestDismissHandler_ReappearsAfterNewWebhook(t *testing.T) { diun.UpdatesReset() diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"}) req := httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil) rec := httptest.NewRecorder() diun.DismissHandler(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("expected 204 on acknowledge, got %d", rec.Code) } if !diun.GetUpdatesMap()["nginx:latest"].Acknowledged { t.Errorf("expected entry to be acknowledged after PATCH") } diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest", Status: "update"}) m := diun.GetUpdatesMap() if len(m) != 1 { t.Errorf("expected entry to remain, got %d entries", len(m)) } if m["nginx:latest"].Event.Status != "update" { t.Errorf("unexpected status: %s", m["nginx:latest"].Event.Status) } if m["nginx:latest"].Acknowledged { t.Errorf("expected acknowledged to be reset after new webhook") } } // --- Tag handler tests --- func postTag(t *testing.T, name string) (int, int) { t.Helper() body, _ := json.Marshal(map[string]string{"name": name}) req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.TagsHandler(rec, req) return rec.Code, rec.Body.Len() } func postTagAndGetID(t *testing.T, name string) int { t.Helper() body, _ := json.Marshal(map[string]string{"name": name}) req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.TagsHandler(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 creating tag %q, got %d", name, rec.Code) } var tag diun.Tag json.NewDecoder(rec.Body).Decode(&tag) return tag.ID } func TestCreateTagHandler_Success(t *testing.T) { diun.UpdatesReset() body, _ := json.Marshal(map[string]string{"name": "nextcloud"}) req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.TagsHandler(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d", rec.Code) } var tag diun.Tag if err := json.NewDecoder(rec.Body).Decode(&tag); err != nil { t.Fatalf("decode failed: %v", err) } if tag.Name != "nextcloud" { t.Errorf("expected name 'nextcloud', got %q", tag.Name) } if tag.ID == 0 { t.Errorf("expected non-zero ID") } } func TestCreateTagHandler_DuplicateName(t *testing.T) { diun.UpdatesReset() postTag(t, "monitoring") code, _ := postTag(t, "monitoring") if code != http.StatusConflict { t.Errorf("expected 409, got %d", code) } } func TestCreateTagHandler_EmptyName(t *testing.T) { diun.UpdatesReset() body, _ := json.Marshal(map[string]string{"name": ""}) req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.TagsHandler(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", rec.Code) } } func TestGetTagsHandler_Empty(t *testing.T) { diun.UpdatesReset() req := httptest.NewRequest(http.MethodGet, "/api/tags", nil) rec := httptest.NewRecorder() diun.TagsHandler(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var tags []diun.Tag json.NewDecoder(rec.Body).Decode(&tags) if len(tags) != 0 { t.Errorf("expected empty list, got %d tags", len(tags)) } } func TestGetTagsHandler_WithTags(t *testing.T) { diun.UpdatesReset() postTag(t, "alpha") postTag(t, "beta") req := httptest.NewRequest(http.MethodGet, "/api/tags", nil) rec := httptest.NewRecorder() diun.TagsHandler(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var tags []diun.Tag json.NewDecoder(rec.Body).Decode(&tags) if len(tags) != 2 { t.Errorf("expected 2 tags, got %d", len(tags)) } } func TestDeleteTagHandler_Success(t *testing.T) { diun.UpdatesReset() id := postTagAndGetID(t, "to-delete") req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/tags/%d", id), nil) rec := httptest.NewRecorder() diun.TagByIDHandler(rec, req) if rec.Code != http.StatusNoContent { t.Errorf("expected 204, got %d", rec.Code) } } func TestDeleteTagHandler_NotFound(t *testing.T) { diun.UpdatesReset() req := httptest.NewRequest(http.MethodDelete, "/api/tags/9999", nil) rec := httptest.NewRecorder() diun.TagByIDHandler(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", rec.Code) } } func TestDeleteTagHandler_CascadesAssignment(t *testing.T) { diun.UpdatesReset() diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"}) id := postTagAndGetID(t, "cascade-test") // Assign the tag body, _ := json.Marshal(map[string]interface{}{"image": "nginx:latest", "tag_id": id}) 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("expected 204 on assign, got %d", rec.Code) } // Delete the tag req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/tags/%d", id), nil) rec = httptest.NewRecorder() diun.TagByIDHandler(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("expected 204 on delete, got %d", rec.Code) } // Confirm assignment cascaded m := diun.GetUpdatesMap() if m["nginx:latest"].Tag != nil { t.Errorf("expected tag to be nil after cascade delete, got %+v", m["nginx:latest"].Tag) } } func TestTagAssignmentHandler_Assign(t *testing.T) { diun.UpdatesReset() diun.UpdateEvent(diun.DiunEvent{Image: "alpine:latest"}) id := postTagAndGetID(t, "assign-test") body, _ := json.Marshal(map[string]interface{}{"image": "alpine:latest", "tag_id": id}) req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.TagAssignmentHandler(rec, req) if rec.Code != http.StatusNoContent { t.Errorf("expected 204, got %d", rec.Code) } } func TestTagAssignmentHandler_Reassign(t *testing.T) { diun.UpdatesReset() diun.UpdateEvent(diun.DiunEvent{Image: "redis:latest"}) id1 := postTagAndGetID(t, "group-a") id2 := postTagAndGetID(t, "group-b") assign := func(tagID int) { body, _ := json.Marshal(map[string]interface{}{"image": "redis: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("expected 204, got %d", rec.Code) } } assign(id1) assign(id2) m := diun.GetUpdatesMap() if m["redis:latest"].Tag == nil || m["redis:latest"].Tag.ID != id2 { t.Errorf("expected tag id %d after reassign, got %+v", id2, m["redis:latest"].Tag) } } func TestTagAssignmentHandler_Unassign(t *testing.T) { diun.UpdatesReset() diun.UpdateEvent(diun.DiunEvent{Image: "busybox:latest"}) id := postTagAndGetID(t, "unassign-test") body, _ := json.Marshal(map[string]interface{}{"image": "busybox:latest", "tag_id": id}) req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body)) rec := httptest.NewRecorder() diun.TagAssignmentHandler(rec, req) // Now unassign body, _ = json.Marshal(map[string]string{"image": "busybox:latest"}) req = httptest.NewRequest(http.MethodDelete, "/api/tag-assignments", bytes.NewReader(body)) rec = httptest.NewRecorder() diun.TagAssignmentHandler(rec, req) if rec.Code != http.StatusNoContent { t.Errorf("expected 204, got %d", rec.Code) } m := diun.GetUpdatesMap() if m["busybox:latest"].Tag != nil { t.Errorf("expected tag nil after unassign, got %+v", m["busybox:latest"].Tag) } } func TestGetUpdates_IncludesTag(t *testing.T) { diun.UpdatesReset() diun.UpdateEvent(diun.DiunEvent{Image: "postgres:latest"}) id := postTagAndGetID(t, "databases") body, _ := json.Marshal(map[string]interface{}{"image": "postgres:latest", "tag_id": id}) 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("expected 204, got %d", rec.Code) } m := diun.GetUpdatesMap() entry, ok := m["postgres:latest"] if !ok { t.Fatal("expected postgres:latest in updates") } if entry.Tag == nil { t.Fatal("expected tag to be set") } if entry.Tag.Name != "databases" { t.Errorf("expected tag name 'databases', got %q", entry.Tag.Name) } if entry.Tag.ID != id { t.Errorf("expected tag id %d, got %d", id, entry.Tag.ID) } }