v2.31.0-dev4 f8fb41e

formatter_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close