You've already forked coredns-powerdns-api-wrapper
first commit
This commit is contained in:
492
pdns_test.go
Normal file
492
pdns_test.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user