1package docker
2
3import (
4 "bufio"
5 "context"
6 "encoding/binary"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/moby/moby/api/pkg/stdcopy"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "github.com/bnema/gordon/internal/domain"
18)
19
20func TestRuntime_StreamContainerLogs_DemuxesTimestampedLines(t *testing.T) {
21 var gotFollow string
22 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23 gotFollow = r.URL.Query().Get("follow")
24 w.Header().Set("Content-Type", "application/vnd.docker.multiplexed-stream")
25 _, _ = w.Write(muxFrame(stdcopy.Stdout, "2026-09-10T12:00:01.5Z hello\n"))
26 _, _ = w.Write(muxFrame(stdcopy.Stderr, "2026-09-10T12:00:02Z boom\n"))
27 }))
28 defer server.Close()
29
30 runtime := newRuntimeForHTTPServer(t, server)
31 var lines []domain.ContainerLogLine
32 err := runtime.StreamContainerLogs(context.Background(), "c1", time.Time{}, func(l domain.ContainerLogLine) {
33 lines = append(lines, l)
34 })
35
36 require.NoError(t, err)
37 assert.Equal(t, "1", gotFollow)
38 require.Len(t, lines, 2)
39 byStream := map[string]domain.ContainerLogLine{}
40 for _, l := range lines {
41 byStream[l.Stream] = l
42 }
43 assert.Equal(t, "hello", byStream[domain.LogStreamStdout].Body)
44 assert.Equal(t, time.Date(2026, 9, 10, 12, 0, 1, 500000000, time.UTC), byStream[domain.LogStreamStdout].Time)
45 assert.Equal(t, "boom", byStream[domain.LogStreamStderr].Body)
46}
47
48// muxFrame encodes one Docker multiplexed-stream frame.
49func muxFrame(stream stdcopy.StdType, payload string) []byte {
50 frame := make([]byte, 8, 8+len(payload))
51 frame[0] = byte(stream)
52 binary.BigEndian.PutUint32(frame[4:], uint32(len(payload)))
53 return append(frame, payload...)
54}
55
56func TestRuntime_StreamContainerLogs_NotFound(t *testing.T) {
57 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
58 w.Header().Set("Content-Type", "application/json")
59 w.WriteHeader(http.StatusNotFound)
60 _, _ = w.Write([]byte(`{"message":"No such container: gone"}`))
61 }))
62 defer server.Close()
63
64 runtime := newRuntimeForHTTPServer(t, server)
65 err := runtime.StreamContainerLogs(context.Background(), "gone", time.Time{}, func(domain.ContainerLogLine) {})
66
67 assert.ErrorIs(t, err, domain.ErrContainerNotFound)
68}
69
70func TestReadBoundedLine_TruncatesOversizedLines(t *testing.T) {
71 long := strings.Repeat("x", maxLogLineBytes+100)
72 reader := bufio.NewReaderSize(strings.NewReader(long+"\nnext\n"), 16)
73
74 first, err := readBoundedLine(reader)
75 require.NoError(t, err)
76 second, err := readBoundedLine(reader)
77 require.NoError(t, err)
78
79 assert.Len(t, first, maxLogLineBytes)
80 assert.Equal(t, "next", second)
81}
82
83func TestParseTimestampedLine_KeepsBodyWithoutTimestamp(t *testing.T) {
84 line := parseTimestampedLine("plain text line", domain.LogStreamStdout)
85 assert.Equal(t, "plain text line", line.Body)
86}
87
88func TestParseTimestampedLine_SanitizesInvalidUTF8(t *testing.T) {
89 line := parseTimestampedLine("2026-09-10T12:00:01Z bad\xffbytes", domain.LogStreamStdout)
90 assert.Equal(t, "bad\uFFFDbytes", line.Body)
91
92 plain := parseTimestampedLine("plain\xff", domain.LogStreamStdout)
93 assert.Equal(t, "plain\uFFFD", plain.Body)
94}