// SPDX-License-Identifier: AGPL-3.0-or-later package web import ( "bufio" "context" "errors" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "connectrpc.com/connect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" pb "bnema.dev/ermite-api/gen/ermite" "bnema.dev/ermite-api/gen/ermite/ermiteconnect" ) func TestFormatEvent(t *testing.T) { assert.Equal(t, "event: home-list\nid: b-1\ndata: \ndata: 1\ndata: \n\n", formatEvent("b-1", "home-list", "\n 1\n", true), "one data line per line") assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true)) assert.Equal(t, "id: b-2\n\n", formatEvent("b-2", "", "", false), "an id alone") assert.Equal(t, "event: e\ndata: a\ndata: b\ndata: c\n\n", formatEvent("", "e", "a\r\nb\rc", true), "no line break escapes the field") assert.Equal(t, "event: e\ndata: \n\n", formatEvent("", "e", "", true)) } func TestParseTopic(t *testing.T) { for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { repo, ok := parseTopic(in) assert.True(t, ok, in) assert.Equal(t, want, repo, in) } for _, in := range []string{"", "repo:", "other", "home,repo:x", "Repo:x", "repo"} { _, ok := parseTopic(in) assert.False(t, ok, in) } } func TestLiveURL(t *testing.T) { assert.Equal(t, "/-/events?topic=home", liveURL("home", "")) assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs")) } func TestNewCommitsText(t *testing.T) { assert.Equal(t, "1 new commit — reload", newCommits("1", 1)) assert.Equal(t, "3 new commits — reload", newCommits("3", 3)) assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) } // fakeEvents is an EventService whose behaviour a test sets. type fakeEvents struct { ermiteconnect.UnimplementedEventServiceHandler err error events []*pb.Event opened atomic.Int32 closed atomic.Int32 resume atomic.Value release chan struct{} push chan *pb.Event // events sent on demand, after the fixed ones } func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error { f.opened.Add(1) defer f.closed.Add(1) f.resume.Store(req.Msg.GetResumeAfter()) if f.err != nil { return f.err } _ = st.Send(nil) for _, ev := range f.events { if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { return err } } for { select { case <-ctx.Done(): return nil case <-f.release: return nil case ev := <-f.push: if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { return err } } } } // repoOK answers every GetRepo with a public repository. type repoOK struct { ermiteconnect.UnimplementedRepoServiceHandler } func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil } // eventsServer serves the web on a fake API, with a write timeout shorter // than the heartbeat, like a server that sets one. func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server { t.Helper() return eventsServerWith(t, f, beat, writeTimeout, nil) } // eventsServerWith is eventsServer with sessions, when sess is not nil. func eventsServerWith(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration, sess SessionAuth) *httptest.Server { t.Helper() api := http.NewServeMux() api.Handle(ermiteconnect.NewEventServiceHandler(f)) api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) backend := httptest.NewServer(api) t.Cleanup(backend.Close) srv, err := New(Config{ Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL), PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess), }) require.NoError(t, err) ts := httptest.NewUnstartedServer(srv) ts.Config.WriteTimeout = writeTimeout ts.Start() t.Cleanup(ts.Close) return ts } func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string) (*http.Response, context.CancelFunc) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/-/events?"+query, nil) require.NoError(t, err) for k, v := range hdr { req.Header.Set(k, v) } resp, err := ts.Client().Do(req) require.NoError(t, err) t.Cleanup(func() { cancel(); _ = resp.Body.Close() }) return resp, cancel } // An idle stream gets a comment every heartbeat, and the write timeout of the // server does not end it. func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { f := &fakeEvents{release: make(chan struct{})} ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) resp, cancel := open(t, ts, "topic=home", nil) require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) sc := bufio.NewScanner(resp.Body) beats := 0 deadline := time.AfterFunc(5*time.Second, cancel) defer deadline.Stop() start := time.Now() for sc.Scan() && beats < 8 { if strings.HasPrefix(sc.Text(), ": heartbeat") { beats++ } } assert.Equal(t, 8, beats) assert.Greater(t, time.Since(start), 250*time.Millisecond, "the stream outlived the 100 ms write timeout") } // The id of the event, its name and the lines of its data come through; the // Last-Event-ID header is the resume point; a Gap becomes a gap event. func TestEventsStreamsEventsAndResumes(t *testing.T) { f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{ {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}}, {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}, }} ts := eventsServer(t, f, time.Hour, 0) resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) require.Equal(t, http.StatusOK, resp.StatusCode) sc := bufio.NewScanner(resp.Body) var lines []string for sc.Scan() { lines = append(lines, sc.Text()) if sc.Text() == "data: gap" { break } } assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") assert.Contains(t, lines, "event: gap") assert.Equal(t, "b-6", f.resume.Load()) } // Too many watchers is a 503 the browser can retry, not a stream. func TestEventsResourceExhausted(t *testing.T) { f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))} ts := eventsServer(t, f, time.Hour, 0) resp, _ := open(t, ts, "topic=home", nil) assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) assert.NotEmpty(t, resp.Header.Get("Retry-After")) assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") f.err = connect.NewError(connect.CodeNotFound, errors.New("x")) resp, _ = open(t, ts, "topic=home", nil) assert.Equal(t, http.StatusNotFound, resp.StatusCode) f.err = errors.New("boom") resp, _ = open(t, ts, "topic=home", nil) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) } // The handler returns, and the watch it opened ends, when the visitor goes // away or the API ends the stream. func TestEventsNoLeak(t *testing.T) { f := &fakeEvents{release: make(chan struct{})} ts := eventsServer(t, f, time.Hour, 0) resp, cancel := open(t, ts, "topic=home", nil) require.Equal(t, http.StatusOK, resp.StatusCode) require.Eventually(t, func() bool { return f.opened.Load() == 1 }, 5*time.Second, time.Millisecond) cancel() require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the visitor left") resp, _ = open(t, ts, "topic=home", nil) require.Equal(t, http.StatusOK, resp.StatusCode) require.Eventually(t, func() bool { return f.opened.Load() == 2 }, 5*time.Second, time.Millisecond) close(f.release) // the API ends the stream (shutdown) done := make(chan struct{}) go func() { _, _ = bufio.NewReader(resp.Body).ReadString(0); close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("the stream did not end with the API's") } require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) } func TestEventsOffWithoutClient(t *testing.T) { srv, err := New(Config{ Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"), Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"), PublicURL: "https://forge.example", }) require.NoError(t, err) rec := httptest.NewRecorder() srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) assert.Equal(t, http.StatusNotFound, rec.Code) } // A change of the repository (its default branch, say) makes the pages that // show it load themselves again: the summary and the refs page, whose rows // and marks depend on it. The commits page shows a branch, not the default. func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { api := http.NewServeMux() api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) backend := httptest.NewServer(api) t.Cleanup(backend.Close) srv, err := New(Config{ Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), PublicURL: "https://forge.example", }) require.NoError(t, err) ev := &pb.Event{Kind: &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: &pb.Repo{Name: "x"}}}} for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} { ls := &liveStream{s: srv, repo: "x", view: view} got, err := ls.render(t.Context(), ev) require.NoError(t, err, view) if wantGap { assert.Equal(t, gap, got, view) } else { assert.Empty(t, got, view) } } } func limiterFor(sess SessionAuth) FailureLimiter { if sess == nil { return nil } return &countingLimiter{} } // revocable is a SessionAuth whose one session ends when the test says so, like // SignOutEverywhere does. type revocable struct { fakeSessions ended atomic.Bool } func (r *revocable) Verify(ctx context.Context, tok string) (context.Context, bool, error) { return ctx, tok == r.valid && !r.ended.Load(), nil } // GO-002: the stream of the owner is checked before each event is sent, not // only on the heartbeat: after the session ended, the next event is not sent // and the stream ends. func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}} ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) require.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Cache-Control"), "private") // Without the check the stream would stay open: bound the read. defer time.AfterFunc(5*time.Second, cancel).Stop() rd := bufio.NewReader(resp.Body) line := func() string { s, err := rd.ReadString('\n') if err != nil { return "EOF" } return strings.TrimRight(s, "\n") } // The stream is up; a first event goes through while the session lives. require.Equal(t, ": connected", line()) line() f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} var seen []string for l := line(); l != "data: gap"; l = line() { seen = append(seen, l) require.NotEqual(t, "EOF", l) } assert.Contains(t, seen, "id: b-1") // Signed out everywhere: the next event is not sent, the stream ends. sess.ended.Store(true) select { case f.push <- &pb.Event{Id: "b-2", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}: case <-time.After(5 * time.Second): t.Fatal("the event was not delivered to the web") } rest, _ := io.ReadAll(rd) assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended") require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed") } // A visitor's stream does not depend on sessions: events keep flowing. func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}} sess.ended.Store(true) ts := eventsServerWith(t, f, time.Hour, 0, sess) resp, _ := open(t, ts, "topic=home", nil) require.Equal(t, http.StatusOK, resp.StatusCode) f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} sc := bufio.NewScanner(resp.Body) found := false for sc.Scan() { if sc.Text() == "id: b-1" { found = true break } } assert.True(t, found) } // GO-006: a stream opened with an invalid cookie is anonymous, and its headers // say the answer must not be stored and varies on the cookie. func TestEventsInvalidCookieStream(t *testing.T) { f := &fakeEvents{release: make(chan struct{})} ts := eventsServerWith(t, f, time.Hour, 0, &fakeSessions{valid: "tok"}) resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"}) require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "no-store, no-transform", resp.Header.Get("Cache-Control")) assert.Contains(t, resp.Header.Values("Vary"), "Cookie") assert.Contains(t, resp.Header.Get("Set-Cookie"), "Max-Age=0") }