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 var seen []string
42 runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(
43 func(_ context.Context, _ domain.Principal, _ domain.RepoName, p ports.GetJobLogParams, send func(ports.LogBatch) error) error {
44 seen = append(seen, p.Client)
45 return send(ports.LogBatch{Lines: []ports.JobLogLine{{Step: 1, CILogLine: domain.CILogLine{Time: time.Unix(0, 0), Text: "x"}}}})
46 })
47
48 browse := mocks.NewBrowseService(t)
49 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
50 srv := newTestServer(t, Config{
51 Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs,
52 TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
53 })
54 for _, path := range []string{
55 "/proj/actions/runs/1/job/" + jobID,
56 "/proj/actions/runs/1/job/" + jobID + "/raw",
57 "/proj/actions/runs/1/job/" + jobID + "/stream",
58 } {
59 req := httptest.NewRequest(http.MethodGet, path, nil)
60 req.RemoteAddr = "192.0.2.1:4000"
61 req.Header.Set("X-Forwarded-For", "198.51.100.7")
62 rec := httptest.NewRecorder()
63 srv.ServeHTTP(rec, req)
64 require.Equal(t, http.StatusOK, rec.Code, path)
65 }
66 require.Len(t, seen, 3)
67 for _, c := range seen {
68 assert.Equal(t, want, c)
69 }
70}
71
72// M4: one helper shapes the lines of the page, the raw feed and the live feed.
73func TestAppendLogLine(t *testing.T) {
74 t.Parallel()
75 at := time.Date(2026, 10, 12, 10, 11, 12, 0, time.UTC)
76 line := func(text string, cont bool) ports.JobLogLine {
77 return ports.JobLogLine{Step: 2, CILogLine: domain.CILogLine{Time: at, Text: text, Continued: cont}}
78 }
79 var out []LogLineView
80 out = appendLogLine(out, 10, line("##[group]Run make", false))
81 out = appendLogLine(out, 11, line("plain <b>", false))
82 out = appendLogLine(out, 12, line("##[error]Process completed with exit code 1.", false))
83 out = appendLogLine(out, 13, line("##[endgroup]", false))
84 require.Len(t, out, 4)
85 assert.True(t, out[0].Group)
86 assert.Equal(t, "Run make", out[0].Text, "the marker is structure, not text")
87 assert.EqualValues(t, 10, out[0].N)
88 assert.Equal(t, "10:11:12", out[0].Time)
89 assert.EqualValues(t, 2, out[0].Step)
90 assert.False(t, out[1].Group || out[1].Error || out[1].End)
91 assert.True(t, out[2].Error)
92 assert.Equal(t, "Process completed with exit code 1.", out[2].Text)
93 assert.True(t, out[3].End)
94
95 // A cut line comes back as one line, with the number of its first piece.
96 var cut []LogLineView
97 cut = appendLogLine(cut, 20, line("first half, ", false))
98 cut = appendLogLine(cut, 21, line("second half", true))
99 require.Len(t, cut, 1)
100 assert.Equal(t, "first half, second half", cut[0].Text)
101 assert.EqualValues(t, 20, cut[0].N)
102
103 // A piece that opens a batch has no line before it here: it stays, marked,
104 // for the page to join to the line it already holds.
105 open := appendLogLine(nil, 30, line("rest", true))
106 require.Len(t, open, 1)
107 assert.True(t, open[0].Continued)
108
109 // Invisible characters of the runner's text are shown, never applied.
110 hidden := appendLogLine(nil, 40, line("a\u202eb", false))
111 assert.NotContains(t, hidden[0].Text, "\u202e")
112}