1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports/mocks"
17)
18
19const testOwner = "owner"
20
21// ownerRedirect sends /<owner>/<rest> to /<rest> whenever no repository named
22// like the owner is visible, and only for a clean repository segment.
23func TestOwnerRedirect(t *testing.T) {
24 t.Parallel()
25 none := reposServing(t)
26 tests := []struct {
27 name string
28 method string
29 target string
30 repos func(t *testing.T) *mocks.RepoService
31 want string // "" : not redirected
32 }{
33 {"a repository page", http.MethodGet, "/owner/demo", nil, "/demo"},
34 {"a repository page with a slash", http.MethodGet, "/owner/demo/", nil, "/demo/"},
35 {"a sub-route", http.MethodGet, "/owner/demo/commit/abc123", nil, "/demo/commit/abc123"},
36 {"head", http.MethodHead, "/owner/demo", nil, "/demo"},
37 {"post is not redirected", http.MethodPost, "/owner/demo", nil, ""},
38 {"query is kept", http.MethodGet, "/owner/demo/commits/main?a=1&b=x%20y", nil, "/demo/commits/main?a=1&b=x%20y"},
39 {"a repository named like a route", http.MethodGet, "/owner/actions", nil, "/actions"},
40 {"a repository named like a route, with a path", http.MethodGet, "/owner/actions/actions/runs/1", nil, "/actions/actions/runs/1"},
41 {"git smart HTTP", http.MethodGet, "/owner/repo.git/info/refs?service=git-upload-pack", nil, "/repo.git/info/refs?service=git-upload-pack"},
42 {"the owner alone", http.MethodGet, "/owner", nil, ""},
43 {"the owner with a slash", http.MethodGet, "/owner/", nil, ""},
44 {"another first segment", http.MethodGet, "/other/demo", nil, ""},
45 {"a prefix of the owner", http.MethodGet, "/owners/demo", nil, ""},
46 {"a double slash host", http.MethodGet, "//evil.com", nil, ""},
47 {"a double slash host after the owner", http.MethodGet, "/owner//evil.com", nil, ""},
48 {"an encoded slash", http.MethodGet, "/owner/%2Fevil.com", nil, ""},
49 {"an encoded slash after the repository", http.MethodGet, "/owner/demo%2F/x", nil, ""},
50 {"an encoded backslash", http.MethodGet, "/owner/%5Cevil.com", nil, ""},
51 {"a backslash", http.MethodGet, `/owner/\evil.com`, nil, ""},
52 {"dot dot", http.MethodGet, "/owner/..", nil, ""},
53 {"dot dot after the repository", http.MethodGet, "/owner/../etc", nil, ""},
54 {"encoded dot dot", http.MethodGet, "/owner/%2e%2e/etc", nil, ""},
55 {"a dot", http.MethodGet, "/owner/.", nil, ""},
56 {"an uppercase name", http.MethodGet, "/owner/Demo", nil, ""},
57 {"the /-/ routes", http.MethodGet, "/owner/-/events", nil, ""},
58 {"a bare .git", http.MethodGet, "/owner/.git/info/refs", nil, ""},
59 {"a repository really named like the owner", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
60 return reposServing(t, publicRepo(t, testOwner))
61 }, ""},
62 {"a route of the repository named like the owner", http.MethodGet, "/owner/actions", func(t *testing.T) *mocks.RepoService {
63 return reposServing(t, publicRepo(t, testOwner), publicRepo(t, "actions"))
64 }, ""},
65 {"a private repository named like the owner is a missing one", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
66 m := mocks.NewRepoService(t)
67 m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
68 return m
69 }, "/demo"},
70 {"a failing lookup serves the request as it is", http.MethodGet, "/owner/demo", func(t *testing.T) *mocks.RepoService {
71 return reposFailing(t, errors.New("store down"))
72 }, ""},
73 }
74 for _, tt := range tests {
75 t.Run(tt.name, func(t *testing.T) {
76 t.Parallel()
77 repos := none
78 if tt.repos != nil {
79 repos = tt.repos(t)
80 }
81 srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t), Owner: testOwner})
82 req := httptest.NewRequest(tt.method, tt.target, nil)
83 to, ok := srv.ownerRedirect(req)
84 assert.Equal(t, tt.want != "", ok)
85 assert.Equal(t, tt.want, to)
86 if !ok {
87 return
88 }
89 // Whatever the target is, it stays on this origin.
90 require.True(t, to[0] == '/' && (len(to) == 1 || (to[1] != '/' && to[1] != '\\')), "target %q", to)
91 })
92 }
93}
94
95// Through ServeHTTP the redirect is a 308 with the Location, and the paths
96// that are not redirected reach the router.
97func TestOwnerRedirectServed(t *testing.T) {
98 t.Parallel()
99 srv := newTestServer(t, Config{Repos: reposServing(t), Browse: mocks.NewBrowseService(t), Owner: testOwner})
100 rec := httptest.NewRecorder()
101 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/owner/actions?x=1", nil))
102 assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
103 assert.Equal(t, "/actions?x=1", rec.Header().Get("Location"))
104
105 rec = httptest.NewRecorder()
106 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "//evil.com", nil))
107 // The mux cleans the path itself and answers with a local redirect: the
108 // owner redirect adds none, and nothing leads off this origin.
109 assert.NotEqual(t, http.StatusPermanentRedirect, rec.Code)
110 assert.Equal(t, "/evil.com", rec.Header().Get("Location"))
111}
112
113// Without an owner there is no redirect.
114func TestOwnerRedirectOff(t *testing.T) {
115 t.Parallel()
116 srv := newTestServer(t, Config{Repos: reposServing(t), Browse: mocks.NewBrowseService(t)})
117 _, ok := srv.ownerRedirect(httptest.NewRequest(http.MethodGet, "/owner/demo", nil))
118 assert.False(t, ok)
119}