Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4637026bc4 | ||
|
|
3aab234a73 | ||
|
|
d4ffcbdf9e | ||
|
|
604b51eea6 | ||
|
|
ba5a716223 |
@@ -34,6 +34,30 @@ P2: security: referer blacklist
|
||||
not exist is passed over; any other error, such as a directory on the path
|
||||
that pixa may not enter, aborts startup naming the file, as a file that does
|
||||
not parse already did.
|
||||
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and
|
||||
metrics recording have tests (closes #79): `MetricsAuth` on its own answers
|
||||
401 with a challenge without credentials or with a wrong username or password
|
||||
and lets the configured ones through; a preflight request gets `*` for any
|
||||
origin when `access_control_allow_origin` is `*` and no
|
||||
`Access-Control-Allow-Origin` from another origin than the configured one; a
|
||||
`POST /` carrying the signing key leaves no trace of it in the request log
|
||||
line, and the login handler's own log lines leave out the submitted key; the
|
||||
metrics middleware on its own records a request it served, and the router
|
||||
records nothing while no metrics username is set. Not tested: that the router
|
||||
puts the basic auth in front of `/metrics` and records requests when a
|
||||
metrics username is set. Only one test per package can set up `/metrics`, and
|
||||
in `internal/server` that is `TestMaintenanceModeKeepsOtherRoutes`, which
|
||||
needs the owner's approval to change; #180 holds it. Tests only; the basic
|
||||
auth library already compares the password in constant time.
|
||||
- 2026-10-04 the image route's signature check and error answers are tested
|
||||
(closes #76): new tests in `internal/handlers`, with no network, check the
|
||||
status and JSON error body for a missing, wrong, unpadded, upper-case or
|
||||
expired signature on a host not on the allowlist, or a valid one sent for
|
||||
its parent domain, a sibling host, a subdomain or the host with another
|
||||
domain appended (401), an unparseable path (400), `localhost` as the
|
||||
upstream host (403) and an upstream error (502); that an allowlisted host is
|
||||
served without a signature, another host only with a valid one; and the
|
||||
answers of `/robots.txt` and the health check. No code changes.
|
||||
- 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates
|
||||
(closes #84): pixa's own `RequestID` middleware, in place of chi's, gives each
|
||||
request an ID, its own `X-Request-ID` when that is at most 64 letters, digits,
|
||||
|
||||
@@ -618,6 +618,62 @@ func TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFileLinkingToItselfAbortsStartup checks that a config file
|
||||
// pixa cannot read for a reason other than not existing aborts startup,
|
||||
// as root too: a symbolic link to itself fails with "too many levels of
|
||||
// symbolic links".
|
||||
func TestConfigFileLinkingToItselfAbortsStartup(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
|
||||
err := os.Symlink("config.yml", filepath.Join(workDir, "config.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create symbolic link: %v", err)
|
||||
}
|
||||
|
||||
// Only the working directory's config.yml is there: the appname rules
|
||||
// out /etc, and HOME is empty.
|
||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Chdir(workDir)
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||
if err == nil {
|
||||
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||
sc)
|
||||
}
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
if !strings.Contains(err.Error(), "config.yml") {
|
||||
t.Errorf("error %q does not name the config file config.yml", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigPathThroughFileIsPassedOver checks that a config file path
|
||||
// that runs through a file, such as one under a HOME of /dev/null, is
|
||||
// passed over like one that does not exist, since no file can be there.
|
||||
func TestConfigPathThroughFileIsPassedOver(t *testing.T) {
|
||||
// No config file is there: the appname rules out /etc, HOME is
|
||||
// /dev/null, and the working directory is empty.
|
||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||
t.Setenv("HOME", os.DevNull)
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||
if err != nil {
|
||||
t.Fatalf("a config path through a file must be passed over, got error: %v",
|
||||
err)
|
||||
}
|
||||
|
||||
if sc != nil {
|
||||
t.Errorf("expected no config file, got config: %v", sc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/session"
|
||||
)
|
||||
|
||||
// TestLoginLogLeavesOutSubmittedKey verifies that the log lines for a
|
||||
// failed and for a successful login do not contain the submitted key.
|
||||
func TestLoginLogLeavesOutSubmittedKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const wrongKey = "wrong-signing-key-fedcba9876543210"
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
sessMgr, err := session.NewManager(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("session.NewManager() error = %v", err)
|
||||
}
|
||||
|
||||
h := &Handlers{
|
||||
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||
config: &config.Config{SigningKey: testSigningKey},
|
||||
sessMgr: sessMgr,
|
||||
}
|
||||
|
||||
submittedKeys := []string{wrongKey, testSigningKey}
|
||||
|
||||
for _, key := range submittedKeys {
|
||||
form := url.Values{loginKeyField: {key}}
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodPost, "/",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
h.handleLoginPost(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
for _, msg := range []string{"failed login attempt", "successful login"} {
|
||||
if !strings.Contains(buf.String(), msg) {
|
||||
t.Fatalf("log missing %q; got %q", msg, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range submittedKeys {
|
||||
if strings.Contains(buf.String(), key) {
|
||||
t.Errorf("log contains submitted key %q; got %q", key, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
"sneak.berlin/go/pixa/internal/signature"
|
||||
)
|
||||
|
||||
// allowlistedHost is the only host on the allowlist of the image route
|
||||
// newImageRoute builds.
|
||||
const allowlistedHost = "allowed.example.com"
|
||||
|
||||
// newImageRoute returns the image route of a Handlers whose service fetches
|
||||
// with fetcher and checks signatures with testSigningKey.
|
||||
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
}
|
||||
|
||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||
Cache: cache,
|
||||
Fetcher: fetcher,
|
||||
SigningKey: testSigningKey,
|
||||
Allowlist: []string{allowlistedHost},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/image/*", h.HandleImage())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
||||
// each of hosts, and answers any other URL with an upstream error.
|
||||
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
||||
t.Helper()
|
||||
|
||||
photo := &fstest.MapFile{
|
||||
Data: generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}),
|
||||
}
|
||||
|
||||
files := fstest.MapFS{}
|
||||
for _, host := range hosts {
|
||||
files[host+photoPath] = photo
|
||||
}
|
||||
|
||||
return httpfetcher.NewMock(files)
|
||||
}
|
||||
|
||||
// photoURL returns the image route URL of photoPath on host, as a 50x50 JPEG.
|
||||
func photoURL(host string) string {
|
||||
return "/v1/image/" + host + photoPath + "/50x50.jpeg"
|
||||
}
|
||||
|
||||
// photoURLWithSig returns photoURL(host) with sig and expires as its sig and
|
||||
// exp.
|
||||
func photoURLWithSig(host, sig string, expires time.Time) string {
|
||||
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
|
||||
}
|
||||
|
||||
// photoSignature returns the signature of photoURL(host) at the default
|
||||
// quality and fit, made with key and expiring at expires.
|
||||
func photoSignature(key, host string, expires time.Time) string {
|
||||
return signature.New(key).Sign(&signature.Request{
|
||||
SourceHost: host,
|
||||
SourcePath: photoPath,
|
||||
Width: 50,
|
||||
Height: 50,
|
||||
Format: string(imgcache.FormatJPEG),
|
||||
Quality: 85,
|
||||
FitMode: string(imgcache.FitCover),
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
// sendGet sends a GET for target to route and returns the response.
|
||||
func sendGet(
|
||||
t *testing.T, route http.Handler, target string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
route.ServeHTTP(rec, req)
|
||||
t.Logf("GET %s: %d", target, rec.Code)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// checkErrorBody checks that rec has status wantStatus and the JSON error body
|
||||
// the image route sends: wantError, wantStatus and the time in RFC 3339.
|
||||
func checkErrorBody(
|
||||
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantError string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if rec.Code != wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
|
||||
}
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Status int `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding response body: %v", err)
|
||||
}
|
||||
|
||||
if body.Error != wantError || body.Status != wantStatus {
|
||||
t.Errorf("body error and status = %q %d, want %q %d",
|
||||
body.Error, body.Status, wantError, wantStatus)
|
||||
}
|
||||
|
||||
_, err = time.Parse(time.RFC3339, body.Timestamp)
|
||||
if err != nil {
|
||||
t.Errorf("body timestamp: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleImage_ErrorAnswers checks the status and the JSON error body the
|
||||
// image route answers each request below with. The JPEG at photoPath exists on
|
||||
// signedHost and on each host below that differs from it, so a request refused
|
||||
// with 401 would otherwise be served.
|
||||
func TestHandleImage_ErrorAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A signature for signedHost must not verify for any of these.
|
||||
parentHost := "example.com"
|
||||
siblingHost := "other.example.com"
|
||||
subdomainHost := "img." + signedHost
|
||||
appendedHost := signedHost + ".example.net"
|
||||
|
||||
photos := newPhotoFetcher(t,
|
||||
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
||||
// The real fetcher refuses localhost before any lookup or connection.
|
||||
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
|
||||
|
||||
exp := time.Now().Add(time.Hour)
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
sig := photoSignature(testSigningKey, signedHost, exp)
|
||||
otherKeySig := photoSignature("another-signing-key", signedHost, exp)
|
||||
expiredSig := photoSignature(testSigningKey, signedHost, expired)
|
||||
localhostSig := photoSignature(testSigningKey, "localhost", exp)
|
||||
|
||||
// The error every request refused for its signature gets.
|
||||
const unauthorized = "unauthorized"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fetcher httpfetcher.Fetcher
|
||||
target string
|
||||
wantStatus int
|
||||
wantError string
|
||||
}{
|
||||
{"no sig or exp", photos, photoURL(signedHost),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"exp but no sig", photos,
|
||||
fmt.Sprintf("%s?exp=%d", photoURL(signedHost), exp.Unix()),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"sig made with another key", photos,
|
||||
photoURLWithSig(signedHost, otherKeySig, exp),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"sig without its = padding", photos,
|
||||
photoURLWithSig(signedHost, strings.TrimRight(sig, "="), exp),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"sig in upper case", photos,
|
||||
photoURLWithSig(signedHost, strings.ToUpper(sig), exp),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"expired sig", photos, photoURLWithSig(signedHost, expiredSig, expired),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"sig sent for the parent domain", photos,
|
||||
photoURLWithSig(parentHost, sig, exp),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"sig sent for a sibling host", photos,
|
||||
photoURLWithSig(siblingHost, sig, exp),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"sig sent for a subdomain", photos,
|
||||
photoURLWithSig(subdomainHost, sig, exp),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"sig sent with another domain appended", photos,
|
||||
photoURLWithSig(appendedHost, sig, exp),
|
||||
http.StatusUnauthorized, unauthorized},
|
||||
{"unparseable path", photos,
|
||||
"/v1/image/" + allowlistedHost + photoPath + "/big.jpeg",
|
||||
http.StatusBadRequest, "invalid image URL: invalid size format"},
|
||||
{"blocked upstream address", realFetcher,
|
||||
photoURLWithSig("localhost", localhostSig, exp),
|
||||
http.StatusForbidden, "forbidden"},
|
||||
{"upstream error", photos,
|
||||
"/v1/image/" + allowlistedHost + "/images/missing.jpg/50x50.jpeg",
|
||||
http.StatusBadGateway, "upstream error"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := sendGet(t, newImageRoute(t, tt.fetcher), tt.target)
|
||||
checkErrorBody(t, rec, tt.wantStatus, tt.wantError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleImage_AllowlistOrSignature checks that the image route serves an
|
||||
// image without a signature for a host on the allowlist only, and for another
|
||||
// host only with a valid signature.
|
||||
func TestHandleImage_AllowlistOrSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
sig := photoSignature(testSigningKey, signedHost, exp)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
wantStatus int
|
||||
}{
|
||||
{"allowlisted host, no sig", photoURL(allowlistedHost), http.StatusOK},
|
||||
{"other host, no sig", photoURL(signedHost), http.StatusUnauthorized},
|
||||
{"other host, valid sig", photoURLWithSig(signedHost, sig, exp),
|
||||
http.StatusOK},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := sendGet(t, newImageRoute(t, photos), tt.target)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
|
||||
// TestHandleRobotsTxt checks that /robots.txt asks every crawler to stay off
|
||||
// the whole site.
|
||||
func TestHandleRobotsTxt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||
rec := sendGet(t, h.HandleRobotsTxt(), "/robots.txt")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "text/plain" {
|
||||
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||||
}
|
||||
|
||||
want := "User-agent: *\nDisallow: /\n"
|
||||
if rec.Body.String() != want {
|
||||
t.Errorf("body = %q, want %q", rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHealthCheck checks that the health check answers 200 with status
|
||||
// ok, the app's name and version, now, uptime_seconds, uptime_human and
|
||||
// maintenance_mode, which is true here: the health check stays 200 while
|
||||
// maintenance mode is on.
|
||||
func TestHandleHealthCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New() error = %v", err)
|
||||
}
|
||||
|
||||
hc, err := healthcheck.New(lc, healthcheck.Params{
|
||||
Globals: &globals.Globals{Appname: "pixad", Version: "v1.2.3"},
|
||||
Config: &config.Config{MaintenanceMode: true},
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("healthcheck.New() error = %v", err)
|
||||
}
|
||||
|
||||
h := &Handlers{hc: hc, log: slog.New(slog.DiscardHandler)}
|
||||
rec := sendGet(t, h.HandleHealthCheck(), "/.well-known/healthcheck.json")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
|
||||
err = json.NewDecoder(rec.Body).Decode(&body)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding response body: %v", err)
|
||||
}
|
||||
|
||||
if body["status"] != "ok" || body["appname"] != "pixad" ||
|
||||
body["version"] != "v1.2.3" || body["maintenance_mode"] != true {
|
||||
t.Errorf("body = %v, want status ok, appname pixad, version v1.2.3 "+
|
||||
"and maintenance_mode true", body)
|
||||
}
|
||||
|
||||
for _, key := range []string{"now", "uptime_seconds", "uptime_human"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Errorf("body = %v, has no %s", body, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
@@ -56,6 +61,203 @@ func TestCORSAnswersWithConfiguredOrigin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSAnswersPreflightWithConfiguredOrigin checks that the CORS
|
||||
// middleware answers a preflight request, which the CORS library handles
|
||||
// apart from other requests, the same way: "*" lets any origin read
|
||||
// responses and a single origin lets only that origin read them.
|
||||
func TestCORSAnswersPreflightWithConfiguredOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const appOrigin = "https://app.example.com"
|
||||
|
||||
cases := []struct {
|
||||
configured string
|
||||
requestOrigin string
|
||||
want string
|
||||
}{
|
||||
{"*", "https://any.example.com", "*"},
|
||||
{appOrigin, appOrigin, appOrigin},
|
||||
{appOrigin, "https://other.example.com", ""},
|
||||
}
|
||||
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
for _, tc := range cases {
|
||||
mw := &Middleware{
|
||||
log: slog.Default(),
|
||||
config: &config.Config{AccessControlAllowOrigin: tc.configured},
|
||||
}
|
||||
|
||||
handler := mw.CORS()(testHandler)
|
||||
|
||||
// An OPTIONS request naming the method it asks about is the
|
||||
// preflight a browser sends before some cross-origin requests.
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodOptions, "/v1/image/example.com/a.jpg/1x1.png", nil)
|
||||
req.Header.Set("Origin", tc.requestOrigin)
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
||||
if got != tc.want {
|
||||
t.Errorf("configured %q, preflight from %q: "+
|
||||
"Access-Control-Allow-Origin = %q, want %q",
|
||||
tc.configured, tc.requestOrigin, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsAuthRequiresConfiguredCredentials checks that MetricsAuth on
|
||||
// its own answers 401 with a challenge to a request without credentials or
|
||||
// with a wrong username or password, and lets a request with the configured
|
||||
// username and password through. That the router puts it in front of
|
||||
// /metrics is not tested.
|
||||
func TestMetricsAuthRequiresConfiguredCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
username = "metricsuser"
|
||||
password = "metricspass"
|
||||
challenge = `Basic realm="metrics"`
|
||||
)
|
||||
|
||||
// An empty username stands for a request sent without credentials.
|
||||
cases := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
wantReached bool
|
||||
}{
|
||||
{"no credentials", "", "", false},
|
||||
{"wrong username", "someone", password, false},
|
||||
{"wrong password", username, "wrongpass", false},
|
||||
{"configured credentials", username, password, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mw := &Middleware{
|
||||
log: slog.Default(),
|
||||
config: &config.Config{
|
||||
MetricsUsername: username,
|
||||
MetricsPassword: password,
|
||||
},
|
||||
}
|
||||
|
||||
reached := false
|
||||
handler := mw.MetricsAuth()(http.HandlerFunc(
|
||||
func(http.ResponseWriter, *http.Request) {
|
||||
reached = true
|
||||
}))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/metrics", nil)
|
||||
|
||||
if tc.username != "" {
|
||||
req.SetBasicAuth(tc.username, tc.password)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if reached != tc.wantReached {
|
||||
t.Fatalf("request reached /metrics = %v, want %v",
|
||||
reached, tc.wantReached)
|
||||
}
|
||||
|
||||
if tc.wantReached {
|
||||
return
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d",
|
||||
rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("WWW-Authenticate"); got != challenge {
|
||||
t.Errorf("WWW-Authenticate = %q, want %q", got, challenge)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsRecordsServedRequest checks that the metrics middleware
|
||||
// records a request it served, so /metrics reports it. It is the only test
|
||||
// in this package that sets up the metrics middleware, which registers with
|
||||
// the process-wide Prometheus registry and can do so only once.
|
||||
func TestMetricsRecordsServedRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The line /metrics shows once one GET /test has been served.
|
||||
const want = `http_request_duration_seconds_count{` +
|
||||
`code="200",handler="/test",method="GET",service=""} 1`
|
||||
|
||||
mw := &Middleware{log: slog.Default(), config: &config.Config{}}
|
||||
|
||||
handler := mw.Metrics()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/test", nil))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
promhttp.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/metrics", nil))
|
||||
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("/metrics does not report the GET /test served; "+
|
||||
"want the line %q in:\n%s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggingLeavesOutSubmittedSigningKey checks that a login, a POST /
|
||||
// whose form carries the signing key, leaves no trace of the key in the
|
||||
// request's log line.
|
||||
func TestLoggingLeavesOutSubmittedSigningKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const signingKey = "test-signing-key-0123456789abcdef"
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
mw := newTestMiddleware(t, &buf)
|
||||
|
||||
// The handler reads the key from the form, as the login handler does.
|
||||
handler := mw.Logging()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.FormValue("key"); got != signingKey {
|
||||
t.Errorf("key in form = %q, want %q", got, signingKey)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
}))
|
||||
|
||||
form := url.Values{"key": {signingKey}}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if !strings.Contains(buf.String(), `"method":"POST"`) {
|
||||
t.Fatalf("no log line for the request; got %q", buf.String())
|
||||
}
|
||||
|
||||
if strings.Contains(buf.String(), signingKey) {
|
||||
t.Errorf("log output contains the signing key; got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// TestNoMetricsRecordedWithoutMetricsUsername checks that with no metrics
|
||||
// username set the router records nothing about the requests it serves.
|
||||
// /metrics is not served then, so the process-wide Prometheus registry is
|
||||
// read directly. TestMaintenanceModeKeepsOtherRoutes records into the same
|
||||
// registry, but never a GET /robots.txt.
|
||||
func TestNoMetricsRecordedWithoutMetricsUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/robots.txt", nil))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
promhttp.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/metrics", nil))
|
||||
|
||||
if strings.Contains(rec.Body.String(), `handler="/robots.txt"`) {
|
||||
t.Errorf("with no metrics username GET /robots.txt was recorded:\n%s",
|
||||
rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user