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}