659 lines
18 KiB
Go
659 lines
18 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi"
|
|
chimw "github.com/go-chi/chi/middleware"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
)
|
|
|
|
// floodRequests is the number of distinct invented paths each flood
|
|
// test drives through the access log.
|
|
const floodRequests = 64
|
|
|
|
// attackerMarker is embedded in every invented path. No access log
|
|
// line for a redirected or rejected request may contain it.
|
|
const attackerMarker = "QQATTACKERTEXTQQ"
|
|
|
|
// maxLineBytes bounds a single access log line whose client-supplied
|
|
// fields are of ordinary size. Well above what the fixed fields need,
|
|
// well below the length of the oversized input the amplification tests
|
|
// send.
|
|
const maxLineBytes = 1024
|
|
|
|
// maxCappedLineBytes bounds a single access log line when every
|
|
// client-supplied field arrives oversized and is truncated to its
|
|
// budget. This is the number the README quotes as the per-line cost an
|
|
// operator sizes log storage against, and it is a bound on the
|
|
// ENCODED line, which is what the operator's disk holds.
|
|
const maxCappedLineBytes = 2560
|
|
|
|
// oversizedSegmentBytes is the length of the single attacker-chosen
|
|
// path segment, query string or header used to show line size does not
|
|
// track input size.
|
|
const oversizedSegmentBytes = 8192
|
|
|
|
// tailMarker is placed at the END of an oversized header value, so its
|
|
// absence from the log proves the value was truncated rather than
|
|
// merely being short.
|
|
const tailMarker = "QQTRUNCATEDTAILQQ"
|
|
|
|
// These mirror the middleware's own budgets, which are unexported.
|
|
// They are duplicated rather than exported so that widening a budget
|
|
// in the middleware has to be restated here deliberately.
|
|
const (
|
|
maxFieldBytes = 512
|
|
maxRequestIDBytes = 128
|
|
maxMethodBytes = 32
|
|
truncationSuffix = "[truncated]"
|
|
unmatchedRouteLiteral = "(unmatched)"
|
|
)
|
|
|
|
// capturingMiddleware returns a Middleware whose logger writes JSON
|
|
// lines into the returned buffer, so the access log can be asserted
|
|
// on directly.
|
|
func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
buf := new(bytes.Buffer)
|
|
log := slog.New(slog.NewJSONHandler(
|
|
buf,
|
|
&slog.HandlerOptions{Level: slog.LevelInfo},
|
|
))
|
|
|
|
cfg := &config.Config{Environment: config.EnvironmentDev}
|
|
|
|
return middleware.NewForTest(log, cfg, nil), buf
|
|
}
|
|
|
|
// capturingTextMiddleware is capturingMiddleware for the other handler
|
|
// internal/logger can select: slog's text handler, which
|
|
// internal/logger/logger.go installs when stderr is a tty. It escapes
|
|
// differently from the JSON one, so the line bound has to be asserted
|
|
// against both.
|
|
func capturingTextMiddleware(
|
|
t *testing.T,
|
|
) (*middleware.Middleware, *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
buf := new(bytes.Buffer)
|
|
log := slog.New(slog.NewTextHandler(
|
|
buf,
|
|
&slog.HandlerOptions{Level: slog.LevelInfo},
|
|
))
|
|
|
|
cfg := &config.Config{Environment: config.EnvironmentDev}
|
|
|
|
return middleware.NewForTest(log, cfg, nil), buf
|
|
}
|
|
|
|
// accessLogRouter mirrors the production route shapes that an
|
|
// unauthenticated client can reach: the public receiver, the
|
|
// authenticated profile route (which redirects to login rather than
|
|
// rejecting outright), the health check (which answers 200 to anyone,
|
|
// behind no rate limiter at all), and a plain static route.
|
|
func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
|
router := chi.NewRouter()
|
|
// Production registers RequestID ahead of Logging, and chi's
|
|
// RequestID passes an inbound X-Request-Id header straight
|
|
// through, so the request_id field is client-supplied too.
|
|
router.Use(chimw.RequestID)
|
|
router.Use(m.Logging())
|
|
|
|
router.Get(
|
|
"/.well-known/healthcheck",
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
)
|
|
|
|
router.HandleFunc(
|
|
"/webhook/{uuid}",
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
// Stands in for the real handler: an unknown entrypoint
|
|
// UUID 404s, a known one succeeds.
|
|
if chi.URLParam(r, "uuid") != "known" {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
)
|
|
|
|
router.Route("/user/{username}", func(r chi.Router) {
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
})
|
|
})
|
|
|
|
boom := func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
}
|
|
|
|
router.Get("/boom", boom)
|
|
// The 5xx branch keeps the concrete path, so it needs a route that
|
|
// answers 500 to a path of the client's choosing: that is where the
|
|
// url field and the header fields are both at their budget on the
|
|
// same line.
|
|
router.Get("/boom/*", boom)
|
|
|
|
return router
|
|
}
|
|
|
|
// accessLogEntries decodes the captured buffer into one map per
|
|
// logged line, holding every line to maxLineBytes.
|
|
func accessLogEntries(
|
|
t *testing.T,
|
|
buf *bytes.Buffer,
|
|
) []map[string]any {
|
|
t.Helper()
|
|
|
|
return accessLogEntriesWithin(t, buf, maxLineBytes)
|
|
}
|
|
|
|
// accessLogEntriesWithin decodes the captured buffer into one map per
|
|
// logged line, holding every line to bound bytes.
|
|
func accessLogEntriesWithin(
|
|
t *testing.T,
|
|
buf *bytes.Buffer,
|
|
bound int,
|
|
) []map[string]any {
|
|
t.Helper()
|
|
|
|
var entries []map[string]any
|
|
|
|
for line := range strings.SplitSeq(
|
|
strings.TrimSpace(buf.String()), "\n",
|
|
) {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
require.LessOrEqual(
|
|
t, len(line), bound,
|
|
"access log line exceeded its bound",
|
|
)
|
|
|
|
var entry map[string]any
|
|
|
|
require.NoError(t, json.Unmarshal([]byte(line), &entry))
|
|
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
// get drives one GET through the router.
|
|
func get(t *testing.T, router *chi.Mux, target string) int {
|
|
t.Helper()
|
|
|
|
return getWithHeaders(t, router, target, nil)
|
|
}
|
|
|
|
// getWithHeaders drives one GET through the router with the supplied
|
|
// request headers set.
|
|
func getWithHeaders(
|
|
t *testing.T,
|
|
router *chi.Mux,
|
|
target string,
|
|
headers map[string]string,
|
|
) int {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, target, nil,
|
|
)
|
|
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
return w.Code
|
|
}
|
|
|
|
// assertFloodIsBounded drives floodRequests distinct invented paths
|
|
// built by pathFor and asserts every logged line names wantURL, that
|
|
// none carries the invented text, and that the line count is exactly
|
|
// one per request.
|
|
func assertFloodIsBounded(
|
|
t *testing.T,
|
|
pathFor func(i int) string,
|
|
wantStatus int,
|
|
wantURL string,
|
|
) {
|
|
t.Helper()
|
|
|
|
m, buf := capturingMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
for i := range floodRequests {
|
|
assert.Equal(t, wantStatus, get(t, router, pathFor(i)))
|
|
}
|
|
|
|
assert.NotContains(
|
|
t, buf.String(), attackerMarker,
|
|
"access log carried attacker-chosen path text",
|
|
)
|
|
|
|
entries := accessLogEntries(t, buf)
|
|
require.Len(t, entries, floodRequests)
|
|
|
|
for _, entry := range entries {
|
|
assert.Equal(t, wantURL, entry["url"])
|
|
assert.InDelta(
|
|
t, float64(wantStatus), entry["status"], 0,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertFloodIsBounded(
|
|
t,
|
|
func(i int) string {
|
|
return "/webhook/" + attackerMarker +
|
|
strings.Repeat("x", i) + "?q=" + attackerMarker
|
|
},
|
|
http.StatusNotFound,
|
|
"/webhook/{uuid}",
|
|
)
|
|
}
|
|
|
|
func TestAccessLog_InventedProfilePathsLogRoutePattern(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The login redirect is a 3xx, not a 4xx, but it is just as free
|
|
// for an unauthenticated client to drive with invented input.
|
|
// The doubled slash is what chi's RoutePattern yields for a
|
|
// mounted subrouter's index route.
|
|
assertFloodIsBounded(
|
|
t,
|
|
func(i int) string {
|
|
return "/user/" + attackerMarker +
|
|
strings.Repeat("x", i) + "/"
|
|
},
|
|
http.StatusSeeOther,
|
|
"/user/{username}//",
|
|
)
|
|
}
|
|
|
|
func TestAccessLog_UnroutablePathsLogFixedLiteral(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertFloodIsBounded(
|
|
t,
|
|
func(i int) string {
|
|
return "/" + attackerMarker + strings.Repeat("x", i)
|
|
},
|
|
http.StatusNotFound,
|
|
"(unmatched)",
|
|
)
|
|
}
|
|
|
|
// oversizedValue builds an 8 KB header value out of repetitions of ch,
|
|
// with the tail marker at its end.
|
|
//
|
|
// The leading 'x' is load-bearing for tab: net/textproto strips leading
|
|
// and trailing whitespace from a header value, so a value that were
|
|
// nothing but tabs would arrive empty over a real connection and the
|
|
// case would prove nothing.
|
|
func oversizedValue(ch string) string {
|
|
return "x" + strings.Repeat(ch, oversizedSegmentBytes) + tailMarker
|
|
}
|
|
|
|
// oversizedHeaders fills every client-supplied header the access log
|
|
// reads with the same value.
|
|
func oversizedHeaders(value string) map[string]string {
|
|
return map[string]string{
|
|
"User-Agent": value,
|
|
"Referer": value,
|
|
"X-Request-Id": value,
|
|
}
|
|
}
|
|
|
|
// sizeCase is one way of pointing 8 KB of client-chosen text at the
|
|
// access log.
|
|
type sizeCase struct {
|
|
target string
|
|
headers map[string]string
|
|
wantStatus int
|
|
wantURL string
|
|
bound int
|
|
}
|
|
|
|
// lineSizeCases enumerates every part of a request that reaches the
|
|
// access log, at 8 KB apiece.
|
|
func lineSizeCases() map[string]sizeCase {
|
|
cases := map[string]sizeCase{
|
|
"oversized path segment": {
|
|
target: "/webhook/" + attackerMarker +
|
|
strings.Repeat("x", oversizedSegmentBytes),
|
|
wantStatus: http.StatusNotFound,
|
|
wantURL: "/webhook/{uuid}",
|
|
bound: maxLineBytes,
|
|
},
|
|
// /.well-known/healthcheck answers 200 to anyone and has no
|
|
// rate limiter in front of it, so an oversized query appended
|
|
// to it would otherwise buy the same amplification as an
|
|
// invented 404 path, unauthenticated and unthrottled.
|
|
"oversized query on an unauthenticated 200": {
|
|
target: "/.well-known/healthcheck?q=" + attackerMarker +
|
|
strings.Repeat("x", oversizedSegmentBytes),
|
|
wantStatus: http.StatusOK,
|
|
wantURL: "/.well-known/healthcheck?(redacted)",
|
|
bound: maxLineBytes,
|
|
},
|
|
// These reach the line on every request, including one whose
|
|
// url field is correctly redacted.
|
|
"oversized headers": {
|
|
target: "/" + attackerMarker,
|
|
headers: oversizedHeaders(oversizedValue("h")),
|
|
wantStatus: http.StatusNotFound,
|
|
wantURL: unmatchedRouteLiteral,
|
|
bound: maxCappedLineBytes,
|
|
},
|
|
}
|
|
|
|
// The url field on a 5xx keeps the concrete path, so it reaches its
|
|
// own budget on the same line as the three header fields. That is
|
|
// the widest access log line the service can be made to write.
|
|
longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes)
|
|
wantLongURL := longPath[:maxFieldBytes] + truncationSuffix
|
|
|
|
// escapeChars are the runes Go's header parser accepts in a header
|
|
// value and the log handler then escapes, coming out wider than
|
|
// they went in. A budget counted in raw bytes lets any of them buy
|
|
// a field several times its nominal size, so every one of them
|
|
// gets a case.
|
|
//
|
|
// The astral one is the case the JSON handler alone does not
|
|
// reach: U+1000C is unassigned, so it is non-printable, and
|
|
// strconv.Quote spells a non-printable rune at or above U+10000
|
|
// as a ten-byte \UXXXXXXXX. The JSON handler passes it through as
|
|
// its four UTF-8 bytes, so only the text-handler shape of this
|
|
// test holds the ten-byte charge honest.
|
|
escapeChars := map[string]string{
|
|
"quote": `"`,
|
|
"backslash": `\`,
|
|
"tab": "\t",
|
|
"astral": "\U0001000C",
|
|
}
|
|
|
|
for kind, char := range escapeChars {
|
|
fill := oversizedValue(char)
|
|
|
|
cases["oversized "+kind+" headers"] = sizeCase{
|
|
target: "/" + attackerMarker,
|
|
headers: oversizedHeaders(fill),
|
|
wantStatus: http.StatusNotFound,
|
|
wantURL: unmatchedRouteLiteral,
|
|
bound: maxCappedLineBytes,
|
|
}
|
|
|
|
cases["oversized "+kind+" headers with a 5xx concrete url"] =
|
|
sizeCase{
|
|
target: longPath,
|
|
headers: oversizedHeaders(fill),
|
|
wantStatus: http.StatusInternalServerError,
|
|
wantURL: wantLongURL,
|
|
bound: maxCappedLineBytes,
|
|
}
|
|
}
|
|
|
|
return cases
|
|
}
|
|
|
|
// TestAccessLog_LineSizeDoesNotTrackInputSize drives 8 KB of
|
|
// client-chosen text at the access log through each part of the
|
|
// request that reaches it, and holds the resulting line to a fixed
|
|
// bound in every case.
|
|
//
|
|
// The bound is on the ENCODED line, so the cases built out of
|
|
// characters the handler escapes are the ones that matter: a budget
|
|
// spent in raw bytes passes every plain-ASCII case here and still
|
|
// writes a line half again as long as the stated ceiling.
|
|
func TestAccessLog_LineSizeDoesNotTrackInputSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.Equal(
|
|
t, middleware.MaxAccessLogLineBytes, maxCappedLineBytes,
|
|
"the README quotes this ceiling and the middleware derives "+
|
|
"it; they have to agree",
|
|
)
|
|
|
|
for name, tc := range lineSizeCases() {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, buf := capturingMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
assert.Equal(
|
|
t,
|
|
tc.wantStatus,
|
|
getWithHeaders(t, router, tc.target, tc.headers),
|
|
)
|
|
|
|
// accessLogEntriesWithin enforces the bound, which is
|
|
// orders of magnitude smaller than the input just sent.
|
|
entries := accessLogEntriesWithin(t, buf, tc.bound)
|
|
require.Len(t, entries, 1)
|
|
assert.Equal(t, tc.wantURL, entries[0]["url"])
|
|
|
|
// The markers sit at the far end of the client-chosen
|
|
// text, so their absence is what proves the redaction and
|
|
// the truncation actually ran.
|
|
assert.NotContains(
|
|
t, buf.String(), attackerMarker,
|
|
"access log carried attacker-chosen text",
|
|
)
|
|
assert.NotContains(
|
|
t, buf.String(), tailMarker,
|
|
"access log carried an untruncated client field",
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAccessLog_LineSizeDoesNotTrackInputSizeOnTheTextHandler runs the
|
|
// same cases through slog's text handler, which internal/logger
|
|
// selects on a tty.
|
|
//
|
|
// MaxAccessLogLineBytes is quoted to operators unqualified, so it has
|
|
// to hold for whichever handler is installed — and the two do not
|
|
// escape alike. The astral case is the one that separates them: the
|
|
// JSON handler emits U+1000C as its four UTF-8 bytes, while
|
|
// strconv.Quote spells it \U0001000C at ten. Charging six for it, as
|
|
// this code did, put a real 2,676-byte line on the wire here while
|
|
// every JSON case stayed comfortably inside the bound.
|
|
//
|
|
// Only the size bound is asserted; the url field's contents are the
|
|
// JSON shape's business above.
|
|
func TestAccessLog_LineSizeDoesNotTrackInputSizeOnTheTextHandler(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
for name, tc := range lineSizeCases() {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, buf := capturingTextMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
assert.Equal(
|
|
t,
|
|
tc.wantStatus,
|
|
getWithHeaders(t, router, tc.target, tc.headers),
|
|
)
|
|
|
|
line := strings.TrimSpace(buf.String())
|
|
|
|
require.NotEmpty(t, line)
|
|
assert.NotContains(
|
|
t, line, "\n", "expected exactly one log line",
|
|
)
|
|
require.LessOrEqual(
|
|
t, len(line), tc.bound,
|
|
"access log line exceeded its bound",
|
|
)
|
|
assert.Contains(t, line, "url=")
|
|
assert.NotContains(
|
|
t, line, attackerMarker,
|
|
"access log carried attacker-chosen text",
|
|
)
|
|
assert.NotContains(
|
|
t, line, tailMarker,
|
|
"access log carried an untruncated client field",
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAccessLog_OversizedMethodIsTruncated covers the last term in the
|
|
// MaxAccessLogLineBytes arithmetic that the size cases above cannot
|
|
// reach: Go accepts any RFC 7230 token as a method, and getWithHeaders
|
|
// only ever sends GET.
|
|
func TestAccessLog_OversizedMethodIsTruncated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, buf := capturingMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
method := strings.Repeat("M", oversizedSegmentBytes) + attackerMarker
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), method, "/"+attackerMarker, nil,
|
|
)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
entries := accessLogEntriesWithin(t, buf, maxLineBytes)
|
|
require.Len(t, entries, 1)
|
|
assert.Equal(
|
|
t,
|
|
strings.Repeat("M", maxMethodBytes)+truncationSuffix,
|
|
entries[0]["method"],
|
|
)
|
|
assert.NotContains(
|
|
t, buf.String(), attackerMarker,
|
|
"access log carried attacker-chosen text",
|
|
)
|
|
}
|
|
|
|
// TestAccessLog_OversizedHeadersKeepATruncatedPrefix checks the other
|
|
// half of the header cap: the fields are cut, not dropped, so a
|
|
// truncated User-Agent is still worth reading.
|
|
func TestAccessLog_OversizedHeadersKeepATruncatedPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, buf := capturingMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
assert.Equal(
|
|
t,
|
|
http.StatusNotFound,
|
|
getWithHeaders(
|
|
t, router, "/nope",
|
|
oversizedHeaders(oversizedValue("h")),
|
|
),
|
|
)
|
|
|
|
entries := accessLogEntriesWithin(t, buf, maxCappedLineBytes)
|
|
require.Len(t, entries, 1)
|
|
|
|
for key, budget := range map[string]int{
|
|
"useragent": maxFieldBytes,
|
|
"referer": maxFieldBytes,
|
|
"request_id": maxRequestIDBytes,
|
|
} {
|
|
value, ok := entries[0][key].(string)
|
|
require.True(t, ok, key)
|
|
assert.LessOrEqual(
|
|
t, len(value), budget+len(truncationSuffix), key,
|
|
)
|
|
assert.Contains(t, value, truncationSuffix, key)
|
|
assert.Contains(t, value, "hhhh", key)
|
|
}
|
|
}
|
|
|
|
func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m, buf := capturingMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
assert.Equal(
|
|
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
|
|
)
|
|
|
|
// The path resolved against a stored entrypoint, so it stays. The
|
|
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
|
entries := accessLogEntries(t, buf)
|
|
require.Len(t, entries, 1)
|
|
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
|
|
assert.NotContains(t, buf.String(), "src=ci")
|
|
}
|
|
|
|
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, buf := capturingMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
assert.Equal(
|
|
t, http.StatusInternalServerError, get(t, router, "/boom"),
|
|
)
|
|
|
|
entries := accessLogEntries(t, buf)
|
|
require.Len(t, entries, 1)
|
|
assert.Equal(t, "/boom", entries[0]["url"])
|
|
}
|
|
|
|
func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, buf := capturingMiddleware(t)
|
|
router := accessLogRouter(m)
|
|
|
|
assert.Equal(
|
|
t,
|
|
http.StatusNotFound,
|
|
get(t, router, "/webhook/"+attackerMarker),
|
|
)
|
|
|
|
entries := accessLogEntries(t, buf)
|
|
require.Len(t, entries, 1)
|
|
|
|
for _, key := range []string{
|
|
"request_start", "method", "url", "useragent", "request_id",
|
|
"referer", "proto", "remoteIP", "status", "latency_ms",
|
|
} {
|
|
assert.Contains(t, entries[0], key)
|
|
}
|
|
|
|
assert.Equal(t, http.MethodGet, entries[0]["method"])
|
|
assert.Equal(t, "HTTP/1.1", entries[0]["proto"])
|
|
}
|