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}