detached 0e6522c

ownerredirect_test.go RawHistoryFolder

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}

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