1package accesslog
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 out "github.com/bnema/gordon/internal/boundaries/out"
18)
19
20var sampleEntry = out.AccessLogEntry{
21 Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC),
22 ClientIP: "10.0.0.1",
23 Method: "POST",
24 Host: "api.example.com",
25 Path: "/api/v1/data",
26 Query: "limit=10",
27 Status: 200,
28 BytesSent: 512,
29 DurationMS: 3.5,
30 UserAgent: "curl/7.88",
31 Referer: "https://example.com",
32 RequestID: "abc123",
33 Proto: "HTTP/2.0",
34}
35
36// --- New() validation ---
37
38func TestNew_InvalidFormat(t *testing.T) {
39 _, err := New(Config{Format: "xml", Output: "stdout"})
40 require.Error(t, err)
41 assert.Contains(t, err.Error(), "invalid logging.access_log.format")
42}
43
44func TestNew_InvalidOutput(t *testing.T) {
45 _, err := New(Config{Format: "json", Output: "kafka"})
46 require.Error(t, err)
47 assert.Contains(t, err.Error(), "invalid logging.access_log.output")
48}
49
50func TestNew_FileOutput_EmptyPath(t *testing.T) {
51 _, err := New(Config{Format: "json", Output: "file", FilePath: ""})
52 require.Error(t, err)
53}
54
55func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
56 dir := t.TempDir()
57 path := filepath.Join(dir, "sub", "access.log")
58 w, err := New(Config{
59 Format: "json",
60 Output: "file",
61 FilePath: path,
62 MaxSize: 10,
63 })
64 require.NoError(t, err)
65 defer w.Close()
66
67 assert.DirExists(t, filepath.Join(dir, "sub"))
68}
69
70// --- stdout sink ---
71
72func TestWriter_Stdout_JSON(t *testing.T) {
73 // Replace stdout to capture output.
74 orig := os.Stdout
75 r, w, err := os.Pipe()
76 require.NoError(t, err)
77 os.Stdout = w
78
79 writer, err := New(Config{Format: "json", Output: "stdout"})
80 require.NoError(t, err)
81
82 require.NoError(t, writer.Write(sampleEntry))
83 require.NoError(t, writer.Close())
84
85 w.Close()
86 os.Stdout = orig
87
88 var buf bytes.Buffer
89 _, _ = buf.ReadFrom(r)
90 line := strings.TrimSpace(buf.String())
91
92 var got map[string]any
93 require.NoError(t, json.Unmarshal([]byte(line), &got), "output must be valid JSON: %s", line)
94 assert.Equal(t, "10.0.0.1", got["client_ip"])
95 assert.Equal(t, "POST", got["method"])
96 assert.InDelta(t, 200, got["status"], 0)
97}
98
99func TestWriter_Stdout_CLF(t *testing.T) {
100 orig := os.Stdout
101 r, w, err := os.Pipe()
102 require.NoError(t, err)
103 os.Stdout = w
104
105 writer, err := New(Config{Format: "clf", Output: "stdout"})
106 require.NoError(t, err)
107 require.NoError(t, writer.Write(sampleEntry))
108 require.NoError(t, writer.Close())
109
110 w.Close()
111 os.Stdout = orig
112
113 var buf bytes.Buffer
114 _, _ = buf.ReadFrom(r)
115 line := strings.TrimSpace(buf.String())
116
117 assert.True(t, strings.HasPrefix(line, "10.0.0.1 - - ["))
118 assert.Contains(t, line, `"POST /api/v1/data?limit=10 HTTP/2.0"`)
119 assert.Contains(t, line, " 200 512")
120}
121
122func TestWriter_Stdout_Combined(t *testing.T) {
123 orig := os.Stdout
124 r, w, err := os.Pipe()
125 require.NoError(t, err)
126 os.Stdout = w
127
128 writer, err := New(Config{Format: "combined", Output: "stdout"})
129 require.NoError(t, err)
130 require.NoError(t, writer.Write(sampleEntry))
131 require.NoError(t, writer.Close())
132
133 w.Close()
134 os.Stdout = orig
135
136 var buf bytes.Buffer
137 _, _ = buf.ReadFrom(r)
138 line := strings.TrimSpace(buf.String())
139
140 assert.Contains(t, line, `"https://example.com"`)
141 assert.Contains(t, line, `"curl/7.88"`)
142}
143
144// --- file sink ---
145
146func TestWriter_File_WritesLines(t *testing.T) {
147 dir := t.TempDir()
148 path := filepath.Join(dir, "access.log")
149
150 writer, err := New(Config{
151 Format: "json",
152 Output: "file",
153 FilePath: path,
154 MaxSize: 10,
155 MaxBackups: 1,
156 })
157 require.NoError(t, err)
158
159 require.NoError(t, writer.Write(sampleEntry))
160 require.NoError(t, writer.Write(sampleEntry))
161 require.NoError(t, writer.Close())
162
163 data, err := os.ReadFile(path)
164 require.NoError(t, err)
165
166 lines := strings.Split(strings.TrimSpace(string(data)), "\n")
167 assert.Len(t, lines, 2)
168 for _, l := range lines {
169 var obj map[string]any
170 assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
171 }
172}
173
174// --- concurrency ---
175
176func TestWriter_ConcurrentWrites(t *testing.T) {
177 dir := t.TempDir()
178 path := filepath.Join(dir, "concurrent.log")
179
180 writer, err := New(Config{
181 Format: "json",
182 Output: "file",
183 FilePath: path,
184 MaxSize: 100,
185 })
186 require.NoError(t, err)
187 defer writer.Close()
188
189 const goroutines = 50
190 var wg sync.WaitGroup
191 wg.Add(goroutines)
192 for i := range goroutines {
193 go func(n int) {
194 defer wg.Done()
195 e := sampleEntry
196 e.RequestID = fmt.Sprintf("req-%d", n)
197 assert.NoError(t, writer.Write(e))
198 }(i)
199 }
200 wg.Wait()
201
202 data, err := os.ReadFile(path)
203 require.NoError(t, err)
204
205 lines := strings.Split(strings.TrimSpace(string(data)), "\n")
206 assert.Len(t, lines, goroutines, "each goroutine should produce exactly one line")
207 for _, l := range lines {
208 var obj map[string]any
209 assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
210 }
211}