package collector import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "git.tornberg.me/go-otel/internal/storage" ) // cspMemStore records ingested reports and serves canned query results. type cspMemStore struct { reports []storage.CSPReport } func (s *cspMemStore) AppendCSPReport(_ context.Context, r storage.CSPReport) error { s.reports = append(s.reports, r) return nil } func (s *cspMemStore) CSPReports(_ context.Context, q storage.CSPReportsQuery) ([]storage.CSPReport, error) { return s.reports, nil } func (s *cspMemStore) CSPSummary(_ context.Context, _ int) (storage.CSPSummary, error) { return storage.CSPSummary{Total: uint64(len(s.reports))}, nil } func TestParseCSPReportsLegacy(t *testing.T) { body := []byte(`{"csp-report":{ "document-uri":"https://app.k6n.net/page?x=1", "referrer":"https://k6n.net/", "blocked-uri":"https://evil.example.com/tracker.js", "violated-directive":"script-src-elem", "effective-directive":"script-src-elem", "original-policy":"script-src 'self'; default-src 'self'", "disposition":"enforce", "status-code":200, "script-sample":"evil()"}}`) reports := parseCSPReports(body) if len(reports) != 1 { t.Fatalf("want 1 report, got %d", len(reports)) } r := reports[0] if r.DocumentURL != "https://app.k6n.net/page?x=1" || r.BlockedURL != "https://evil.example.com/tracker.js" { t.Errorf("urls not parsed: %+v", r) } if r.EffectiveDirective != "script-src-elem" || r.Disposition != "enforce" || r.StatusCode != 200 { t.Errorf("directives/disposition/status not parsed: %+v", r) } if r.OriginalPolicy != "script-src 'self'; default-src 'self'" || r.Sample != "evil()" { t.Errorf("policy/sample not parsed: %+v", r) } } func TestParseCSPReportsReportingAPI(t *testing.T) { body := []byte(`[ {"age":1,"type":"csp-violation","url":"https://docs.tornberg.me/a","user_agent":"Mozilla/5.0", "body":{"documentURL":"https://docs.tornberg.me/a","blockedURL":"inline","effectiveDirective":"style-src", "disposition":"report","statusCode":200,"sample":"color:red","sourceFile":"x.js"}}, {"age":2,"type":"deprecation","body":{}}]`) reports := parseCSPReports(body) if len(reports) != 1 { t.Fatalf("want only the csp-violation report, got %d", len(reports)) } r := reports[0] if r.EffectiveDirective != "style-src" || r.UserAgent != "Mozilla/5.0" || r.ReportType != "csp-violation" { t.Errorf("reporting-api fields not parsed: %+v", r) } } func TestParseCSPReportsRejectsGarbage(t *testing.T) { for _, body := range []string{"", "not json", `{"nope":1}`, `[]`, `[{"body":{}}]`} { if got := parseCSPReports([]byte(body)); got != nil { t.Errorf("parseCSPReports(%q) = %+v, want nil", body, got) } } } func TestHandleCSPIngest(t *testing.T) { store := &cspMemStore{} c := New(nil, 10, nil) c.SetCSPStore(store) // Legacy payload via sendBeacon (text/plain, no UA in body). req := httptest.NewRequest(http.MethodPost, "/v1/csp", strings.NewReader( `{"csp-report":{"document-uri":"https://app.k6n.net/p","blocked-uri":"https://ads.knatofs.se/x.js", "effective-directive":"script-src","disposition":"enforce"}}`)) req.Header.Set("User-Agent", "TestUA/1.0") req.RemoteAddr = "203.0.113.7:1234" rec := httptest.NewRecorder() c.HandleCSPIngest(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("legacy ingest: code=%d body=%s", rec.Code, rec.Body.String()) } if len(store.reports) != 1 { t.Fatalf("legacy ingest: stored %d reports", len(store.reports)) } got := store.reports[0] if got.Site != "app.k6n.net" || got.SourceIP != "203.0.113.7" { t.Errorf("site/source ip not derived: %+v", got) } if got.UserAgent != "TestUA/1.0" || got.ReportType != "csp-report" || got.ID == "" { t.Errorf("ua/type/id not finished: %+v", got) } // Reporting API batch. req = httptest.NewRequest(http.MethodPost, "/v1/csp", strings.NewReader( `[{"type":"csp-violation","body":{"documentURL":"https://www.tornberg.me/","blockedURL":"https://b.example/"}}]`)) req.Header.Set("X-Forwarded-For", "198.51.100.9, 10.0.0.1") rec = httptest.NewRecorder() c.HandleCSPIngest(rec, req) if rec.Code != http.StatusNoContent || len(store.reports) != 2 { t.Fatalf("api ingest: code=%d stored=%d", rec.Code, len(store.reports)) } if got := store.reports[1]; got.Site != "www.tornberg.me" || got.SourceIP != "198.51.100.9" { t.Errorf("api ingest normalization: %+v", got) } // Garbage is rejected; reads stay 405. if rec := post(c, "/v1/csp", "garbage"); rec.Code != http.StatusBadRequest { t.Errorf("garbage: code=%d", rec.Code) } getRec := httptest.NewRecorder() c.HandleCSPIngest(getRec, httptest.NewRequest(http.MethodGet, "/v1/csp", nil)) if getRec.Code != http.StatusMethodNotAllowed { t.Errorf("GET: code=%d", getRec.Code) } } func post(c *Collector, path, body string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() c.HandleCSPIngest(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))) return rec } func TestHandleCSPViews(t *testing.T) { store := &cspMemStore{reports: []storage.CSPReport{{ ID: "abc", Timestamp: time.Now(), Site: "app.k6n.net", EffectiveDirective: "script-src", }}} c := New(nil, 10, nil) c.SetCSPStore(store) rec := httptest.NewRecorder() c.HandleCSPReports(rec, httptest.NewRequest(http.MethodGet, "/api/csp/reports?site=app.k6n.net&hours=24", nil)) if rec.Code != http.StatusOK { t.Fatalf("reports: code=%d", rec.Code) } var list struct { Reports []storage.CSPReport `json:"reports"` } if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list.Reports) != 1 { t.Fatalf("reports: err=%v body=%s", err, rec.Body.String()) } rec = httptest.NewRecorder() c.HandleCSPSummary(rec, httptest.NewRequest(http.MethodGet, "/api/csp/summary?hours=168", nil)) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"total":1`) { t.Fatalf("summary: code=%d body=%s", rec.Code, rec.Body.String()) } // Without a store both views answer 501 politely. c2 := New(nil, 10, nil) rec = httptest.NewRecorder() c2.HandleCSPReports(rec, httptest.NewRequest(http.MethodGet, "/api/csp/reports", nil)) if rec.Code != http.StatusNotImplemented { t.Fatalf("no store: code=%d", rec.Code) } } func TestFinishCSPReportIDStable(t *testing.T) { now := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) a := finishCSPReport(storage.CSPReport{DocumentURL: "https://a.k6n.net/", BlockedURL: "https://b/"}, now, "1.2.3.4", "UA") b := finishCSPReport(storage.CSPReport{DocumentURL: "https://a.k6n.net/", BlockedURL: "https://b/"}, now, "5.6.7.8", "Other") if a.ID != b.ID { // The id intentionally covers report content only, not delivery // context, so duplicate re-deliveries share it. t.Errorf("ids differ across delivery context: %s vs %s", a.ID, b.ID) } if a.Site != "a.k6n.net" || a.Timestamp != now.UTC() { t.Errorf("finish: %+v", a) } }