Files
2026-07-10 23:53:43 +02:00

493 lines
16 KiB
Go

// 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<ttl<records", typeIdx, ttlIdx, recordsIdx)
}
}
func TestHandleZoneDetail_TrailingDotEquivalence(t *testing.T) {
srv, cfg := dbTestServer(t)
_, bodyWithDot := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
_, bodyNoDot := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com", nil)
if !bytes.Equal(bodyWithDot, bodyNoDot) {
t.Fatalf("zone detail differs with/without trailing dot:\nwith: %s\nwithout: %s", bodyWithDot, bodyNoDot)
}
}
// --- TXT quoting both directions -----------------------------------------
func TestTXTQuotingDBToAPI(t *testing.T) {
srv, cfg := dbTestServer(t)
_, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
var detail pdnsZoneDetail
if err := json.Unmarshal(body, &detail); err != nil {
t.Fatal(err)
}
found := false
for _, rr := range detail.RRsets {
if rr.Type == "TXT" {
for _, rec := range rr.Records {
if rec.Content == `"first"` || rec.Content == `"second"` {
found = true
}
}
}
}
if !found {
t.Fatalf("expected quoted TXT content \"first\"/\"second\" in %+v", detail.RRsets)
}
}
func TestTXTQuotingAPIToDB(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
patchBody := `{"rrsets":[{"name":"tok.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"tok\"","disabled":false}]}]}`
resp, respBody := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, respBody)
}
var content string
err := db.QueryRowContext(context.Background(),
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "tok", "TXT").Scan(&content)
if err != nil {
t.Fatal(err)
}
if content != `{"text":"tok"}` {
t.Fatalf("stored content = %q, want {\"text\":\"tok\"}", content)
}
}
// --- PATCH REPLACE ---------------------------------------------------------
func TestHandlePatchReplace(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
patchBody := `{"rrsets":[{"name":"www.example.com.","type":"CNAME","ttl":60,"changetype":"REPLACE","records":[{"content":"a.example.com.","disabled":false},{"content":"b.example.com.","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
if len(body) != 0 {
t.Fatalf("expected empty body on 204, got %q", body)
}
rows, err := db.QueryContext(context.Background(),
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "www", "CNAME")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var contents []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
contents = append(contents, c)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(contents) != 2 {
t.Fatalf("got %d rows after REPLACE, want exactly 2: %v", len(contents), contents)
}
want := map[string]bool{`{"host":"a.example.com."}`: true, `{"host":"b.example.com."}`: true}
for _, c := range contents {
if !want[c] {
t.Fatalf("unexpected content %q, want one of %v", c, want)
}
}
}
// --- PATCH DELETE -----------------------------------------------------------
func TestHandlePatchDelete(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
// DELETE without ttl/records fields, matching acme.sh's shape.
patchBody := `{"rrsets":[{"name":"www.example.com.","type":"CNAME","changetype":"DELETE"}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
var count int
err := db.QueryRowContext(context.Background(),
fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "www", "CNAME").Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("count = %d after DELETE, want 0", count)
}
}
// --- FQDN normalization -----------------------------------------------------
func TestFQDNNormalization(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
// Uppercase name, zone_id without trailing dot.
patchBody := `{"rrsets":[{"name":"_x.EXAMPLE.COM.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
var name string
err := db.QueryRowContext(context.Background(),
fmt.Sprintf("SELECT name FROM %s WHERE zone=? AND record_type=? AND content=?", testTableName),
"example.com.", "TXT", `{"text":"v"}`).Scan(&name)
if err != nil {
t.Fatalf("query stored row: %v", err)
}
if name != "_x" {
t.Fatalf("stored name = %q, want relative lowercase _x", name)
}
}
func TestFQDNNormalization_OutsideZone422(t *testing.T) {
srv, cfg := dbTestServer(t)
patchBody := `{"rrsets":[{"name":"foo.other.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
}
}
func TestFQDNNormalization_BoundaryCase(t *testing.T) {
srv, cfg := dbTestServer(t)
// "notexample.com." is NOT part of zone "example.com." despite the
// substring match — must be 422, not accepted.
patchBody := `{"rrsets":[{"name":"notexample.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
}
}
// --- notify ------------------------------------------------------------
func TestHandleNotify(t *testing.T) {
srv, cfg := dbTestServer(t)
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PUT", "/api/v1/servers/localhost/zones/example.com./notify", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
want := `{"result":"Notification queued"}` + "\n"
if string(body) != want {
t.Fatalf("body = %q, want %q", body, want)
}
}
func TestHandleNotify_UnknownZone(t *testing.T) {
srv, cfg := dbTestServer(t)
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PUT", "/api/v1/servers/localhost/zones/nope.com./notify", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, body = %s, want 404", resp.StatusCode, body)
}
}
// --- error bodies --------------------------------------------------------
func TestUnknownZoneErrors(t *testing.T) {
srv, cfg := dbTestServer(t)
t.Run("GET", func(t *testing.T) {
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/nope.com.", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
want := `{"error":"Not Found"}` + "\n"
if string(body) != want {
t.Fatalf("body = %q, want %q", body, want)
}
})
t.Run("PATCH", func(t *testing.T) {
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/nope.com.", []byte(`{"rrsets":[]}`))
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
want := `{"error":"Not Found"}` + "\n"
if string(body) != want {
t.Fatalf("body = %q, want %q", body, want)
}
})
}
func TestMalformedRRsets422(t *testing.T) {
srv, cfg := dbTestServer(t)
// ZoneExists (→404) is checked before body decode/validation (→422), so
// every case here must target the seeded existing zone example.com.
tests := []struct {
name string
body string
}{
{"missing changetype", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"records":[{"content":"\"v\""}]}]}`},
{"unknown changetype", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"changetype":"BOGUS","records":[{"content":"\"v\""}]}]}`},
{"REPLACE with 0 records", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[]}]}`},
{"missing name", `{"rrsets":[{"type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\""}]}]}`},
{"missing type", `{"rrsets":[{"name":"www.example.com.","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\""}]}]}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(tt.body))
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s, want 422", 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)
}
})
}
}
func TestMalformedJSONBody(t *testing.T) {
srv, cfg := dbTestServer(t)
// Current behavior: json.Decode failure -> 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)
}
}