Files
go-otel/internal/storage/csp_integration_test.go
developer 36f00d49b9
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m3s
CSP violation reports: ingest endpoint, ClickHouse storage, UI view
- POST /v1/csp collects browser CSP reports in both wire formats (legacy
  application/csp-report and Reporting API application/reports+json), with
  CORS limited to the operator's domains (any subdomain of k6n.net,
  tornberg.me, knatofs.se) and preflight handling.
- Reports are stored in a dedicated typed ClickHouse table (csp_reports,
  90-day TTL) with a JSONL file fallback (OTEL_CSP_PATH).
- New authenticated view endpoints /api/csp/reports and /api/csp/summary
  plus a 'CSP reports' screen in the UI (tab 12) with time window, site and
  directive filters and report drill-down.
- Documented in instrumentation.md; unit tests for parsing/handlers/file
  store and an env-gated ClickHouse integration test.
2026-09-23 16:50:43 +00:00

78 lines
3.0 KiB
Go

package storage
import (
"context"
"os"
"testing"
"time"
)
// TestCSPReportsIntegration is skipped unless OTEL_CLICKHOUSE_TEST_ADDR
// points at a disposable ClickHouse (native port 9000). It exercises the
// real SQL: schema creation, typed insert and the view's queries.
func TestCSPReportsIntegration(t *testing.T) {
addr := os.Getenv("OTEL_CLICKHOUSE_TEST_ADDR")
if addr == "" {
t.Skip("set OTEL_CLICKHOUSE_TEST_ADDR=host:9000 (with a disposable DB) to run")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
db := "otel_csp_test_" + time.Now().UTC().Format("150405")
s, err := NewClickHouseStore(ctx, ClickHouseConfig{Addr: addr, Database: db, Username: os.Getenv("OTEL_CLICKHOUSE_TEST_USER"), Password: os.Getenv("OTEL_CLICKHOUSE_TEST_PASSWORD")})
if err != nil {
t.Skipf("ClickHouse not reachable at %s: %v", addr, err)
}
defer func() {
_ = s.conn.Exec(ctx, "DROP DATABASE IF EXISTS "+db)
_ = s.Close()
}()
now := time.Now().UTC().Truncate(time.Millisecond)
reports := []CSPReport{
{ID: "r1", Timestamp: now, Site: "app.k6n.net", DocumentURL: "https://app.k6n.net/p", BlockedURL: "https://ads.example/x.js", EffectiveDirective: "script-src", Disposition: "enforce", StatusCode: 200, Sample: "evil()", ReportType: "csp-report", UserAgent: "UA/1", SourceIP: "203.0.113.1"},
{ID: "r2", Timestamp: now.Add(-time.Minute), Site: "docs.tornberg.me", DocumentURL: "https://docs.tornberg.me/", BlockedURL: "inline", EffectiveDirective: "style-src", Disposition: "report", ReportType: "csp-violation"},
}
for _, r := range reports {
if err := s.AppendCSPReport(ctx, r); err != nil {
t.Fatalf("append: %v", err)
}
}
got, err := s.CSPReports(ctx, CSPReportsQuery{Limit: 10})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 || got[0].ID != "r1" {
t.Fatalf("list: got %+v", got)
}
if got[0].Site != "app.k6n.net" || got[0].StatusCode != 200 || got[0].Sample != "evil()" || got[0].Timestamp != now {
t.Errorf("round-trip mismatch: %+v", got[0])
}
if got, err = s.CSPReports(ctx, CSPReportsQuery{Site: "docs.tornberg.me"}); err != nil || len(got) != 1 {
t.Errorf("site filter: %v %+v", err, got)
}
if got, err = s.CSPReports(ctx, CSPReportsQuery{Directive: "style-src"}); err != nil || len(got) != 1 || got[0].ID != "r2" {
t.Errorf("directive filter: %v %+v", err, got)
}
if got, err = s.CSPReports(ctx, CSPReportsQuery{Search: "ads.example"}); err != nil || len(got) != 1 {
t.Errorf("search filter: %v %+v", err, got)
}
sum, err := s.CSPSummary(ctx, 24)
if err != nil {
t.Fatalf("summary: %v", err)
}
if sum.Total != 2 || sum.Sites != 2 {
t.Errorf("summary: %+v", sum)
}
if len(sum.ByDirective) != 2 {
t.Errorf("summary directives: %+v", sum.ByDirective)
}
if len(sum.ByBlockedURL) != 2 || sum.ByBlockedURL[0].Name != "ads.example" {
t.Errorf("summary blocked hosts: %+v", sum.ByBlockedURL)
}
if len(sum.BySite) != 2 || sum.BySite[0].Count != 1 {
t.Errorf("summary sites: %+v", sum.BySite)
}
}