366 lines
11 KiB
Go
366 lines
11 KiB
Go
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, "</html>") {
|
|
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)
|
|
}
|
|
}
|