1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-03-19 14:17:48 +02:00
pocketbase/tools/router/event_test.go
2024-11-25 09:11:58 +02:00

960 lines
24 KiB
Go

package router_test
import (
"bytes"
"crypto/tls"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/router"
)
type unwrapTester struct {
http.ResponseWriter
}
func (ut unwrapTester) Unwrap() http.ResponseWriter {
return ut.ResponseWriter
}
func TestEventWritten(t *testing.T) {
t.Parallel()
res1 := httptest.NewRecorder()
res2 := httptest.NewRecorder()
res2.Write([]byte("test"))
res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4.Write([]byte("test"))
scenarios := []struct {
name string
response http.ResponseWriter
expected bool
}{
{
name: "non-written non-WriteTracker",
response: res1,
expected: false,
},
{
name: "written non-WriteTracker",
response: res2,
expected: false,
},
{
name: "non-written WriteTracker",
response: res3,
expected: false,
},
{
name: "written WriteTracker",
response: res4,
expected: true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
event := router.Event{
Response: s.response,
}
result := event.Written()
if result != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestEventStatus(t *testing.T) {
t.Parallel()
res1 := httptest.NewRecorder()
res2 := httptest.NewRecorder()
res2.WriteHeader(123)
res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4.WriteHeader(123)
scenarios := []struct {
name string
response http.ResponseWriter
expected int
}{
{
name: "non-written non-StatusTracker",
response: res1,
expected: 0,
},
{
name: "written non-StatusTracker",
response: res2,
expected: 0,
},
{
name: "non-written StatusTracker",
response: res3,
expected: 0,
},
{
name: "written StatusTracker",
response: res4,
expected: 123,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
event := router.Event{
Response: s.response,
}
result := event.Status()
if result != s.expected {
t.Fatalf("Expected %d, got %d", s.expected, result)
}
})
}
}
func TestEventIsTLS(t *testing.T) {
t.Parallel()
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
event := router.Event{Request: req}
// without TLS
if event.IsTLS() {
t.Fatalf("Expected IsTLS false")
}
// dummy TLS state
req.TLS = new(tls.ConnectionState)
// with TLS
if !event.IsTLS() {
t.Fatalf("Expected IsTLS true")
}
}
func TestEventSetCookie(t *testing.T) {
t.Parallel()
event := router.Event{
Response: httptest.NewRecorder(),
}
cookie := event.Response.Header().Get("set-cookie")
if cookie != "" {
t.Fatalf("Expected empty cookie string, got %q", cookie)
}
event.SetCookie(&http.Cookie{Name: "test", Value: "a"})
expected := "test=a"
cookie = event.Response.Header().Get("set-cookie")
if cookie != expected {
t.Fatalf("Expected cookie %q, got %q", expected, cookie)
}
}
func TestEventRemoteIP(t *testing.T) {
t.Parallel()
scenarios := []struct {
remoteAddr string
expected string
}{
{"", "invalid IP"},
{"1.2.3.4", "invalid IP"},
{"1.2.3.4:8090", "1.2.3.4"},
{"[0000:0000:0000:0000:0000:0000:0000:0002]:80", "0000:0000:0000:0000:0000:0000:0000:0002"},
{"[::2]:80", "0000:0000:0000:0000:0000:0000:0000:0002"}, // should always return the expanded version
}
for _, s := range scenarios {
t.Run(s.remoteAddr, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
req.RemoteAddr = s.remoteAddr
event := router.Event{Request: req}
ip := event.RemoteIP()
if ip != s.expected {
t.Fatalf("Expected IP %q, got %q", s.expected, ip)
}
})
}
}
func TestFindUploadedFiles(t *testing.T) {
scenarios := []struct {
filename string
expectedPattern string
}{
{"ab.png", `^ab\w{10}_\w{10}\.png$`},
{"test", `^test_\w{10}\.txt$`},
{"a b c d!@$.j!@$pg", `^a_b_c_d_\w{10}\.jpg$`},
{strings.Repeat("a", 150), `^a{100}_\w{10}\.txt$`},
}
for _, s := range scenarios {
t.Run(s.filename, func(t *testing.T) {
// create multipart form file body
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
w, err := mp.CreateFormFile("test", s.filename)
if err != nil {
t.Fatal(err)
}
w.Write([]byte("test"))
mp.Close()
// ---
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Add("Content-Type", mp.FormDataContentType())
event := router.Event{Request: req}
result, err := event.FindUploadedFiles("test")
if err != nil {
t.Fatal(err)
}
if len(result) != 1 {
t.Fatalf("Expected 1 file, got %d", len(result))
}
if result[0].Size != 4 {
t.Fatalf("Expected the file size to be 4 bytes, got %d", result[0].Size)
}
pattern, err := regexp.Compile(s.expectedPattern)
if err != nil {
t.Fatalf("Invalid filename pattern %q: %v", s.expectedPattern, err)
}
if !pattern.MatchString(result[0].Name) {
t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name)
}
})
}
}
func TestFindUploadedFilesMissing(t *testing.T) {
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
mp.Close()
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Add("Content-Type", mp.FormDataContentType())
event := router.Event{Request: req}
result, err := event.FindUploadedFiles("test")
if err == nil {
t.Error("Expected error, got nil")
}
if result != nil {
t.Errorf("Expected result to be nil, got %v", result)
}
}
func TestEventSetGet(t *testing.T) {
event := router.Event{}
// get before any set (ensures that doesn't panic)
if v := event.Get("test"); v != nil {
t.Fatalf("Expected nil value, got %v", v)
}
event.Set("a", 123)
event.Set("b", 456)
scenarios := []struct {
key string
expected any
}{
{"", nil},
{"missing", nil},
{"a", 123},
{"b", 456},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) {
result := event.Get(s.key)
if result != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestEventSetAllGetAll(t *testing.T) {
data := map[string]any{
"a": 123,
"b": 456,
}
rawData, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
event := router.Event{}
event.SetAll(data)
// modify the data to ensure that the map was shallow coppied
data["c"] = 789
result := event.GetAll()
rawResult, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if len(rawResult) == 0 || !bytes.Equal(rawData, rawResult) {
t.Fatalf("Expected\n%v\ngot\n%v", rawData, rawResult)
}
}
func TestEventString(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
status: 123,
headers: nil,
body: "test",
expectedStatus: 123,
expectedHeaders: map[string]string{"content-type": "text/plain; charset=utf-8"},
expectedBody: "test",
},
{
name: "with explicit content-type",
status: 123,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 123,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.String(s.status, s.body)
})
}
}
func TestEventHTML(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
status: 123,
headers: nil,
body: "test",
expectedStatus: 123,
expectedHeaders: map[string]string{"content-type": "text/html; charset=utf-8"},
expectedBody: "test",
},
{
name: "with explicit content-type",
status: 123,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 123,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.HTML(s.status, s.body)
})
}
}
func TestEventJSON(t *testing.T) {
body := map[string]any{"a": 123, "b": 456, "c": "test"}
expectedPickedBody := `{"a":123,"c":"test"}` + "\n"
expectedFullBody := `{"a":123,"b":456,"c":"test"}` + "\n"
scenarios := []testResponseWriteScenario[any]{
{
name: "no explicit content-type",
status: 200,
headers: nil,
body: body,
expectedStatus: 200,
expectedHeaders: map[string]string{"content-type": "application/json"},
expectedBody: expectedPickedBody,
},
{
name: "with explicit content-type (200)",
status: 200,
headers: map[string]string{"content-type": "application/test"},
body: body,
expectedStatus: 200,
expectedHeaders: map[string]string{"content-type": "application/test"},
expectedBody: expectedPickedBody,
},
{
name: "with explicit content-type (400)", // no fields picker
status: 400,
headers: map[string]string{"content-type": "application/test"},
body: body,
expectedStatus: 400,
expectedHeaders: map[string]string{"content-type": "application/test"},
expectedBody: expectedFullBody,
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
e.Request.URL.RawQuery = "fields=a,c" // ensures that the picker is invoked
return e.JSON(s.status, s.body)
})
}
}
func TestEventXML(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
status: 234,
headers: nil,
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "application/xml; charset=utf-8"},
expectedBody: xml.Header + "<string>test</string>",
},
{
name: "with explicit content-type",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: xml.Header + "<string>test</string>",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.XML(s.status, s.body)
})
}
}
func TestEventStream(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "stream",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.Stream(s.status, s.headers["content-type"], strings.NewReader(s.body))
})
}
}
func TestEventBlob(t *testing.T) {
scenarios := []testResponseWriteScenario[[]byte]{
{
name: "blob",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: []byte("test"),
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.Blob(s.status, s.headers["content-type"], s.body)
})
}
}
func TestEventNoContent(t *testing.T) {
s := testResponseWriteScenario[any]{
name: "no content",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: nil,
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "",
}
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.NoContent(s.status)
})
}
func TestEventFlush(t *testing.T) {
rec := httptest.NewRecorder()
event := &router.Event{
Response: unwrapTester{&router.ResponseWriter{ResponseWriter: rec}},
}
event.Response.Write([]byte("test"))
event.Flush()
if !rec.Flushed {
t.Fatal("Expected response to be flushed")
}
}
func TestEventRedirect(t *testing.T) {
scenarios := []testResponseWriteScenario[any]{
{
name: "non-30x status",
status: 200,
expectedStatus: 200,
expectedError: router.ErrInvalidRedirectStatusCode,
},
{
name: "30x status",
status: 302,
headers: map[string]string{"location": "test"}, // should be overwritten with the argument
expectedStatus: 302,
expectedHeaders: map[string]string{"location": "example"},
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.Redirect(s.status, "example")
})
}
}
func TestEventFileFS(t *testing.T) {
// stub test files
// ---
dir, err := os.MkdirTemp("", "EventFileFS")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
err = os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644)
if err != nil {
t.Fatal(err)
}
// create sub directory with an index.html file inside it
err = os.MkdirAll(filepath.Join(dir, "sub1"), os.ModePerm)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "sub1", "index.html"), []byte("sub1 index"), 0644)
if err != nil {
t.Fatal(err)
}
err = os.MkdirAll(filepath.Join(dir, "sub2"), os.ModePerm)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "sub2", "test.txt"), []byte("sub2 test"), 0644)
if err != nil {
t.Fatal(err)
}
// ---
scenarios := []struct {
name string
path string
expected string
}{
{"missing file", "", ""},
{"root with no explicit file", "", ""},
{"root with explicit file", "test.txt", "test"},
{"sub dir with no explicit file", "sub1", "sub1 index"},
{"sub dir with no explicit file (no index.html)", "sub2", ""},
{"sub dir explicit file", "sub2/test.txt", "sub2 test"},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
event := &router.Event{
Request: req,
Response: rec,
}
err = event.FileFS(os.DirFS(dir), s.path)
hasErr := err != nil
expectErr := s.expected == ""
if hasErr != expectErr {
t.Fatalf("Expected hasErr %v, got %v (%v)", expectErr, hasErr, err)
}
result := rec.Result()
raw, err := io.ReadAll(result.Body)
result.Body.Close()
if err != nil {
t.Fatal(err)
}
if string(raw) != s.expected {
t.Fatalf("Expected body\n%s\ngot\n%s", s.expected, raw)
}
// ensure that the proper file headers are added
// (aka. http.ServeContent is invoked)
length, _ := strconv.Atoi(result.Header.Get("content-length"))
if length != len(s.expected) {
t.Fatalf("Expected Content-Length %d, got %d", len(s.expected), length)
}
})
}
}
func TestEventError(t *testing.T) {
err := new(router.Event).Error(123, "message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":123}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventBadRequestError(t *testing.T) {
err := new(router.Event).BadRequestError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":400}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventNotFoundError(t *testing.T) {
err := new(router.Event).NotFoundError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":404}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventForbiddenError(t *testing.T) {
err := new(router.Event).ForbiddenError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":403}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventUnauthorizedError(t *testing.T) {
err := new(router.Event).UnauthorizedError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":401}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventTooManyRequestsError(t *testing.T) {
err := new(router.Event).TooManyRequestsError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":429}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventInternalServerError(t *testing.T) {
err := new(router.Event).InternalServerError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":500}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventBindBody(t *testing.T) {
type testDstStruct struct {
A int `json:"a" xml:"a" form:"a"`
B int `json:"b" xml:"b" form:"b"`
C string `json:"c" xml:"c" form:"c"`
}
emptyDst := `{"a":0,"b":0,"c":""}`
queryDst := `a=123&b=-456&c=test`
xmlDst := `
<?xml version="1.0" encoding="UTF-8" ?>
<root>
<a>123</a>
<b>-456</b>
<c>test</c>
</root>
`
jsonDst := `{"a":123,"b":-456,"c":"test"}`
// multipart
mpBody := &bytes.Buffer{}
mpWriter := multipart.NewWriter(mpBody)
mpWriter.WriteField("@jsonPayload", `{"a":123}`)
mpWriter.WriteField("b", "-456")
mpWriter.WriteField("c", "test")
if err := mpWriter.Close(); err != nil {
t.Fatal(err)
}
scenarios := []struct {
contentType string
body io.Reader
expectDst string
expectError bool
}{
{
contentType: "",
body: strings.NewReader(jsonDst),
expectDst: emptyDst,
expectError: true,
},
{
contentType: "application/rtf", // unsupported
body: strings.NewReader(jsonDst),
expectDst: emptyDst,
expectError: true,
},
// empty body
{
contentType: "application/json;charset=emptybody",
body: strings.NewReader(""),
expectDst: emptyDst,
},
// json
{
contentType: "application/json",
body: strings.NewReader(jsonDst),
expectDst: jsonDst,
},
{
contentType: "application/json;charset=abc",
body: strings.NewReader(jsonDst),
expectDst: jsonDst,
},
// xml
{
contentType: "text/xml",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
{
contentType: "text/xml;charset=abc",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
{
contentType: "application/xml",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
{
contentType: "application/xml;charset=abc",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
// x-www-form-urlencoded
{
contentType: "application/x-www-form-urlencoded",
body: strings.NewReader(queryDst),
expectDst: jsonDst,
},
{
contentType: "application/x-www-form-urlencoded;charset=abc",
body: strings.NewReader(queryDst),
expectDst: jsonDst,
},
// multipart
{
contentType: mpWriter.FormDataContentType(),
body: mpBody,
expectDst: jsonDst,
},
}
for _, s := range scenarios {
t.Run(s.contentType, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "/", s.body)
if err != nil {
t.Fatal(err)
}
req.Header.Add("content-type", s.contentType)
event := &router.Event{Request: req}
dst := testDstStruct{}
err = event.BindBody(&dst)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
dstRaw, err := json.Marshal(dst)
if err != nil {
t.Fatal(err)
}
if string(dstRaw) != s.expectDst {
t.Fatalf("Expected dst\n%s\ngot\n%s", s.expectDst, dstRaw)
}
})
}
}
// -------------------------------------------------------------------
type testResponseWriteScenario[T any] struct {
name string
status int
headers map[string]string
body T
expectedStatus int
expectedHeaders map[string]string
expectedBody string
expectedError error
}
func testEventResponseWrite[T any](
t *testing.T,
scenario testResponseWriteScenario[T],
writeFunc func(e *router.Event) error,
) {
t.Run(scenario.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
event := &router.Event{
Request: req,
Response: &router.ResponseWriter{ResponseWriter: rec},
}
for k, v := range scenario.headers {
event.Response.Header().Add(k, v)
}
err = writeFunc(event)
if (scenario.expectedError != nil || err != nil) && !errors.Is(err, scenario.expectedError) {
t.Fatalf("Expected error %v, got %v", scenario.expectedError, err)
}
result := rec.Result()
if result.StatusCode != scenario.expectedStatus {
t.Fatalf("Expected status code %d, got %d", scenario.expectedStatus, result.StatusCode)
}
resultBody, err := io.ReadAll(result.Body)
result.Body.Close()
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
resultBody, err = json.Marshal(string(resultBody))
if err != nil {
t.Fatal(err)
}
expectedBody, err := json.Marshal(scenario.expectedBody)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(resultBody, expectedBody) {
t.Fatalf("Expected body\n%s\ngot\n%s", expectedBody, resultBody)
}
for k, ev := range scenario.expectedHeaders {
if v := result.Header.Get(k); v != ev {
t.Fatalf("Expected %q header to be %q, got %q", k, ev, v)
}
}
})
}