package api import ( "bytes" "encoding/json" "io" "log" "net/http/httptest" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-387 — the ingest handler rewrote an unknown severity WITHOUT SAYING SO, and the guard built to // catch that class sat downstream of the rewrite, permanently blind to it. // // THE ASYMMETRY. Two fields, same handler, opposite discipline: an unknown event TYPE is rejected // with a loud 400; an unknown SEVERITY was silently coerced to "info" — after which severityNotifies // drops it and NEITHER leg runs. Two shipped features (`DiskAlertKind.Severity` until controller // v0.215.0, `app_start_failed` until v0.223.0) emitted "warn" and were mailed to nobody, while every // POST returned 200 and every dashboard showed the alert. // // THE LAYER. The guard sits at INGEST, because that is the last point at which the offending value // still exists — by design it ceases to exist one line later, which is exactly why nothing downstream // could ever detect it. // // WHY NOT A 400. A rejected event is a LOST event, and losing an alarm is worse than mis-routing one. // The coercion is deliberate and stays; only the silence is fixed. // // RED-PROOF (observed, see REPORT.md): delete the h.logger.Printf from the default branch and // TestR387_UnknownSeverityIsCoercedAndAnnounced fails with `the hub rewrote a severity and said // nothing`. func severityTestHandler(t *testing.T) (*Handler, *store.Store, *bytes.Buffer) { t.Helper() var buf bytes.Buffer path := filepath.Join(t.TempDir(), "test.db") st, err := store.New(path, log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) if err := st.SaveCustomerConfig(&store.CustomerConfig{ CustomerID: "c1", APIKey: "k1", RetrievalPassword: "p", }); err != nil { t.Fatal(err) } h := New(st, globalKey, "", "", nil, log.New(&buf, "", 0)) return h, st, &buf } func postEvent(t *testing.T, h *Handler, eventType, severity string) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(map[string]any{ "customer_id": "c1", "event_type": eventType, "severity": severity, "message": "test message", }) return do(h, "POST", "/event", "k1", string(body)) } func TestR387_UnknownSeverityIsCoercedAndAnnounced(t *testing.T) { h, st, logs := severityTestHandler(t) rr := postEvent(t, h, "backup_failed", "warn") // The event must NOT be lost — that is the whole reason this is a coercion and not a 400. if rr.Code >= 400 { t.Fatalf("HTTP %d — an unknown severity must never LOSE the event; a rejected alarm is worse "+ "than a mis-routed one", rr.Code) } // The STORED effect: coerced to info, exactly as before. events, err := st.GetRecentEvents("c1", 10) if err != nil { t.Fatal(err) } if len(events) != 1 { t.Fatalf("stored %d events, want 1 — the event was lost", len(events)) } if events[0].Severity != "info" { t.Errorf("stored severity = %q, want \"info\" — the coercion behaviour must not change", events[0].Severity) } // THE FIX: it is no longer silent. out := logs.String() if !strings.Contains(out, "WARN") { t.Fatalf("the hub rewrote a severity and said nothing — this is R-387, and it is how two "+ "features shipped undeliverable for months. log=%q", out) } for _, want := range []string{`"warn"`, "c1", "backup_failed"} { if !strings.Contains(out, want) { t.Errorf("the log line does not name %q, so nobody can act on it: %q", want, out) } } } // A VALID severity must stay silent — a guard that fires on the normal path is one people learn to // ignore, which is the same failure one door over. func TestR387_ValidSeveritiesAreSilent(t *testing.T) { for _, sev := range []string{"info", "warning", "error", "critical"} { h, _, logs := severityTestHandler(t) if rr := postEvent(t, h, "backup_failed", sev); rr.Code >= 400 { t.Fatalf("severity %q: HTTP %d", sev, rr.Code) } if strings.Contains(logs.String(), "not in {info,warning,error,critical}") { t.Errorf("severity %q produced a coercion warning: %q", sev, logs.String()) } } } // The asymmetry this finding is about, pinned so it stays deliberate: an unknown TYPE is still // rejected loudly, an unknown SEVERITY is still accepted-and-logged. Two different answers, both on // purpose, and now both visible. func TestR387_UnknownTypeStillRejectedUnknownSeverityStillAccepted(t *testing.T) { h, st, _ := severityTestHandler(t) if rr := postEvent(t, h, "no_such_event_type", "warning"); rr.Code != 400 { t.Errorf("unknown event_type: HTTP %d, want 400 — an unlisted type must stay a loud refusal", rr.Code) } if rr := postEvent(t, h, "backup_failed", "nonsense"); rr.Code >= 400 { t.Errorf("unknown severity: HTTP %d, want <400 — it must be accepted and logged, never lost", rr.Code) } events, err := st.GetRecentEvents("c1", 10) if err != nil { t.Fatal(err) } if len(events) != 1 { t.Errorf("stored %d events, want exactly 1 (the bad-severity one; the bad-type one must not "+ "be stored)", len(events)) } }