1package accesslog
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7 "time"
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
15var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC)
16
17var baseEntry = out.AccessLogEntry{
18 Time: testTime,
19 ClientIP: "135.181.213.219",
20 Method: "GET",
21 Host: "traefik.example.dev",
22 Path: "/robots.txt",
23 Query: "",
24 Status: 404,
25 BytesSent: 19,
26 DurationMS: 0.167,
27 UserAgent: "MJ12bot/v1.4.8",
28 Referer: "",
29 RequestID: "95126efcb65d4df81f7ae633e2a712cd",
30 Proto: "HTTP/1.1",
31}
32
33// --- JSON formatter ---
34
35func TestFormatJSON_AllFields(t *testing.T) {
36 line, err := formatJSON(baseEntry)
37 require.NoError(t, err)
38
39 var got map[string]any
40 require.NoError(t, json.Unmarshal([]byte(line), &got))
41
42 assert.Equal(t, "2026-04-03T14:20:54.167Z", got["time"])
43 assert.Equal(t, "135.181.213.219", got["client_ip"])
44 assert.Equal(t, "GET", got["method"])
45 assert.Equal(t, "traefik.example.dev", got["host"])
46 assert.Equal(t, "/robots.txt", got["path"])
47 assert.Equal(t, "", got["query"])
48 assert.InDelta(t, 404, got["status"], 0)
49 assert.InDelta(t, 19, got["bytes_sent"], 0)
50 assert.InDelta(t, 0.167, got["duration_ms"], 0.0001)
51 assert.Equal(t, "MJ12bot/v1.4.8", got["user_agent"])
52 assert.Equal(t, "", got["referer"])
53 assert.Equal(t, "95126efcb65d4df81f7ae633e2a712cd", got["request_id"])
54 assert.Equal(t, "HTTP/1.1", got["proto"])
55}
56
57func TestFormatJSON_OneLinePerEntry(t *testing.T) {
58 line, err := formatJSON(baseEntry)
59 require.NoError(t, err)
60 assert.False(t, strings.Contains(line, "\n"), "JSON line must not contain newline")
61}
62
63func TestFormatJSON_TimestampMillisecondPrecision(t *testing.T) {
64 // Ensure we get .167Z and not RFC3339Nano (which would be .167000000Z)
65 line, err := formatJSON(baseEntry)
66 require.NoError(t, err)
67 assert.Contains(t, line, `"2026-04-03T14:20:54.167Z"`)
68}
69
70func TestFormatJSON_ClientIPNoPort(t *testing.T) {
71 e := baseEntry
72 e.ClientIP = "10.0.0.1" // ensure no port
73 line, err := formatJSON(e)
74 require.NoError(t, err)
75 assert.Contains(t, line, `"client_ip":"10.0.0.1"`)
76}
77
78func TestFormatJSON_EmptyOptionalFields(t *testing.T) {
79 e := baseEntry
80 e.Query = ""
81 e.Referer = ""
82 e.UserAgent = ""
83 line, err := formatJSON(e)
84 require.NoError(t, err)
85
86 var got map[string]any
87 require.NoError(t, json.Unmarshal([]byte(line), &got))
88 assert.Equal(t, "", got["query"])
89 assert.Equal(t, "", got["referer"])
90 assert.Equal(t, "", got["user_agent"])
91}
92
93func TestFormatJSON_WithQuery(t *testing.T) {
94 e := baseEntry
95 e.Query = "foo=bar&baz=qux"
96 line, err := formatJSON(e)
97 require.NoError(t, err)
98 assert.Contains(t, line, `"query":"foo=bar\u0026baz=qux"`)
99}
100
101// --- CLF formatter ---
102
103func TestFormatCLF_BasicLine(t *testing.T) {
104 line, err := formatCLF(baseEntry)
105 require.NoError(t, err)
106 assert.Equal(t, `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19`, line)
107}
108
109func TestFormatCLF_WithQuery(t *testing.T) {
110 e := baseEntry
111 e.Query = "page=2"
112 line, err := formatCLF(e)
113 require.NoError(t, err)
114 assert.Contains(t, line, `"GET /robots.txt?page=2 HTTP/1.1"`)
115}
116
117func TestFormatCLF_NoRefererOrUA(t *testing.T) {
118 // CLF format should not include referer or UA
119 line, err := formatCLF(baseEntry)
120 require.NoError(t, err)
121 assert.NotContains(t, line, "MJ12bot")
122}
123
124func TestFormatCLF_EscapesQuotesInTarget(t *testing.T) {
125 e := baseEntry
126 e.Path = `/path/"quoted"`
127 line, err := formatCLF(e)
128 require.NoError(t, err)
129 // Forward slashes do not need escaping; only backslashes and double-quotes do.
130 assert.Contains(t, line, `/path/\"quoted\"`)
131}
132
133// --- Combined formatter ---
134
135func TestFormatCombined_BasicLine(t *testing.T) {
136 line, err := formatCombined(baseEntry)
137 require.NoError(t, err)
138 assert.Equal(t,
139 `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19 "-" "MJ12bot/v1.4.8"`,
140 line,
141 )
142}
143
144func TestFormatCombined_EmptyRefererIsDash(t *testing.T) {
145 e := baseEntry
146 e.Referer = ""
147 line, err := formatCombined(e)
148 require.NoError(t, err)
149 assert.Contains(t, line, `"-"`)
150}
151
152func TestFormatCombined_EmptyUAIsDash(t *testing.T) {
153 e := baseEntry
154 e.UserAgent = ""
155 line, err := formatCombined(e)
156 require.NoError(t, err)
157 // last quoted field should be "-"
158 assert.True(t, strings.HasSuffix(line, `"-"`))
159}
160
161func TestFormatCombined_WithReferer(t *testing.T) {
162 e := baseEntry
163 e.Referer = "https://example.com/"
164 line, err := formatCombined(e)
165 require.NoError(t, err)
166 assert.Contains(t, line, `"https://example.com/"`)
167}
168
169func TestFormatCombined_EscapesQuotesInUA(t *testing.T) {
170 e := baseEntry
171 e.UserAgent = `bot/"evil"`
172 line, err := formatCombined(e)
173 require.NoError(t, err)
174 // Forward slashes don't need escaping; only backslashes and double-quotes do.
175 assert.Contains(t, line, `"bot/\"evil\""`)
176}
177
178// --- buildRequestTarget ---
179
180func TestBuildRequestTarget_NoQuery(t *testing.T) {
181 assert.Equal(t, "/foo", buildRequestTarget("/foo", ""))
182}
183
184func TestBuildRequestTarget_WithQuery(t *testing.T) {
185 assert.Equal(t, "/foo?bar=1", buildRequestTarget("/foo", "bar=1"))
186}
187
188// --- escapeQuoted ---
189
190func TestEscapeQuoted(t *testing.T) {
191 assert.Equal(t, `hello`, escapeQuoted(`hello`))
192 assert.Equal(t, `say \"hi\"`, escapeQuoted(`say "hi"`))
193 assert.Equal(t, `back\\slash`, escapeQuoted(`back\slash`))
194 assert.Equal(t, `\"\\\"`, escapeQuoted(`"\"`))
195}
196
197func TestEscapeQuoted_ControlCharacters(t *testing.T) {
198 // Named control characters with dedicated escapes
199 assert.Equal(t, `line\nbreak`, escapeQuoted("line\nbreak"))
200 assert.Equal(t, `cr\rreturn`, escapeQuoted("cr\rreturn"))
201 assert.Equal(t, `a\ttab`, escapeQuoted("a\ttab"))
202
203 // NUL, BEL, ESC — hex-escaped
204 assert.Equal(t, `\x00`, escapeQuoted("\x00"))
205 assert.Equal(t, `\x07`, escapeQuoted("\x07"))
206 assert.Equal(t, `\x1b`, escapeQuoted("\x1b"))
207
208 // All control characters 0x00-0x1F should be escaped (none pass through raw)
209 for b := byte(0); b < 0x20; b++ {
210 result := escapeQuoted(string([]byte{b}))
211 assert.NotEqual(t, string([]byte{b}), result,
212 "control byte 0x%02x should be escaped", b)
213 }
214}