package web_test import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/owen/vibedns/internal/api" "github.com/owen/vibedns/internal/app" "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/config" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/web" ) const testPassword = "an-adequate-test-phrase" // newTestServer builds the whole HTTP stack against a temporary database and // seeds one of every object, so page rendering is exercised with real data // rather than only against empty tables. func newTestServer(t *testing.T) (http.Handler, *app.App) { t.Helper() ctx := context.Background() path := filepath.Join(t.TempDir(), "web.db") db, err := database.Open(path) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { db.Close() }) if _, err := db.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } log := slog.New(slog.NewTextHandler(io.Discard, nil)) boot := config.DefaultBootstrap() boot.DBPath = path a, err := app.New(ctx, boot, db, log) if err != nil { t.Fatalf("build app: %v", err) } if _, _, err := a.Auth.EnsureAdmin(ctx, "admin", testPassword); err != nil { t.Fatalf("create admin: %v", err) } actor := auditlog.Actor{Name: "test", Source: auditlog.SourceCLI} zone, err := a.CreateZone(ctx, actor, app.ZoneInput{Name: "example.com"}) if err != nil { t.Fatalf("seed zone: %v", err) } for _, in := range []app.RecordInput{ {Name: "@", Type: "A", Data: "192.0.2.10"}, {Name: "www", Type: "CNAME", Data: "example.com."}, {Name: "txt", Type: "TXT", Data: `"hello"`}, } { if _, err := a.CreateRecord(ctx, actor, zone.ID, in); err != nil { t.Fatalf("seed record: %v", err) } } if _, err := a.CreateZone(ctx, actor, app.ZoneInput{CIDR: "192.168.1.0/24", Kind: "reverse4"}); err != nil { t.Fatalf("seed reverse zone: %v", err) } list, err := a.CreateDomainList(ctx, actor, app.ListInput{Kind: models.KindBlacklist, Name: "Seeded"}) if err != nil { t.Fatalf("seed list: %v", err) } if _, err := a.ImportDomains(ctx, actor, list.ID, strings.NewReader("ads.example\n"), true); err != nil { t.Fatalf("seed domains: %v", err) } policy, err := a.CreatePolicy(ctx, actor, app.PolicyInput{ Name: "Seeded Policy", BlockAction: "nxdomain", ListIDs: []int64{list.ID}, }) if err != nil { t.Fatalf("seed policy: %v", err) } if _, err := a.CreateNetwork(ctx, actor, app.NetworkInput{ Name: "Seeded Net", CIDR: "100.64.30.0/24", PolicyIDs: []int64{policy.ID}, }); err != nil { t.Fatalf("seed network: %v", err) } if _, err := a.CreateAPIToken(ctx, actor, "seeded-token", ""); err != nil { t.Fatalf("seed token: %v", err) } srv, err := web.New(web.Options{App: a, Log: log, API: api.New(a, log).Handler()}) if err != nil { t.Fatalf("build web server: %v", err) } return srv.Handler(), a } func get(t *testing.T, h http.Handler, path string, auth bool) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) if auth { req.SetBasicAuth("admin", testPassword) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // TestEveryPageRenders walks every GET route in the interface. A template that // indexes a value the handler forgot to supply fails here rather than as a 500 // the first time an operator opens that page. func TestEveryPageRenders(t *testing.T) { h, _ := newTestServer(t) paths := []string{ "/", "/dashboard", "/zones", "/zones/reverse", "/zones/new", "/zones/new?kind=reverse4", "/zones/1", "/zones/1/edit", "/zones/1?q=www&type=A&status=enabled", "/records", "/records?search=192.0.2&type=A", "/resolver", "/cache", "/cache?q=example", "/policies", "/policies/networks", "/policies/networks/new", "/policies/networks/1", "/policies/rules/new", "/policies/rules/1", "/policies/blacklists", "/policies/allowlists", "/policies/lists/1", "/policies/lists/1?q=ads", "/querylog", "/querylog?domain=example&blocked=blocked", "/audit", "/audit?q=zone", "/tools", "/account", "/settings", "/settings/dns", "/settings/resolver", "/settings/cache", "/settings/logging", "/settings/http", "/settings/database", "/settings/api", } for _, p := range paths { t.Run(p, func(t *testing.T) { rec := get(t, h, p, true) if rec.Code >= 500 { t.Fatalf("GET %s = %d\n%s", p, rec.Code, truncateBody(rec.Body.String())) } if rec.Code != http.StatusOK && rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { t.Errorf("GET %s = %d, want 200 or a redirect", p, rec.Code) } // A rendered page must actually contain the layout, not a stub. if rec.Code == http.StatusOK && strings.Contains(rec.Header().Get("Content-Type"), "text/html") { body := rec.Body.String() if !strings.Contains(body, "") { t.Errorf("GET %s produced a truncated page", p) } } }) } } func truncateBody(s string) string { if len(s) > 800 { return s[:800] + "..." } return s } // TestPagesRequireAuthentication is the guard that every administrative route // is actually protected. func TestPagesRequireAuthentication(t *testing.T) { h, _ := newTestServer(t) protected := []string{ "/", "/zones", "/records", "/resolver", "/cache", "/policies", "/policies/networks", "/policies/blacklists", "/querylog", "/audit", "/tools", "/account", "/settings/dns", "/settings/api", "/api/v1/zones", "/api/v1/settings", "/api/v1/stats", } for _, p := range protected { t.Run(p, func(t *testing.T) { if rec := get(t, h, p, false); rec.Code != http.StatusUnauthorized { t.Errorf("GET %s without credentials = %d, want 401", p, rec.Code) } }) } } func TestPublicEndpoints(t *testing.T) { h, _ := newTestServer(t) t.Run("healthz", func(t *testing.T) { rec := get(t, h, "/healthz", false) if rec.Code != http.StatusOK { t.Errorf("status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "ok") { t.Errorf("body = %q", rec.Body.String()) } }) t.Run("readyz reports not ready without listeners", func(t *testing.T) { // The DNS listeners are not started in this test, so readiness must // report that rather than claiming everything is fine. rec := get(t, h, "/readyz", false) if rec.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503 when the DNS listeners are down", rec.Code) } }) t.Run("static assets", func(t *testing.T) { for _, p := range []string{ "/static/css/app.css", "/static/css/bootstrap.min.css", "/static/js/app.js", "/static/fonts/bootstrap-icons.woff2", } { rec := get(t, h, p, false) if rec.Code != http.StatusOK { t.Errorf("GET %s = %d, want 200", p, rec.Code) } if rec.Body.Len() == 0 { t.Errorf("GET %s returned an empty body", p) } } }) t.Run("metrics requires auth by default", func(t *testing.T) { if rec := get(t, h, "/metrics", false); rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401: metrics must not be public by default", rec.Code) } rec := get(t, h, "/metrics", true) if rec.Code != http.StatusOK { t.Fatalf("authenticated status = %d, want 200", rec.Code) } for _, want := range []string{"vibedns_dns_queries_total", "vibedns_build_info", "vibedns_cache_entries"} { if !strings.Contains(rec.Body.String(), want) { t.Errorf("metrics output is missing %q", want) } } }) } func TestSecurityHeaders(t *testing.T) { h, _ := newTestServer(t) rec := get(t, h, "/", true) want := map[string]string{ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "same-origin", } for k, v := range want { if got := rec.Header().Get(k); got != v { t.Errorf("header %s = %q, want %q", k, got, v) } } csp := rec.Header().Get("Content-Security-Policy") if csp == "" { t.Fatal("no Content-Security-Policy header") } // The policy must not permit inline scripts: page data travels in data- // attributes precisely so it does not have to. if strings.Contains(csp, "script-src") && strings.Contains(csp, "'unsafe-inline' 'self'") { t.Error("the CSP allows inline scripts") } for _, want := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} { if !strings.Contains(csp, want) { t.Errorf("CSP is missing %q: %s", want, csp) } } } // TestStateChangingRequestNeedsCSRF confirms the protection is actually wired // up, not merely present in the code. func TestStateChangingRequestNeedsCSRF(t *testing.T) { h, _ := newTestServer(t) req := httptest.NewRequest(http.MethodPost, "/zones/1/delete", nil) req.SetBasicAuth("admin", testPassword) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Errorf("POST without a CSRF token = %d, want 403", rec.Code) } } func TestAPIReturnsJSON(t *testing.T) { h, _ := newTestServer(t) rec := get(t, h, "/api/v1/zones", true) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { t.Errorf("content type = %q, want JSON", ct) } if !strings.Contains(rec.Body.String(), "example.com.") { t.Errorf("the seeded zone is missing from the response: %s", truncateBody(rec.Body.String())) } // A missing object must be a JSON 404, not an HTML error page. rec = get(t, h, "/api/v1/zones/9999", true) if rec.Code != http.StatusNotFound { t.Errorf("missing zone status = %d, want 404", rec.Code) } if !strings.Contains(rec.Body.String(), `"code"`) { t.Errorf("error body is not the standard shape: %s", rec.Body.String()) } } func TestAPITokenAuthentication(t *testing.T) { h, a := newTestServer(t) ctx := context.Background() tok, err := a.CreateAPIToken(ctx, auditlog.Actor{Name: "test"}, "auth-test", "") if err != nil { t.Fatalf("create token: %v", err) } if tok.Secret == "" { t.Fatal("no secret returned at creation") } req := httptest.NewRequest(http.MethodGet, "/api/v1/zones", nil) req.Header.Set("Authorization", "Bearer "+tok.Secret) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("bearer token status = %d, want 200", rec.Code) } // A wrong token must be rejected. req = httptest.NewRequest(http.MethodGet, "/api/v1/zones", nil) req.Header.Set("Authorization", "Bearer vibedns_thisisnotarealtokenvalue") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("invalid token status = %d, want 401", rec.Code) } } // TestAPIWriteWithTokenSkipsCSRF: automation must be able to write without // obtaining a CSRF token, since a bearer token cannot be replayed by a browser. func TestAPIWriteWithTokenSkipsCSRF(t *testing.T) { h, a := newTestServer(t) ctx := context.Background() tok, err := a.CreateAPIToken(ctx, auditlog.Actor{Name: "test"}, "write-test", "") if err != nil { t.Fatalf("create token: %v", err) } body := strings.NewReader(`{"name":"api-created.example"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/zones", body) req.Header.Set("Authorization", "Bearer "+tok.Secret) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Errorf("status = %d, want 201: %s", rec.Code, truncateBody(rec.Body.String())) } } func TestNotFoundPage(t *testing.T) { h, _ := newTestServer(t) rec := get(t, h, "/no/such/page", true) if rec.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", rec.Code) } }