Added access log to server and modified mock-fts to use it

This commit is contained in:
bwilliams committed 2026-10-16 08:38:41 +01:00
1 parent 2490519c2b
commit 02e3d910a2
10 files changed
+389 -29

No files matched your search

+21 -4
View File
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/http"
@@ -17,6 +18,7 @@ import (
"github.com/pires/go-proxyproto"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/logging"
"git.avsdev.uk/Go/job-search/internal/middleware"
"git.avsdev.uk/Go/job-search/internal/util"
"git.avsdev.uk/Go/job-search/internal/websocket"
@@ -49,6 +51,16 @@ func WithLogger(logger *slog.Logger) Option {
}
}
// WithAccessLog makes the server write a line to logger for every request, in
// the Combined Log Format (see logging.AccessLog), as well as the request
// lines it logs with slog. The line is written when the request ends, and
// covers requests that middleware refuses.
func WithAccessLog(logger *log.Logger) Option {
return func(s *Server) {
s.accessLog = logger
}
}
// WithMiddleware appends middleware to a group. This is the generic hook
// (auth, metrics, tracing, custom header rules, ...).
func WithMiddleware(group Group, middlewares ...Middleware) Option {
@@ -82,10 +94,12 @@ type limiterHook struct {
// Server is the application server.
type Server struct {
conf *config.JSCServer
log *slog.Logger
hooks map[Group][]Middleware
limiters map[Group]limiterHook
conf *config.JSCServer
log *slog.Logger
// accessLog is where the access log is written, or nil for none.
accessLog *log.Logger
hooks map[Group][]Middleware
limiters map[Group]limiterHook
// trusted are the proxies whose forwarding headers are believed.
trusted []netip.Prefix
@@ -156,6 +170,9 @@ func New(conf *config.JSCServer, opts ...Option) (*Server, error) {
server.backgroundCancel()
return nil, err
}
if server.accessLog != nil {
server.handler = logging.AccessLog(server.accessLog, true, server.handler)
}
return server, nil
}
+89
View File
@@ -1,13 +1,16 @@
package server
import (
"bytes"
"context"
"encoding/json"
"log"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
@@ -218,3 +221,89 @@ func TestNoLimitIfUnset(t *testing.T) {
}
}
}
// With WithAccessLog, each request, including one refused by middleware, is
// written to the access log in the Combined Log Format.
func TestAccessLog(t *testing.T) {
var buffer bytes.Buffer
ts := newTestServer(t, func(conf *config.JSCServer) {
conf.Headers.AllowedHosts = []string{"example.com"}
}, WithAccessLog(log.New(&buffer, "", 0)))
req, _ := http.NewRequest("GET", ts.URL+"/api/health", nil)
req.Host = "example.com"
req.Header.Set("User-Agent", "test-agent")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
// A Host that isn't allowed is refused by RequestChecks.
res, err = http.Get(ts.URL + "/api/health")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
lines := strings.Split(strings.TrimSpace(buffer.String()), "\n")
if len(lines) != 2 {
t.Fatalf("access log has %d lines; want 2:\n%s", len(lines), &buffer)
}
if !strings.Contains(lines[0], `"GET /api/health HTTP/1.1" 200`) ||
!strings.Contains(lines[0], `"test-agent"`) {
t.Errorf("first line = %q; want a 200 with the user agent", lines[0])
}
if !strings.Contains(lines[1], `"GET /api/health HTTP/1.1" 421`) {
t.Errorf("second line = %q; want the refused request's 421", lines[1])
}
}
// With WithAccessLog, a WebSocket upgrade still hijacks the connection: the
// client gets its welcome, and the upgrade is logged with 101.
func TestAccessLogWebSocket(t *testing.T) {
var buffer syncBuffer
ts := newTestServer(t, nil, WithAccessLog(log.New(&buffer, "", 0)))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws"
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatal(err)
}
var welcome ws.Envelope
err = wsjson.Read(ctx, conn, &welcome)
if err != nil || welcome.Type != ws.TypeWelcome {
t.Fatalf("want welcome, got %+v, %v", welcome, err)
}
conn.Close(websocket.StatusNormalClosure, "")
// The line is written when the connection's handler returns.
deadline := time.Now().Add(time.Second)
for !strings.Contains(buffer.String(), `"GET /ws HTTP/1.1" 101`) {
if time.Now().After(deadline) {
t.Fatalf("upgrade not logged with 101: %q", buffer.String())
}
time.Sleep(5 * time.Millisecond)
}
}
// syncBuffer is a bytes.Buffer safe to write from one goroutine while
// another reads it.
type syncBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
}
func (b *syncBuffer) Write(data []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Write(data)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}