GET /api/v1/containers behind bearer token auth, JSON errors for 401/404/405/500, one audit row per authenticated request, graceful shutdown; unit and e2e tests for auth, routing, auditing, request logging and server lifecycle.
522 lines
18 KiB
Go
522 lines
18 KiB
Go
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/moby/moby/api/types/container"
|
|
)
|
|
|
|
// fakeAudit is an in-memory AuditRecorder
|
|
type fakeAudit struct {
|
|
mu sync.Mutex
|
|
rows []opRow
|
|
err error
|
|
}
|
|
|
|
func (f *fakeAudit) RecordOperation(ctx context.Context, token, origin, operation string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.rows = append(f.rows, opRow{token: token, origin: origin, operation: operation})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAudit) Rows() []opRow {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]opRow(nil), f.rows...)
|
|
}
|
|
|
|
// doRequest runs one request through the complete handler chain
|
|
func doRequest(s *WebServer, method, target string, header http.Header) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, target, nil)
|
|
req.RemoteAddr = "198.51.100.7:40000"
|
|
for k, vs := range header {
|
|
for _, v := range vs {
|
|
req.Header.Add(k, v)
|
|
}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
s.HTTPServer.Handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// realStack wires the server with real SQLite stores and a fake Docker client
|
|
type realStack struct {
|
|
server *WebServer
|
|
tokens *TokenStore
|
|
audit *AuditStore
|
|
docker *fakeDocker
|
|
log *syncBuffer
|
|
good string
|
|
old string
|
|
}
|
|
|
|
func newRealStack(t *testing.T, level slog.Level) *realStack {
|
|
t.Helper()
|
|
log, buf := newTestLogger(level)
|
|
db := openTestDB(t)
|
|
clock := newFakeClock(t0, time.Millisecond)
|
|
tokens := NewTokenStore(db, log, clock.Now, GenerateToken)
|
|
audit := NewAuditStore(db, log, clock.Now)
|
|
good, err := tokens.CreateToken(context.Background(), "good")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old, err := tokens.CreateToken(context.Background(), "old")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tokens.DeactivateToken(context.Background(), old.Token); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
docker := &fakeDocker{containers: []container.Summary{}}
|
|
return &realStack{
|
|
server: NewWebServer(log, tokens, audit, docker),
|
|
tokens: tokens, audit: audit, docker: docker, log: buf,
|
|
good: good.Token, old: old.Token,
|
|
}
|
|
}
|
|
|
|
func bearer(token string) http.Header {
|
|
return http.Header{"Authorization": {"Bearer " + token}}
|
|
}
|
|
|
|
func assertJSONResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
|
|
t.Helper()
|
|
if rec.Code != status {
|
|
t.Errorf("status = %d, want %d", rec.Code, status)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q", ct)
|
|
}
|
|
if got := rec.Body.String(); got != body {
|
|
t.Errorf("body = %q, want %q", got, body)
|
|
}
|
|
if cl := rec.Header().Get("Content-Length"); cl != itoa(len(body)) {
|
|
t.Errorf("Content-Length = %q, want %d", cl, len(body))
|
|
}
|
|
}
|
|
|
|
// nearMiss returns a different token differing only in its first character
|
|
func nearMiss(token string) string {
|
|
if token[0] == 'A' {
|
|
return "B" + token[1:]
|
|
}
|
|
return "A" + token[1:]
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
b, _ := json.Marshal(n)
|
|
return string(b)
|
|
}
|
|
|
|
func TestContainersNoContainers(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
assertJSONResponse(t, rec, 200, "[]")
|
|
rows := readOperations(t, st.audit)
|
|
if len(rows) != 1 || rows[0].token != st.good || rows[0].origin != "198.51.100.7" || rows[0].operation != "GET containers" {
|
|
t.Fatalf("audit rows = %+v", rows)
|
|
}
|
|
if _, err := time.Parse(time.RFC3339Nano, rows[0].time); err != nil || !strings.HasSuffix(rows[0].time, "Z") {
|
|
t.Fatalf("audit time %q", rows[0].time)
|
|
}
|
|
}
|
|
|
|
func TestContainersNilSliceFromLister(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
st.docker.containers = nil
|
|
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
// A nil slice from a lister still yields valid JSON (null); the real client never returns nil
|
|
if rec.Code != 200 || rec.Body.String() != "null" {
|
|
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestContainersOneAndMany(t *testing.T) {
|
|
t.Parallel()
|
|
for _, n := range []int{1, 250} {
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
st.docker.containers = makeContainers(n)
|
|
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
want, _ := json.Marshal(st.docker.containers)
|
|
assertJSONResponse(t, rec, 200, string(want))
|
|
var got []map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != n || got[0]["Id"] != st.docker.containers[0].ID || got[n-1]["State"] != "running" {
|
|
t.Fatalf("n=%d: decoded %d items", n, len(got))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContainersQueryStringIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
rec := doRequest(st.server, "GET", "/api/v1/containers?all=1&x=%00", bearer(st.good))
|
|
assertJSONResponse(t, rec, 200, "[]")
|
|
}
|
|
|
|
func TestContainersDockerError(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, LevelTrace)
|
|
st.docker.err = errors.New("Cannot connect to the Docker daemon at unix:///var/run/docker.sock")
|
|
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
|
|
if strings.Contains(rec.Body.String(), "docker") {
|
|
t.Fatal("internal detail leaked")
|
|
}
|
|
// The request was audited even though Docker failed
|
|
if rows := readOperations(t, st.audit); len(rows) != 1 {
|
|
t.Fatalf("audit rows = %d", len(rows))
|
|
}
|
|
if !strings.Contains(st.log.String(), `level=ERROR msg="docker request failed" error="Cannot connect`) {
|
|
t.Fatalf("missing error log:\n%s", st.log.String())
|
|
}
|
|
}
|
|
|
|
func TestContainersAuditError(t *testing.T) {
|
|
t.Parallel()
|
|
log, buf := newTestLogger(slog.LevelInfo)
|
|
docker := &fakeDocker{}
|
|
s := NewWebServer(log, newFakeLookup(), &fakeAudit{err: errors.New("SQL logic error: no such table")}, docker)
|
|
rec := doRequest(s, "GET", "/api/v1/containers", bearer("good"))
|
|
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
|
|
if docker.Calls() != 0 {
|
|
t.Fatal("Docker queried although the audit failed")
|
|
}
|
|
if !strings.Contains(buf.String(), `level=ERROR msg="audit insert failed" token_id=`+tokenID("good")) {
|
|
t.Fatalf("missing error log:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestContainersAuditForeignKeyFailure(t *testing.T) {
|
|
t.Parallel()
|
|
// The lookup says the token exists but the real store has no such row: the FK rejects the audit row
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
s := NewWebServer(traceDiscard(), &fakeLookup{tokens: map[string]Token{"ghost": {Token: "ghost", Active: true}}}, st.audit, st.docker)
|
|
rec := doRequest(s, "GET", "/api/v1/containers", bearer("ghost"))
|
|
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
|
|
if st.docker.Calls() != 0 {
|
|
t.Fatal("Docker queried although the audit failed")
|
|
}
|
|
}
|
|
|
|
func TestContainersLookupError(t *testing.T) {
|
|
t.Parallel()
|
|
audit := &fakeAudit{}
|
|
s := NewWebServer(traceDiscard(), &fakeLookup{err: errors.New("database is locked")}, audit, &fakeDocker{})
|
|
rec := doRequest(s, "GET", "/api/v1/containers", bearer("good"))
|
|
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
|
|
if len(audit.Rows()) != 0 {
|
|
t.Fatal("audited without authentication")
|
|
}
|
|
}
|
|
|
|
func TestContainersSerializationFailure(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
st.docker.containers = makeContainers(2)
|
|
st.server.encode = func(any) ([]byte, error) { return nil, errors.New("encoder exploded") }
|
|
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
|
|
if !strings.Contains(st.log.String(), `msg="response serialization failed" error="encoder exploded"`) {
|
|
t.Fatalf("missing log:\n%s", st.log.String())
|
|
}
|
|
}
|
|
|
|
func TestContainersUnauthorized(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelDebug)
|
|
cases := []struct {
|
|
name string
|
|
header http.Header
|
|
class string
|
|
}{
|
|
{"missing", nil, authMissingHeader},
|
|
{"empty", http.Header{"Authorization": {""}}, authEmptyHeader},
|
|
{"wrong scheme", http.Header{"Authorization": {"Basic " + st.good}}, authBadScheme},
|
|
{"empty bearer", http.Header{"Authorization": {"Bearer "}}, authEmptyToken},
|
|
{"malformed bearer", http.Header{"Authorization": {"Bearer " + st.good}}, authUnknownToken},
|
|
{"duplicated", http.Header{"Authorization": {"Bearer " + st.good, "Bearer " + st.good}}, authMultipleHeaders},
|
|
{"unknown", bearer(nearMiss(st.good)), authUnknownToken},
|
|
{"case changed", bearer(strings.ToUpper(st.good)), authUnknownToken},
|
|
{"inactive", bearer(st.old), authInactiveToken},
|
|
{"sql injection", bearer("' OR '1'='1"), authUnknownToken},
|
|
}
|
|
for _, c := range cases {
|
|
rec := doRequest(st.server, "GET", "/api/v1/containers", c.header)
|
|
assertJSONResponse(t, rec, 401, `{"error":"unauthorized"}`)
|
|
if rec.Header().Get("WWW-Authenticate") != "Bearer" {
|
|
t.Errorf("%s: WWW-Authenticate = %q", c.name, rec.Header().Get("WWW-Authenticate"))
|
|
}
|
|
if !strings.Contains(st.log.String(), `msg="authentication result" result=`+c.class) {
|
|
t.Errorf("%s: missing class %s in log", c.name, c.class)
|
|
}
|
|
}
|
|
// No audit row and no Docker call for any failed authentication
|
|
if rows := readOperations(t, st.audit); len(rows) != 0 {
|
|
t.Fatalf("audit rows after failed auth: %+v", rows)
|
|
}
|
|
if st.docker.Calls() != 0 {
|
|
t.Fatal("Docker queried without authentication")
|
|
}
|
|
// Then a valid one creates exactly one row
|
|
assertJSONResponse(t, doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good)), 200, "[]")
|
|
if rows := readOperations(t, st.audit); len(rows) != 1 {
|
|
t.Fatalf("audit rows = %d", len(rows))
|
|
}
|
|
}
|
|
|
|
func TestContainersAuditBeforeDocker(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
var during int
|
|
st.docker.onList = func(ctx context.Context) {
|
|
during = len(readOperations(t, st.audit))
|
|
}
|
|
st.docker.err = errors.New("boom")
|
|
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
if during != 1 {
|
|
t.Fatalf("audit rows visible during Docker call = %d, want 1", during)
|
|
}
|
|
}
|
|
|
|
func TestMethodNotAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
for _, m := range []string{"POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT", "FOO", "get"} {
|
|
rec := doRequest(st.server, m, "/api/v1/containers", bearer(st.good))
|
|
assertJSONResponse(t, rec, 405, `{"error":"method not allowed"}`)
|
|
if rec.Header().Get("Allow") != "GET" {
|
|
t.Errorf("%s: Allow = %q", m, rec.Header().Get("Allow"))
|
|
}
|
|
}
|
|
if rows := readOperations(t, st.audit); len(rows) != 0 {
|
|
t.Fatalf("audit rows = %d", len(rows))
|
|
}
|
|
if st.docker.Calls() != 0 {
|
|
t.Fatal("Docker queried")
|
|
}
|
|
}
|
|
|
|
func TestNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
for _, path := range []string{
|
|
"/", "/api", "/api/v1", "/api/v1/", "/api/v1/containers/", "/api/v1/containers/x",
|
|
"/API/V1/CONTAINERS", "/api/v1/Containers", "//api/v1/containers", "/api//v1/containers",
|
|
"/api/v1/./containers", "/api/v1/../v1/containers", "/api/v2/containers", "/api/v1/containers.json",
|
|
"/api/v1/containers%2F", "/api/v1%2Fcontainers", "/api%2fv1%2fcontainers", "/version", "/favicon.ico",
|
|
} {
|
|
for _, m := range []string{"GET", "POST"} {
|
|
rec := doRequest(st.server, m, path, bearer(st.good))
|
|
assertJSONResponse(t, rec, 404, `{"error":"not found"}`)
|
|
if rec.Header().Get("Location") != "" {
|
|
t.Errorf("%s %s redirected to %q", m, path, rec.Header().Get("Location"))
|
|
}
|
|
}
|
|
}
|
|
if rows := readOperations(t, st.audit); len(rows) != 0 {
|
|
t.Fatalf("audit rows = %d", len(rows))
|
|
}
|
|
}
|
|
|
|
func TestEncodedPathMatches(t *testing.T) {
|
|
t.Parallel()
|
|
// Percent-encoded unreserved characters decode to the same path
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
assertJSONResponse(t, doRequest(st.server, "GET", "/api/v1/%63ontainers", bearer(st.good)), 200, "[]")
|
|
}
|
|
|
|
func TestClientIP(t *testing.T) {
|
|
t.Parallel()
|
|
for in, want := range map[string]string{
|
|
"203.0.113.5:1234": "203.0.113.5",
|
|
"[2001:db8::1]:443": "2001:db8::1",
|
|
"[fe80::1%eth0]:8080": "fe80::1%eth0",
|
|
"127.0.0.1:0": "127.0.0.1",
|
|
"garbage": "garbage",
|
|
"": "",
|
|
"@": "@",
|
|
} {
|
|
if got := clientIP(in); got != want {
|
|
t.Errorf("clientIP(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOriginIgnoresForwardingHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
h := bearer(st.good)
|
|
h.Set("X-Forwarded-For", "1.2.3.4")
|
|
h.Set("Forwarded", "for=5.6.7.8")
|
|
h.Set("X-Real-IP", "9.9.9.9")
|
|
h.Set("X-Client-IP", "8.8.8.8")
|
|
h.Set("True-Client-IP", "7.7.7.7")
|
|
h.Set("CF-Connecting-IP", "6.6.6.6")
|
|
doRequest(st.server, "GET", "/api/v1/containers", h)
|
|
rows := readOperations(t, st.audit)
|
|
if len(rows) != 1 || rows[0].origin != "198.51.100.7" {
|
|
t.Fatalf("origin = %+v", rows)
|
|
}
|
|
if strings.Contains(st.log.String(), "1.2.3.4") {
|
|
t.Fatal("forwarded address logged as origin")
|
|
}
|
|
}
|
|
|
|
func TestRequestLogLine(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
doRequest(st.server, "GET", "/api/v1/containers", nil)
|
|
doRequest(st.server, "POST", "/api/v1/containers", nil)
|
|
doRequest(st.server, "GET", "/missing%0Aline", nil)
|
|
st.docker.err = errors.New("x")
|
|
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
out := st.log.String()
|
|
for _, want := range []string{
|
|
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=200 duration=`,
|
|
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=401 duration=`,
|
|
`level=INFO msg="this request has been made" method=POST path=/api/v1/containers status=405 duration=`,
|
|
`level=INFO msg="this request has been made" method=GET path="/missing\nline" status=404 duration=`,
|
|
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=500 duration=`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("missing %q in:\n%s", want, out)
|
|
}
|
|
}
|
|
if c := strings.Count(out, "this request has been made"); c != 5 {
|
|
t.Fatalf("request lines = %d", c)
|
|
}
|
|
if !strings.Contains(out, "origin=198.51.100.7\n") {
|
|
t.Fatalf("origin missing:\n%s", out)
|
|
}
|
|
// INFO shows no ERROR-level noise other than the Docker failure
|
|
if c := strings.Count(out, "level=ERROR"); c != 1 {
|
|
t.Fatalf("error lines = %d", c)
|
|
}
|
|
}
|
|
|
|
func TestNoSecretInLogsAtTrace(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, LevelTrace)
|
|
st.docker.containers = makeContainers(2)
|
|
unknown := nearMiss(st.good)
|
|
for _, h := range []http.Header{
|
|
bearer(st.good), bearer(st.old), bearer(unknown), bearer(""),
|
|
{"Authorization": {"Basic " + st.good}},
|
|
{"Authorization": {"Bearer " + st.good, "Bearer " + st.good}},
|
|
} {
|
|
doRequest(st.server, "GET", "/api/v1/containers", h)
|
|
doRequest(st.server, "POST", "/api/v1/containers", h)
|
|
}
|
|
st.docker.err = errors.New("fail")
|
|
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
|
|
out := st.log.String()
|
|
for _, secret := range []string{st.good, st.old, unknown, st.good[:16], "Authorization", "Bearer"} {
|
|
if strings.Contains(out, secret) {
|
|
t.Fatalf("%q leaked into logs", secret)
|
|
}
|
|
}
|
|
// TRACE covers the execution path
|
|
for _, want := range []string{
|
|
`func=WebServer.logRequests`, `func=WebServer.handleContainers`, `func=authenticate`,
|
|
`func=TokenStore.GetToken`, `func=AuditStore.RecordOperation`, `func=WebServer.handleMethodNotAllowed`,
|
|
`msg="row read" table=tokens token_id=` + tokenID(st.good),
|
|
`msg="row inserted" table=operations token_id=` + tokenID(st.good) + ` origin=198.51.100.7 operation="GET containers"`,
|
|
`level=DEBUG msg="authentication result" result=ok token_id=` + tokenID(st.good),
|
|
`level=DEBUG msg="audit row inserted" token_id=` + tokenID(st.good),
|
|
`level=DEBUG msg="docker request started"`,
|
|
`level=DEBUG msg="docker request finished" ok=true`,
|
|
`level=DEBUG msg="docker request finished" ok=false`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerOverRealHTTP(t *testing.T) {
|
|
t.Parallel()
|
|
st := newRealStack(t, slog.LevelInfo)
|
|
srv := httptest.NewServer(st.server.HTTPServer.Handler)
|
|
defer srv.Close()
|
|
|
|
get := func(header http.Header) *http.Response {
|
|
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/containers", nil)
|
|
req.Header = header
|
|
resp, err := srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
resp := get(bearer(st.good))
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 200 || string(body) != "[]" || resp.Header.Get("Content-Type") != "application/json" {
|
|
t.Fatalf("got %d %q", resp.StatusCode, body)
|
|
}
|
|
// Two Authorization header lines on the wire
|
|
resp = get(http.Header{"Authorization": {"Bearer " + st.good, "Bearer " + st.good}})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Fatalf("duplicate header status = %d", resp.StatusCode)
|
|
}
|
|
rows := readOperations(t, st.audit)
|
|
if len(rows) != 1 || rows[0].origin != "127.0.0.1" {
|
|
t.Fatalf("rows = %+v", rows)
|
|
}
|
|
}
|
|
|
|
func TestStatusRecorder(t *testing.T) {
|
|
t.Parallel()
|
|
// Implicit 200 on Write, first WriteHeader wins, Unwrap exposes the writer
|
|
inner := httptest.NewRecorder()
|
|
rec := &statusRecorder{ResponseWriter: inner, status: http.StatusOK}
|
|
rec.Write([]byte("x"))
|
|
rec.WriteHeader(http.StatusTeapot)
|
|
if rec.status != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.status)
|
|
}
|
|
if rec.Unwrap() != inner {
|
|
t.Fatal("Unwrap")
|
|
}
|
|
rec2 := &statusRecorder{ResponseWriter: httptest.NewRecorder(), status: http.StatusOK}
|
|
rec2.WriteHeader(http.StatusAccepted)
|
|
rec2.WriteHeader(http.StatusTeapot)
|
|
if rec2.status != http.StatusAccepted {
|
|
t.Fatalf("status = %d", rec2.status)
|
|
}
|
|
|
|
// A handler writing nothing is logged as 200
|
|
log, buf := newTestLogger(slog.LevelInfo)
|
|
s := &WebServer{log: log}
|
|
h := s.logRequests(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
|
if !strings.Contains(buf.String(), "status=200") {
|
|
t.Fatalf("log = %s", buf.String())
|
|
}
|
|
}
|