v0.10.0 ce48ee3

errors_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"fmt"
10	"net/http"
11	"net/http/httptest"
12	"regexp"
13	"testing"
14
15	"github.com/bnema/zerowrap"
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22	"bnema.dev/ermite/internal/ports/mocks"
23)
24
25// serve asks the web for path while every read of a repository fails with err,
26// and returns the answer and what was logged.
27func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) {
28	t.Helper()
29	srv := newTestServer(t, Config{Repos: reposFailing(t, err), Browse: mocks.NewBrowseService(t)})
30	var logs bytes.Buffer
31	ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
32	rec := httptest.NewRecorder()
33	srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil))
34	return rec, logs.String()
35}
36
37// Errors of the use cases: not found (and an invalid name) are the 404 page, a
38// visitor that left gets nothing, any other is the 500 page with an id that
39// the log carries.
40func TestErrorPages(t *testing.T) {
41	t.Parallel()
42	tests := []struct {
43		name string
44		err  error
45		path string
46		want int
47	}{
48		{"not found", fmt.Errorf("repository: %w", domain.ErrNotFound), "/gone", http.StatusNotFound},
49		{"invalid repository name", domain.ErrInvalidName, "/Bad", http.StatusNotFound},
50		{"invalid argument", domain.ErrInvalid, "/demo", http.StatusNotFound},
51		{"invalid page token", domain.ErrInvalidPageToken, "/demo", http.StatusNotFound},
52		{"invalid revision", domain.ErrInvalidRevision, "/demo", http.StatusNotFound},
53		{"invalid path", domain.ErrInvalidPath, "/demo", http.StatusNotFound},
54		{"internal", errors.New("dial tcp 10.0.0.5: kv exploded"), "/demo", http.StatusInternalServerError},
55		{"internal on the home page", errors.New("kv exploded on the list"), "/", http.StatusInternalServerError},
56		{"forbidden is not a page of its own", domain.ErrForbidden, "/demo", http.StatusInternalServerError},
57		{"resource exhausted", domain.ErrResourceExhausted, "/demo", http.StatusInternalServerError},
58		{"deadline", context.DeadlineExceeded, "/demo", http.StatusInternalServerError},
59	}
60	for _, tt := range tests {
61		t.Run(tt.name, func(t *testing.T) {
62			t.Parallel()
63			rec, logs := serve(t, tt.err, tt.path)
64			assert.Equal(t, tt.want, rec.Code)
65			if tt.want == http.StatusNotFound {
66				assert.Contains(t, rec.Body.String(), "not found")
67				assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays out of the page")
68				assert.Empty(t, logs, "a 404 is not an error")
69				return
70			}
71			assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
72			assert.Contains(t, rec.Body.String(), "internal error")
73			assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays in the log")
74			m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String())
75			require.Len(t, m, 2, "the page shows a request id")
76			assert.Contains(t, logs, m[1], "the log carries the same id")
77			assert.Contains(t, logs, tt.err.Error())
78		})
79	}
80}
81
82// A visitor that left gets no page at all.
83func TestCanceledRequestGetsNoPage(t *testing.T) {
84	t.Parallel()
85	srv := newTestServer(t, Config{Repos: reposFailing(t, context.Canceled), Browse: mocks.NewBrowseService(t)})
86	rec := httptest.NewRecorder()
87	srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/demo", nil))
88	assert.Equal(t, http.StatusOK, rec.Code, "nothing was written")
89	assert.Empty(t, rec.Body.String())
90}
91
92// The caller of every use case call is the principal of the request: anonymous
93// without a valid session, the owner with one. A private repository is as
94// missing for the first as one that does not exist (the 404 page, never a 403).
95func TestUseCasesGetThePrincipalOfTheRequest(t *testing.T) {
96	t.Parallel()
97	const token = "tok"
98	tests := []struct {
99		name   string
100		cookie string
101		want   domain.Principal
102	}{
103		{"no cookie is anonymous", "", domain.Anonymous()},
104		{"an invalid session is anonymous", "bad", domain.Anonymous()},
105		{"a valid session is the owner", token, domain.Owner()},
106	}
107	for _, tt := range tests {
108		t.Run(tt.name, func(t *testing.T) {
109			t.Parallel()
110			repos := mocks.NewRepoService(t)
111			// The use case answers with the principal it was given: only the
112			// owner sees the private repository.
113			repos.EXPECT().Get(mock.Anything, tt.want, repoName(t, "secret")).RunAndReturn(
114				func(_ context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
115					if !p.IsRoot() {
116						return domain.Repo{}, domain.ErrNotFound
117					}
118					return domain.Repo{Name: name, Visibility: domain.VisibilityPrivate}, nil
119				}).Once()
120			if tt.want.IsOwner() {
121				repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "secret"), nil).Once()
122				repos.EXPECT().ListPage(mock.Anything, tt.want, mock.Anything, mock.Anything).Return(ports.RepoPage{}, nil).Once()
123			}
124			browse := mocks.NewBrowseService(t)
125			if tt.want.IsOwner() {
126				browse.EXPECT().ListRefs(mock.Anything, tt.want, repoName(t, "secret"), mock.Anything).Return(ports.RefPage{}, nil).Once()
127			}
128			srv := newTestServer(t, Config{Repos: repos, Browse: browse, Sessions: newSessions(t, token), Limiter: &countingLimiter{}})
129
130			req := httptest.NewRequest(http.MethodGet, "/secret/refs", nil)
131			if tt.cookie != "" {
132				req.AddCookie(&http.Cookie{Name: CookieName, Value: tt.cookie})
133			}
134			rec := httptest.NewRecorder()
135			srv.ServeHTTP(rec, req)
136
137			if tt.want.IsOwner() {
138				assert.Equal(t, http.StatusOK, rec.Code)
139				return
140			}
141			assert.Equal(t, http.StatusNotFound, rec.Code, "a private repository is a missing one")
142			assert.Contains(t, rec.Body.String(), "not found")
143		})
144	}
145}
146
147// A request that arrives with a principal in its context is anonymous anyway:
148// only a valid session makes the owner.
149func TestRequestContextCannotSetThePrincipal(t *testing.T) {
150	t.Parallel()
151	repos := mocks.NewRepoService(t)
152	repos.EXPECT().Get(mock.Anything, domain.Anonymous(), repoName(t, "secret")).Return(domain.Repo{}, domain.ErrNotFound).Once()
153	srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t)})
154
155	ctx := domain.ContextWithPrincipal(context.Background(), domain.Root())
156	rec := httptest.NewRecorder()
157	srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/secret", nil))
158	assert.Equal(t, http.StatusNotFound, rec.Code)
159}
160
161func TestNewValidates(t *testing.T) {
162	t.Parallel()
163	tests := []struct {
164		name string
165		cfg  Config
166	}{
167		{"use cases are required", Config{PublicURL: testPublicURL}},
168		{"browse is required", Config{Repos: mocks.NewRepoService(t), PublicURL: testPublicURL}},
169		{"repos are required", Config{Browse: mocks.NewBrowseService(t), PublicURL: testPublicURL}},
170		{"the public URL must be one", Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t), PublicURL: "not a url"}},
171	}
172	for _, tt := range tests {
173		t.Run(tt.name, func(t *testing.T) {
174			t.Parallel()
175			_, err := New(tt.cfg)
176			assert.Error(t, err)
177		})
178	}
179}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close