Files
2026-08-15 07:13:00 +00:00

288 lines
9.7 KiB
Go

package httpx
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"github.com/iceBear67/simplepages/api"
)
func prefixes(t *testing.T, ss ...string) []netip.Prefix {
t.Helper()
out := make([]netip.Prefix, 0, len(ss))
for _, s := range ss {
p, err := netip.ParsePrefix(s)
if err != nil {
t.Fatalf("ParsePrefix(%q): %v", s, err)
}
out = append(out, p)
}
return out
}
// TestLogNeverContainsCredentials is the load-bearing test of this package:
// an access log that leaks a bearer token turns every log shipper, backup and
// support ticket into a credential store.
func TestLogNeverContainsCredentials(t *testing.T) {
const token = "pgs_k7m2q4x9v0zt3b8w_S3cr3tVa1ueThatMustNeverBeLogged00000000"
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// A handler that legitimately annotates the log must still not be able to
// smuggle the secret in: it logs the key id, which is public.
LogAttr(r.Context(), "key_id", "k7m2q4x9v0zt3b8w")
LogAttr(r.Context(), "project", "demo")
w.WriteHeader(http.StatusNoContent)
}), WithRequestID(nil), AccessLog(log, nil), Recover(log))
r := httptest.NewRequest(http.MethodGet, "/api/v1/whoami?access_token="+token, nil)
r.Header.Set("Authorization", "Bearer "+token)
r.Header.Set("Cookie", "session="+token)
h.ServeHTTP(httptest.NewRecorder(), r)
out := buf.String()
if out == "" {
t.Fatal("no log output produced")
}
for _, needle := range []string{token, "S3cr3tVa1ue", "Bearer", "Authorization", "session="} {
if strings.Contains(out, needle) {
t.Errorf("log contains %q\nlog: %s", needle, out)
}
}
for _, want := range []string{`"key_id":"k7m2q4x9v0zt3b8w"`, `"project":"demo"`, `"status":204`} {
if !strings.Contains(out, want) {
t.Errorf("log missing %s\nlog: %s", want, out)
}
}
// The raw query string is not logged either: tokens end up there when someone
// ignores the docs, and we would rather drop the field than log the secret.
if strings.Contains(out, "access_token") {
t.Errorf("log contains the query string\nlog: %s", out)
}
}
func TestAccessLogRecordsStatusAndBytes(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
fmt.Fprint(w, "hello")
}), AccessLog(log, nil))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/x", nil))
var rec map[string]any
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
t.Fatalf("log line is not JSON: %v (%s)", err, buf.String())
}
if got := rec["status"]; got != float64(http.StatusTeapot) {
t.Errorf("status = %v, want 418", got)
}
if got := rec["bytes"]; got != float64(5) {
t.Errorf("bytes = %v, want 5", got)
}
if rec["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a 4xx", rec["level"])
}
}
func TestRecoverReturnsOpaque500(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("database password is hunter2")
}), WithRequestID(nil), AccessLog(log, nil), Recover(log))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/boom", nil))
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", w.Code)
}
// The panicking request must still produce an access-log line, which is only
// true while Recover runs inside AccessLog.
if !strings.Contains(buf.String(), `"msg":"request"`) {
t.Errorf("no access-log line for the panicking request\nlog: %s", buf.String())
}
if strings.Contains(w.Body.String(), "hunter2") {
t.Errorf("panic value leaked to the client: %s", w.Body.String())
}
var env api.ErrorEnvelope
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("body is not an error envelope: %v (%s)", err, w.Body.String())
}
if env.Error.Code != api.CodeInternal {
t.Errorf("code = %q, want %q", env.Error.Code, api.CodeInternal)
}
if env.Error.Details["request_id"] == nil {
t.Error("500 body carries no request_id, so the log line cannot be found")
}
if !strings.Contains(buf.String(), "hunter2") {
t.Error("panic value was not logged; it must reach the operator even though it must not reach the client")
}
}
func TestClientIP(t *testing.T) {
trusted := prefixes(t, "127.0.0.1/32", "10.0.0.0/8")
cases := []struct {
name string
remote string
xff []string
want string
}{
{"untrusted peer, header ignored", "203.0.113.9:1234", []string{"9.9.9.9"}, "203.0.113.9"},
{"trusted peer, single hop", "127.0.0.1:1234", []string{"198.51.100.7"}, "198.51.100.7"},
{"trusted peer, chain", "10.1.2.3:1234", []string{"198.51.100.7, 10.4.5.6"}, "198.51.100.7"},
{"forged prefix ignored", "127.0.0.1:1234", []string{"1.2.3.4, 198.51.100.7"}, "198.51.100.7"},
{"multiple headers", "127.0.0.1:1234", []string{"1.1.1.1", "198.51.100.7"}, "198.51.100.7"},
{"garbage entries skipped", "127.0.0.1:1234", []string{"not-an-ip, 198.51.100.7, 10.0.0.1"}, "198.51.100.7"},
{"all trusted, falls back to peer", "127.0.0.1:1234", []string{"10.0.0.1"}, "127.0.0.1"},
{"no header", "127.0.0.1:1234", nil, "127.0.0.1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = tc.remote
for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v)
}
got, ok := ClientIP(r, trusted)
if !ok {
t.Fatal("ClientIP reported no address")
}
if got.String() != tc.want {
t.Errorf("ClientIP = %s, want %s", got, tc.want)
}
})
}
}
func TestRequestIDAdoption(t *testing.T) {
trusted := prefixes(t, "127.0.0.1/32")
cases := []struct {
name string
remote string
header string
wantSet bool // true = the inbound value is echoed back verbatim
}{
{"trusted peer, sane id", "127.0.0.1:1", "deadbeef-42", true},
{"untrusted peer", "203.0.113.9:1", "deadbeef-42", false},
{"trusted peer, space injected", "127.0.0.1:1", "id with space", false},
{"trusted peer, newline injected", "127.0.0.1:1", "id\nlevel=INFO", false},
{"trusted peer, over-long", "127.0.0.1:1", strings.Repeat("a", 65), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var seen string
h := WithRequestID(trusted)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = RequestIDFrom(r.Context())
}))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = tc.remote
r.Header.Set("X-Request-Id", tc.header)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if tc.wantSet {
if seen != tc.header {
t.Errorf("request id = %q, want the inbound %q", seen, tc.header)
}
} else if seen == tc.header {
t.Errorf("adopted an untrustworthy inbound id %q", tc.header)
}
if seen == "" {
t.Error("no request id assigned")
}
if got := w.Header().Get("X-Request-Id"); got != seen {
t.Errorf("echoed %q but handler saw %q", got, seen)
}
})
}
}
func TestWriteErrorMapsCodes(t *testing.T) {
cases := []struct {
err error
want int
}{
{api.Errorf(api.CodeNotFound, "nope"), http.StatusNotFound},
{api.Errorf(api.CodeDeploymentActive, "still active"), http.StatusConflict},
{api.Errorf(api.CodeInvalidPath, "bad"), http.StatusBadRequest},
{api.Errorf(api.CodeLimitExceeded, "too big"), http.StatusRequestEntityTooLarge},
{fmt.Errorf("wrapped: %w", api.Errorf(api.CodeForbidden, "no")), http.StatusForbidden},
{fmt.Errorf("open /var/lib/pages-server/secret: permission denied"), http.StatusInternalServerError},
}
log := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
for _, tc := range cases {
w := httptest.NewRecorder()
WriteError(w, httptest.NewRequest(http.MethodGet, "/", nil), log, tc.err)
if w.Code != tc.want {
t.Errorf("WriteError(%v) = %d, want %d", tc.err, w.Code, tc.want)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q", ct)
}
}
}
// A non-api error must not put internal detail in the response body.
func TestWriteErrorHidesInternalDetail(t *testing.T) {
log := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
w := httptest.NewRecorder()
WriteError(w, httptest.NewRequest(http.MethodGet, "/", nil), log,
fmt.Errorf("sql: no rows in /var/lib/pages-server/pages.db"))
if strings.Contains(w.Body.String(), "pages.db") {
t.Errorf("internal detail leaked: %s", w.Body.String())
}
}
func TestDecodeJSON(t *testing.T) {
type payload struct {
Name string `json:"name"`
}
cases := []struct {
name string
body string
max int64
want api.Code
}{
{"ok", `{"name":"demo"}`, 1024, ""},
{"unknown field", `{"name":"demo","nmae":"typo"}`, 1024, api.CodeBadRequest},
{"wrong type", `{"name":42}`, 1024, api.CodeBadRequest},
{"malformed", `{"name":`, 1024, api.CodeBadRequest},
{"trailing content", `{"name":"a"} {"name":"b"}`, 1024, api.CodeBadRequest},
{"too large", `{"name":"` + strings.Repeat("x", 200) + `"}`, 32, api.CodePayloadTooLarge},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body))
var v payload
err := DecodeJSON(httptest.NewRecorder(), r, tc.max, &v)
if tc.want == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("expected an error")
}
if got := api.CodeOf(err); got != tc.want {
t.Errorf("code = %q, want %q (%v)", got, tc.want, err)
}
})
}
}