1package middleware
2
3import (
4 "fmt"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 out "github.com/bnema/gordon/internal/boundaries/out"
13)
14
15// mockAccessLogWriter records written entries and optionally returns an error.
16type mockAccessLogWriter struct {
17 entries []out.AccessLogEntry
18 err error
19}
20
21func (m *mockAccessLogWriter) Write(entry out.AccessLogEntry) error {
22 m.entries = append(m.entries, entry)
23 return m.err
24}
25
26func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) {
27 mock := &mockAccessLogWriter{}
28 log := testLogger()
29
30 handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31 w.WriteHeader(http.StatusOK)
32 _, _ = w.Write([]byte("hello"))
33 }))
34
35 req := httptest.NewRequest(http.MethodGet, "/some/path?foo=bar", nil)
36 req.RemoteAddr = "192.168.1.100:12345"
37 req.Header.Set("User-Agent", "test-agent")
38 req.Header.Set("Referer", "https://example.com")
39 req.RequestURI = "/some/path?foo=bar"
40 req.Host = "example.com"
41 req.Proto = "HTTP/1.1"
42 rw := httptest.NewRecorder()
43
44 handler.ServeHTTP(rw, req)
45
46 require.Len(t, mock.entries, 1)
47 e := mock.entries[0]
48 assert.Equal(t, http.MethodGet, e.Method)
49 assert.Equal(t, "/some/path", e.Path)
50 assert.Equal(t, "foo=bar", e.Query)
51 assert.Equal(t, "example.com", e.Host)
52 assert.Equal(t, http.StatusOK, e.Status)
53 assert.Equal(t, 5, e.BytesSent)
54 assert.Equal(t, "test-agent", e.UserAgent)
55 assert.Equal(t, "https://example.com", e.Referer)
56 assert.Equal(t, "HTTP/1.1", e.Proto)
57 assert.NotEmpty(t, e.RequestID)
58 assert.Equal(t, "192.168.1.100", e.ClientIP)
59}
60
61func TestAccessLogger_RequestIDReused(t *testing.T) {
62 mock := &mockAccessLogWriter{}
63 log := testLogger()
64
65 handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66 w.WriteHeader(http.StatusOK)
67 }))
68
69 req := httptest.NewRequest(http.MethodGet, "/", nil)
70 req.Header.Set("X-Request-ID", "fixed-id-123")
71 rw := httptest.NewRecorder()
72
73 handler.ServeHTTP(rw, req)
74
75 require.Len(t, mock.entries, 1)
76 assert.Equal(t, "fixed-id-123", mock.entries[0].RequestID)
77}
78
79func TestAccessLogger_ExcludeHealthCheckUA(t *testing.T) {
80 mock := &mockAccessLogWriter{}
81 log := testLogger()
82
83 handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84 w.WriteHeader(http.StatusOK)
85 }))
86
87 req := httptest.NewRequest(http.MethodGet, "/health", nil)
88 req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0")
89 rw := httptest.NewRecorder()
90
91 handler.ServeHTTP(rw, req)
92
93 assert.Empty(t, mock.entries, "health-check request should not be logged")
94}
95
96func TestAccessLogger_ExcludeLoopbackIP(t *testing.T) {
97 mock := &mockAccessLogWriter{}
98 log := testLogger()
99
100 handler := AccessLogger(mock, true, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101 w.WriteHeader(http.StatusOK)
102 }))
103
104 req := httptest.NewRequest(http.MethodGet, "/health", nil)
105 req.RemoteAddr = "127.0.0.1:54321"
106 rw := httptest.NewRecorder()
107
108 handler.ServeHTTP(rw, req)
109
110 assert.Empty(t, mock.entries, "loopback request should not be logged")
111}
112
113func TestAccessLogger_NoExcludeWhenDisabled(t *testing.T) {
114 mock := &mockAccessLogWriter{}
115 log := testLogger()
116
117 handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118 w.WriteHeader(http.StatusOK)
119 }))
120
121 req := httptest.NewRequest(http.MethodGet, "/health", nil)
122 req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0")
123 rw := httptest.NewRecorder()
124
125 handler.ServeHTTP(rw, req)
126
127 assert.Len(t, mock.entries, 1, "health-check request should be logged when excludeHealthChecks=false")
128}
129
130func TestAccessLogger_WriteFailureDoesNotAffectResponse(t *testing.T) {
131 mock := &mockAccessLogWriter{err: fmt.Errorf("disk full")}
132 log := testLogger()
133
134 handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135 w.WriteHeader(http.StatusOK)
136 _, _ = w.Write([]byte("ok"))
137 }))
138
139 req := httptest.NewRequest(http.MethodGet, "/", nil)
140 rw := httptest.NewRecorder()
141
142 // Should not panic or change the response.
143 handler.ServeHTTP(rw, req)
144
145 assert.Equal(t, http.StatusOK, rw.Code)
146 assert.Equal(t, "ok", rw.Body.String())
147}
148
149func TestAccessLogger_PanicRecoveryChain_Captures500(t *testing.T) {
150 mock := &mockAccessLogWriter{}
151 log := testLogger()
152
153 // Chain: AccessLogger (outer) → PanicRecovery → panicking handler
154 panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155 panic("test panic")
156 })
157 chain := AccessLogger(mock, false, log, nil)(PanicRecovery(log)(panicHandler))
158
159 req := httptest.NewRequest(http.MethodGet, "/crash", nil)
160 rw := httptest.NewRecorder()
161
162 chain.ServeHTTP(rw, req)
163
164 // PanicRecovery should have written 500.
165 assert.Equal(t, http.StatusInternalServerError, rw.Code)
166
167 // AccessLogger must have captured the 500.
168 require.Len(t, mock.entries, 1)
169 assert.Equal(t, http.StatusInternalServerError, mock.entries[0].Status)
170}