v0.36.0 1fb2421

raw_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"io"
8	"net/http"
9	"net/http/httptest"
10	"strings"
11	"testing"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18	"bnema.dev/ermite/internal/ports/mocks"
19)
20
21const (
22	blobID   = "0123456789abcdef0123456789abcdef01234567"
23	mainTip  = "c0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe"
24	blobBody = "hello"
25)
26
27// rawBrowse serves one text file, the only call raw makes besides the refs.
28func rawBrowse(t *testing.T) *mocks.BrowseService {
29	t.Helper()
30	browse := mocks.NewBrowseService(t)
31	browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{
32		Refs: []domain.Ref{{
33			Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch,
34			ID: objectID(t, mainTip), CommitID: objectID(t, mainTip),
35		}},
36	}, nil).Maybe()
37	browse.EXPECT().OpenBlob(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything, mock.Anything).RunAndReturn(
38		func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
39			if path.String() != "docs/a.txt" || rev.String() != mainTip {
40				return domain.BlobInfo{}, nil, domain.ErrNotFound
41			}
42			return domain.BlobInfo{ID: objectID(t, blobID), Path: "docs/a.txt", Size: int64(len(blobBody)), MediaType: "text/plain"},
43				io.NopCloser(strings.NewReader(blobBody)), nil
44		}).Maybe()
45	return browse
46}
47
48// A revalidation of an unchanged file ends with a 304 and no body. The blob is
49// opened in every case: its id is only known from the metadata the use case
50// returns with the content.
51func TestRawRevalidation(t *testing.T) {
52	t.Parallel()
53	tests := []struct {
54		name   string
55		url    string
56		method string
57		ifNone string
58		want   int
59		body   string
60	}{
61		{"no validator", "/r/raw/main/docs/a.txt", http.MethodGet, "", http.StatusOK, "hello"},
62		{"matching validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotModified, ""},
63		{"other validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"ffffffffffffffffffffffffffffffffffffffff"`, http.StatusOK, "hello"},
64		{"weak validator matches (weak comparison)", "/r/raw/main/docs/a.txt", http.MethodGet, `W/"` + blobID + `"`, http.StatusNotModified, ""},
65		{"list of validators", "/r/raw/main/docs/a.txt", http.MethodGet, `"other", "` + blobID + `"`, http.StatusNotModified, ""},
66		{"any validator", "/r/raw/main/docs/a.txt", http.MethodGet, "*", http.StatusNotModified, ""},
67		{"list without a match", "/r/raw/main/docs/a.txt", http.MethodGet, `"other", "another"`, http.StatusOK, "hello"},
68		{"head", "/r/raw/main/docs/a.txt", http.MethodHead, "", http.StatusOK, ""},
69		{"missing file", "/r/raw/main/docs/none.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},
70		{"directory", "/r/raw/main", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""},
71	}
72	for _, tt := range tests {
73		t.Run(tt.name, func(t *testing.T) {
74			t.Parallel()
75			repos := reposServing(t, publicRepo(t, "r"))
76			srv := newTestServer(t, Config{Repos: repos, Browse: rawBrowse(t)})
77			req := httptest.NewRequest(tt.method, tt.url, nil)
78			if tt.ifNone != "" {
79				req.Header.Set("If-None-Match", tt.ifNone)
80			}
81			rec := httptest.NewRecorder()
82			srv.ServeHTTP(rec, req)
83
84			assert.Equal(t, tt.want, rec.Code)
85			if tt.want != http.StatusNotFound {
86				assert.Equal(t, tt.body, rec.Body.String())
87			}
88			if tt.want == http.StatusNotModified {
89				assert.Equal(t, `"`+blobID+`"`, rec.Header().Get("ETag"))
90				assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
91			}
92			if tt.want == http.StatusOK {
93				assert.Equal(t, `"`+blobID+`"`, rec.Header().Get("ETag"))
94				assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy"))
95			}
96		})
97	}
98}
99
100func TestRawVideoTypes(t *testing.T) {
101	for _, tc := range []struct {
102		name, head, typ string
103		attachment      bool
104	}{
105		{"mp4", "\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom", "video/mp4", false},
106		{"mov", "\x00\x00\x00\x14ftypqt  \x00\x00\x00\x00qt  ", "video/quicktime", false},
107		{"m4v", "\x00\x00\x00\x14ftypM4V \x00\x00\x00\x00M4V ", "video/mp4", false},
108		{"webm", "\x1a\x45\xdf\xa3\x87\x42\x82\x84webm", "video/webm", false},
109		{"ogg", "OggS\x00header\x80theora", "video/ogg", false},
110		{"ogg audio", "OggS\x00headerOpusHead", "application/octet-stream", true},
111		{"html", "<html><script>alert(1)</script>", "application/octet-stream", true},
112		{"svg", `<svg onload="alert(1)">`, "application/octet-stream", true},
113		{"unknown", "\x00unknown", "application/octet-stream", true},
114	} {
115		t.Run(tc.name, func(t *testing.T) {
116			typ, attachment := rawType(&blobStream{buf: []byte(tc.head), info: domain.BlobInfo{Binary: true}})
117			assert.Equal(t, tc.typ, typ)
118			assert.Equal(t, tc.attachment, attachment)
119		})
120	}
121}
122
123func TestRawVideoRanges(t *testing.T) {
124	body := "\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom" + strings.Repeat("0123456789", 100)
125	for _, tc := range []struct {
126		name, method, rng, ifRange string
127		status                     int
128		start, end                 int
129		cr                         string
130	}{
131		{"whole", "GET", "", "", 200, 0, len(body), ""},
132		{"prefix", "GET", "bytes=2-4", "", 206, 2, 5, "bytes 2-4/1024"},
133		{"after sniff buffer", "GET", "bytes=700-709", "", 206, 700, 710, "bytes 700-709/1024"},
134		{"suffix", "GET", "bytes=-4", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
135		{"open", "GET", "bytes=1020-", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
136		{"clipped end", "GET", "bytes=1020-9999", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
137		{"past size", "GET", "bytes=1024-", "", 416, 0, 0, "bytes */1024"},
138		{"invalid", "GET", "bytes=a-b", "", 416, 0, 0, "bytes */1024"},
139		{"multiple ignored", "GET", "bytes=0-1,4-5", "", 200, 0, len(body), ""},
140		{"stale if-range", "GET", "bytes=2-4", `"old"`, 200, 0, len(body), ""},
141		{"matched if-range", "GET", "bytes=2-4", `"` + blobID + `"`, 206, 2, 5, "bytes 2-4/1024"},
142		{"head", "HEAD", "bytes=2-4", "", 200, 0, 0, ""},
143	} {
144		t.Run(tc.name, func(t *testing.T) {
145			browse := mocks.NewBrowseService(t)
146			browse.EXPECT().OpenBlob(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything, mock.Anything).Return(
147				domain.BlobInfo{ID: objectID(t, blobID), Path: "clip.mp4", Size: int64(len(body)), Binary: true}, io.NopCloser(strings.NewReader(body)), nil).Once()
148			browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, ID: objectID(t, mainTip), CommitID: objectID(t, mainTip)}}}, nil).Maybe()
149			srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
150			req := httptest.NewRequest(tc.method, "/r/raw/main/clip.mp4", nil)
151			req.Header.Set("Range", tc.rng)
152			req.Header.Set("If-Range", tc.ifRange)
153			rec := httptest.NewRecorder()
154			srv.ServeHTTP(rec, req)
155			assert.Equal(t, tc.status, rec.Code)
156			assert.Equal(t, body[tc.start:tc.end], rec.Body.String())
157			assert.Equal(t, tc.cr, rec.Header().Get("Content-Range"))
158			assert.Equal(t, "bytes", rec.Header().Get("Accept-Ranges"))
159			assert.Equal(t, "video/mp4", rec.Header().Get("Content-Type"))
160			assert.Empty(t, rec.Header().Get("Content-Disposition"))
161			assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
162			assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy"))
163		})
164	}
165}

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