// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "github.com/gorilla/mux" ) // dbTestServer builds a WebServer wired to the isolated test_pdnsapi_ // prefixed Store, with the real Routes()/middleware stack, and reseeds the // known example.com. fixture before returning. Callers get a fresh httptest // server per test. func dbTestServer(t *testing.T) (*httptest.Server, *Config) { t.Helper() createTestSchema(t) db := requireDB(t) reseed(t, db, exampleComSeed()) cfg := testConfig() ws := &WebServer{ Config: cfg, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), DB: db, Store: NewStore(db, testTablePrefix), } r := newRouter(ws) srv := httptest.NewServer(r) t.Cleanup(srv.Close) return srv, cfg } func newRouter(ws *WebServer) http.Handler { router := mux.NewRouter() router.Use(ws.loggingMiddleware) ws.Routes(router) return router } // doReq issues an authenticated request against srv and returns the response // with the body read into memory (so callers don't need to remember Close). func doReq(t *testing.T, srv *httptest.Server, apiKey, method, path string, body []byte) (*http.Response, []byte) { t.Helper() var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequest(method, srv.URL+path, rdr) if err != nil { t.Fatal(err) } if apiKey != "" { req.Header.Set("X-API-Key", apiKey) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() got, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } return resp, got } // --- healthz ------------------------------------------------------------- func TestHealthzReachableWithoutKey(t *testing.T) { // /healthz must be reachable without X-API-Key, and must succeed given a // live DB handle (the shape it always has when actually serving — // main.go exits before serving if OpenDB fails). srv, _ := dbTestServer(t) resp, err := http.Get(srv.URL + "/healthz") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { t.Fatalf("/healthz should not require auth, got 401") } if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var body map[string]string if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatal(err) } if body["status"] != "ok" { t.Fatalf("status field = %q, want ok", body["status"]) } } // --- zone listing -------------------------------------------------------- func TestHandleListZones(t *testing.T) { srv, cfg := dbTestServer(t) resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } var zones []pdnsZone if err := json.Unmarshal(body, &zones); err != nil { t.Fatalf("unmarshal: %v; body = %s", err, body) } if len(zones) != 1 { t.Fatalf("got %d zones, want 1: %+v", len(zones), zones) } z := zones[0] if z.ID != "example.com." || z.Name != "example.com." { t.Fatalf("zone = %+v, want id/name example.com. with trailing dot", z) } wantURL := "/api/v1/servers/localhost/zones/example.com." if z.URL != wantURL { t.Fatalf("url = %q, want %q", z.URL, wantURL) } } // --- zone detail: rrset shape -------------------------------------------- func TestHandleZoneDetail_RRsetAggregation(t *testing.T) { srv, cfg := dbTestServer(t) resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } var detail pdnsZoneDetail if err := json.Unmarshal(body, &detail); err != nil { t.Fatalf("unmarshal: %v; body = %s", err, body) } // multi TXT rows (two rows, same name/type) must aggregate into ONE // rrset with both records. var multiTXT *pdnsRRset for i := range detail.RRsets { if detail.RRsets[i].Type == "TXT" && strings.HasPrefix(detail.RRsets[i].Name, "multi.") { multiTXT = &detail.RRsets[i] } } if multiTXT == nil { t.Fatalf("no multi.example.com. TXT rrset found in %+v", detail.RRsets) } if len(multiTXT.Records) != 2 { t.Fatalf("multi TXT rrset has %d records, want 2: %+v", len(multiTXT.Records), multiTXT.Records) } contents := map[string]bool{} for _, r := range multiTXT.Records { contents[r.Content] = true } if !contents[`"first"`] || !contents[`"second"`] { t.Fatalf("multi TXT contents = %v, want both \"first\" and \"second\"", contents) } // apex rows (name == '') must appear as the zone FQDN. var apexFound bool for _, rr := range detail.RRsets { if rr.Type == "SOA" { apexFound = true if rr.Name != "example.com." { t.Fatalf("apex SOA rrset name = %q, want example.com.", rr.Name) } } } if !apexFound { t.Fatalf("no SOA rrset found in %+v", detail.RRsets) } } func TestHandleZoneDetail_FieldOrder(t *testing.T) { srv, cfg := dbTestServer(t) _, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil) // acme.sh regex-scrapes expecting "name" before "records" within each // rrset object. Assert on the raw body, not a re-marshalled map (which // would lose field order). nameIdx := strings.Index(string(body), `"name":"www.example.com."`) if nameIdx == -1 { t.Fatalf("did not find www.example.com. rrset name in body: %s", body) } rest := string(body)[nameIdx:] typeIdx := strings.Index(rest, `"type"`) ttlIdx := strings.Index(rest, `"ttl"`) recordsIdx := strings.Index(rest, `"records"`) if typeIdx == -1 || ttlIdx == -1 || recordsIdx == -1 { t.Fatalf("missing expected fields after name in: %s", rest) } if !(typeIdx < ttlIdx && ttlIdx < recordsIdx) { t.Fatalf("field order wrong: type@%d ttl@%d records@%d, want type 422 with {"error": "malformed // JSON body"}. Asserting current behavior per instructions. resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(`{not valid json`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("status = %d, body = %s, want 422 (current behavior)", resp.StatusCode, body) } var errBody map[string]string if err := json.Unmarshal(body, &errBody); err != nil { t.Fatalf("error body not JSON: %v; body = %s", err, body) } if _, ok := errBody["error"]; !ok { t.Fatalf("error body missing \"error\" key: %s", body) } }