// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import (
"bufio"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"bnema.dev/ermite/internal/domain"
"bnema.dev/ermite/internal/ports"
"bnema.dev/ermite/internal/ports/mocks"
)
func inboxOverview(t testing.TB) ports.InboxOverview {
t.Helper()
mrTitle, err := domain.ParseMRTitle("fix search")
require.NoError(t, err)
isTitle, err := domain.ParseIssueTitle("crash on start")
require.NoError(t, err)
at := time.Now().Add(-2 * time.Hour)
ov := ports.InboxOverview{
MergeRequests: []domain.MergeRequest{{
Repo: repoName(t, "alpha"), Number: 4, Title: mrTitle, State: domain.MRStateOpen, UpdatedAt: at,
}},
Issues: []domain.Issue{{Repo: repoName(t, "beta"), Number: 7, Title: isTitle, State: domain.IssueStateOpen, UpdatedAt: at}},
Repos: []domain.RepoName{repoName(t, "alpha"), repoName(t, "beta")},
}
ov.Counts = ports.InboxCounts{MergeRequests: 1, Issues: 1}
return ov
}
func inboxServer(t *testing.T, inbox ports.InboxService) *Server {
t.Helper()
return newTestServer(t, Config{
Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox,
Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
})
}
func inboxGet(srv *Server, path string, owner bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
if owner {
req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
func TestInboxPageForTheOwner(t *testing.T) {
t.Parallel()
svc := mocks.NewInboxService(t)
svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
srv := inboxServer(t, svc)
rec := inboxGet(srv, "/-/inbox", true)
require.Equal(t, 200, rec.Code, rec.Body.String())
body := rec.Body.String()
assert.Contains(t, body, "1 merge requests ยท 1 issues")
assert.Contains(t, body, "Open merge requests")
assert.Contains(t, body, "Open issues")
assert.Contains(t, body, `href="/alpha/merge-requests/4"`)
assert.Contains(t, body, "fix <b>search</b>")
assert.Contains(t, body, `href="/beta/issues/7"`)
assert.Contains(t, body, "#7")
assert.Contains(t, body, "2h ago")
assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter")
assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
}
func TestInboxPageIsMissingForEveryoneElse(t *testing.T) {
t.Parallel()
svc := mocks.NewInboxService(t) // never asked
srv := inboxServer(t, svc)
missing := inboxGet(srv, "/-/nothing-here", false)
require.Equal(t, 404, missing.Code)
anon := inboxGet(srv, "/-/inbox", false)
assert.Equal(t, 404, anon.Code)
assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one")
assert.NotContains(t, anon.Body.String(), "Open merge requests")
// A bad cookie is anonymous as well.
req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"})
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
assert.Equal(t, 404, rec.Code)
}
func TestInboxNotMountedWithoutTheService(t *testing.T) {
t.Parallel()
srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}})
assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code)
}
func TestInboxFilterAndEmptyStates(t *testing.T) {
t.Parallel()
svc := mocks.NewInboxService(t)
ov := inboxOverview(t)
filtered := ov
filtered.MergeRequests, filtered.Issues = nil, nil
svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil)
srv := inboxServer(t, svc)
rec := inboxGet(srv, "/-/inbox?repo=gamma", true)
require.Equal(t, 200, rec.Code)
body := rec.Body.String()
assert.Contains(t, body, "no open merge requests.")
assert.Contains(t, body, "no open issues.")
assert.Contains(t, body, `href="/-/inbox"`, "the link back to all")
assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page")
}
func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) {
t.Parallel()
svc := mocks.NewInboxService(t)
ov := inboxOverview(t)
ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2}
svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only
srv := inboxServer(t, svc)
anon := inboxGet(srv, "/", false)
require.Equal(t, 200, anon.Code)
assert.NotContains(t, anon.Body.String(), "/-/inbox")
assert.NotContains(t, anon.Body.String(), "Inbox")
owner := inboxGet(srv, "/", true)
require.Equal(t, 200, owner.Code)
assert.Contains(t, owner.Body.String(), `href="/-/inbox"`)
assert.Contains(t, owner.Body.String(), `5`)
}
func TestInboxStreamIsTheOwnersAlone(t *testing.T) {
t.Parallel()
f := &watchProbe{release: make(chan struct{})}
svc := mocks.NewInboxService(t)
srv := newTestServer(t, Config{
Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
})
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
want, _ := io.ReadAll(unknown.Body)
for name, hdr := range map[string]map[string]string{
"anonymous": nil,
"another token": {"Cookie": CookieName + "=other"},
} {
resp, _ := open(t, ts, "topic=owner&view=inbox", hdr)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, unknown.StatusCode, resp.StatusCode, name)
assert.Equal(t, string(want), string(body), name)
assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name)
}
assert.Zero(t, f.opened.Load(), "no watch is opened for them")
resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
}
func TestInboxLiveFragments(t *testing.T) {
t.Parallel()
svc := mocks.NewInboxService(t)
svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
srv := inboxServer(t, svc)
ls := &liveStream{s: srv, view: "inbox"}
frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
require.NoError(t, err)
require.Len(t, frags, 5)
assert.Contains(t, frags[0].html, `id="inbox-counts"`)
assert.Contains(t, frags[1].html, `id="inbox-repos"`)
assert.Contains(t, frags[2].html, `id="inbox-pending"`)
assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
assert.Contains(t, frags[4].html, `id="inbox-issues"`)
none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
require.NoError(t, err)
assert.Empty(t, none, "a push is not inbox news")
}
// withOwner is a context as the session middleware leaves it for the owner.
func withOwner(ctx context.Context) context.Context {
return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner())
}
// A bearer token is not a web session: the inbox answers it as it answers
// anyone else, with the unknown-page and unknown-topic responses.
func TestInboxRefusesABearerToken(t *testing.T) {
t.Parallel()
f := &watchProbe{release: make(chan struct{})}
svc := mocks.NewInboxService(t) // never asked
srv := newTestServer(t, Config{
Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
})
bearer := map[string]string{"Authorization": "Bearer tok"}
// The page.
missing := inboxGet(srv, "/-/nothing-here", false)
req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
assert.Equal(t, missing.Code, rec.Code)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response")
// The stream.
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
want, _ := io.ReadAll(unknown.Body)
resp, _ := open(t, ts, "topic=owner&view=inbox", bearer)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, unknown.StatusCode, resp.StatusCode)
assert.Equal(t, string(want), string(body), "the unknown-topic response")
assert.Zero(t, f.opened.Load(), "no watch is opened")
}
// The owner's inbox stream ends once the session is invalid: the next event is
// not sent.
func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) {
t.Parallel()
f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
sess, ended := newRevocable(t, "tok")
svc := mocks.NewInboxService(t)
svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe()
srv := newTestServer(t, Config{
Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{},
})
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
require.Equal(t, http.StatusOK, resp.StatusCode)
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")
}
require.Equal(t, ": connected", line())
line()
ev := func(id string) ports.WatchEvent {
return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}
}
f.push <- ev("b-1")
var seen []string
for l := line(); !strings.HasPrefix(l, "data:"); l = line() {
seen = append(seen, l)
require.NotEqual(t, "EOF", l)
}
assert.Contains(t, seen, "id: b-1")
ended.Store(true)
select {
case f.push <- ev("b-2"):
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 repository event refreshes the inbox: issues turned off or a deletion
// remove its items from the fragments.
func TestInboxRefreshesOnRepoEvents(t *testing.T) {
t.Parallel()
for name, ev := range map[string]domain.Event{
"issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
"repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}}
svc := mocks.NewInboxService(t)
svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil)
srv := inboxServer(t, svc)
ls := &liveStream{s: srv, view: "inbox"}
frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
require.NoError(t, err)
require.Len(t, frags, 5)
all := ""
for _, f := range frags {
all += f.html
}
assert.Contains(t, all, `id="inbox-issues"`)
assert.NotContains(t, all, "crash on start", "the issue is gone")
assert.NotContains(t, all, "/beta", "the repository is gone from the filter")
})
}
}