1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "net/http/httptest"
9 "net/netip"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/clientlimit"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20 "bnema.dev/ermite/internal/ports/mocks"
21)
22
23// S6: every page that streams a log through the use case names the visitor, so
24// that the limit of log streams per client applies to the pages too, behind a
25// trusted proxy by the address it forwards.
26func TestLogPagesPassTheClientKey(t *testing.T) {
27 t.Parallel()
28 const jobID = "1-0-0"
29 repo := repoName(t, "proj")
30 job := ports.JobView{CIJob: domain.CIJob{
31 ID: jobID, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build",
32 Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
33 }, Lines: 1}
34 want := clientlimit.Key("198.51.100.7")
35
36 runs := mocks.NewRunService(t)
37 runs.EXPECT().GetJob(mock.Anything, mock.Anything, repo, jobID, int32(0)).Return(job, nil).Maybe()
38 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
39 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1}, LatestAttempt: 1,
40 }, nil).Maybe()
41 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{}, nil).Maybe()
42 var seen []string
43 runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(
44 func(_ context.Context, _ domain.Principal, _ domain.RepoName, p ports.GetJobLogParams, send func(ports.LogBatch) error) error {
45 seen = append(seen, p.Client)
46 return send(ports.LogBatch{Lines: []ports.JobLogLine{{Step: 1, CILogLine: domain.CILogLine{Time: time.Unix(0, 0), Text: "x"}}}})
47 })
48
49 browse := mocks.NewBrowseService(t)
50 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
51 srv := newTestServer(t, Config{
52 Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs,
53 TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
54 })
55 for _, path := range []string{
56 "/proj/actions/runs/1/job/" + jobID,
57 "/proj/actions/runs/1/job/" + jobID + "/raw",
58 "/proj/actions/runs/1/job/" + jobID + "/stream",
59 } {
60 req := httptest.NewRequest(http.MethodGet, path, nil)
61 req.RemoteAddr = "192.0.2.1:4000"
62 req.Header.Set("X-Forwarded-For", "198.51.100.7")
63 rec := httptest.NewRecorder()
64 srv.ServeHTTP(rec, req)
65 require.Equal(t, http.StatusOK, rec.Code, path)
66 }
67 require.Len(t, seen, 3)
68 for _, c := range seen {
69 assert.Equal(t, want, c)
70 }
71}
72
73// M4: one helper shapes the lines of the page, the raw feed and the live feed.
74func TestAppendLogLine(t *testing.T) {
75 t.Parallel()
76 at := time.Date(2026, 10, 12, 10, 11, 12, 0, time.UTC)
77 line := func(text string, cont bool) ports.JobLogLine {
78 return ports.JobLogLine{Step: 2, CILogLine: domain.CILogLine{Time: at, Text: text, Continued: cont}}
79 }
80 var out []LogLineView
81 out = appendLogLine(out, 10, line("##[group]Run make", false))
82 out = appendLogLine(out, 11, line("plain <b>", false))
83 out = appendLogLine(out, 12, line("##[error]Process completed with exit code 1.", false))
84 out = appendLogLine(out, 13, line("##[endgroup]", false))
85 require.Len(t, out, 4)
86 assert.True(t, out[0].Group)
87 assert.Equal(t, "Run make", out[0].Text, "the marker is structure, not text")
88 assert.EqualValues(t, 10, out[0].N)
89 assert.Equal(t, "10:11:12", out[0].Time)
90 assert.EqualValues(t, 2, out[0].Step)
91 assert.False(t, out[1].Group || out[1].Error || out[1].End)
92 assert.True(t, out[2].Error)
93 assert.Equal(t, "Process completed with exit code 1.", out[2].Text)
94 assert.True(t, out[3].End)
95
96 // A cut line comes back as one line, with the number of its first piece.
97 var cut []LogLineView
98 cut = appendLogLine(cut, 20, line("first half, ", false))
99 cut = appendLogLine(cut, 21, line("second half", true))
100 require.Len(t, cut, 1)
101 assert.Equal(t, "first half, second half", cut[0].Text)
102 assert.EqualValues(t, 20, cut[0].N)
103
104 // A piece that opens a batch has no line before it here: it stays, marked,
105 // for the page to join to the line it already holds.
106 open := appendLogLine(nil, 30, line("rest", true))
107 require.Len(t, open, 1)
108 assert.True(t, open[0].Continued)
109
110 // Invisible characters of the runner's text are shown, never applied.
111 hidden := appendLogLine(nil, 40, line("a\u202eb", false))
112 assert.NotContains(t, hidden[0].Text, "\u202e")
113}