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}