diff options
author | feistel <6742251-feistel@users.noreply.gitlab.com> | 2021-11-16 04:16:51 +0300 |
---|---|---|
committer | Jaime Martinez <jmartinez@gitlab.com> | 2021-11-16 04:16:51 +0300 |
commit | 85621f69f855e43afe983e2ca107e921aa14c8c8 (patch) | |
tree | 670f67b327fb18afd399ed9f1231fdf6bc1f114b | |
parent | d7562ebf963c4c5f2069899776231ed882e5ed75 (diff) |
feat: handle extra headers when serving from compressed zip archive
Related to https://gitlab.com/gitlab-org/gitlab-pages/-/issues/466
Changelog: added
-rw-r--r-- | go.mod | 4 | ||||
-rw-r--r-- | internal/serving/disk/reader.go | 5 | ||||
-rw-r--r-- | internal/serving/disk/zip/serving_test.go | 23 | ||||
-rw-r--r-- | internal/vfs/serving/LICENSE | 27 | ||||
-rw-r--r-- | internal/vfs/serving/export_test.go | 12 | ||||
-rw-r--r-- | internal/vfs/serving/main_test.go | 133 | ||||
-rw-r--r-- | internal/vfs/serving/serving.go | 264 | ||||
-rw-r--r-- | internal/vfs/serving/serving_test.go | 290 | ||||
-rw-r--r-- | test/acceptance/zip_test.go | 54 |
9 files changed, 809 insertions, 3 deletions
@@ -1,5 +1,9 @@ module gitlab.com/gitlab-org/gitlab-pages +// before bumping this: +// - update the minimum version used in ci +// - make sure the internal/vfs/serving package is synced +// with upstream go 1.16 require ( diff --git a/internal/serving/disk/reader.go b/internal/serving/disk/reader.go index 26425063..06ff67fb 100644 --- a/internal/serving/disk/reader.go +++ b/internal/serving/disk/reader.go @@ -18,6 +18,7 @@ import ( "gitlab.com/gitlab-org/gitlab-pages/internal/serving" "gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk/symlink" "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" + vfsServing "gitlab.com/gitlab-org/gitlab-pages/internal/vfs/serving" ) // Reader is a disk access driver @@ -235,10 +236,8 @@ func (reader *Reader) serveFile(ctx context.Context, w http.ResponseWriter, r *h if rs, ok := file.(vfs.SeekableFile); ok { http.ServeContent(w, r, origPath, fi.ModTime(), rs) } else { - // compressed files will be served by io.Copy - // TODO: Add extra headers https://gitlab.com/gitlab-org/gitlab-pages/-/issues/466 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) - io.Copy(w, file) + vfsServing.ServeCompressedFile(w, r, fi.ModTime(), file) } return true diff --git a/internal/serving/disk/zip/serving_test.go b/internal/serving/disk/zip/serving_test.go index ed18e6c3..f16bbada 100644 --- a/internal/serving/disk/zip/serving_test.go +++ b/internal/serving/disk/zip/serving_test.go @@ -31,6 +31,7 @@ func TestZip_ServeFileHTTP(t *testing.T) { path string expectedStatus int expectedBody string + extraHeaders http.Header }{ "accessing /index.html": { vfsPath: httpURL, @@ -53,6 +54,22 @@ func TestZip_ServeFileHTTP(t *testing.T) { expectedStatus: http.StatusOK, expectedBody: "zip.gitlab.io/project/index.html\n", }, + "accessing / If-Modified-Since": { + vfsPath: httpURL, + path: "/", + expectedStatus: http.StatusNotModified, + extraHeaders: http.Header{ + "If-Modified-Since": {time.Now().Format(http.TimeFormat)}, + }, + }, + "accessing / If-Unmodified-Since": { + vfsPath: httpURL, + path: "/", + expectedStatus: http.StatusPreconditionFailed, + extraHeaders: http.Header{ + "If-Unmodified-Since": {time.Now().AddDate(-10, 0, 0).Format(http.TimeFormat)}, + }, + }, "accessing / from disk": { vfsPath: fileURL, sha256: "15c5438164ec67bb2225f68d7d7a2e0b608035264e5275b7e3302641aa25a528", @@ -114,6 +131,8 @@ func TestZip_ServeFileHTTP(t *testing.T) { w.Code = 0 // ensure that code is not set, and it is being set by handler r := httptest.NewRequest("GET", "http://zip.gitlab.io/zip"+test.path, nil) + r.Header = test.extraHeaders + handler := serving.Handler{ Writer: w, Request: r, @@ -140,6 +159,10 @@ func TestZip_ServeFileHTTP(t *testing.T) { body, err := io.ReadAll(resp.Body) require.NoError(t, err) + if test.expectedStatus == http.StatusOK { + require.NotEmpty(t, resp.Header.Get("Last-Modified")) + } + require.Contains(t, string(body), test.expectedBody) }) } diff --git a/internal/vfs/serving/LICENSE b/internal/vfs/serving/LICENSE new file mode 100644 index 00000000..6a66aea5 --- /dev/null +++ b/internal/vfs/serving/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/internal/vfs/serving/export_test.go b/internal/vfs/serving/export_test.go new file mode 100644 index 00000000..2742825a --- /dev/null +++ b/internal/vfs/serving/export_test.go @@ -0,0 +1,12 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Bridge package to expose http internals to tests in the http_test +// package. + +package serving + +var ( + ExportScanETag = scanETag +) diff --git a/internal/vfs/serving/main_test.go b/internal/vfs/serving/main_test.go new file mode 100644 index 00000000..56babdc8 --- /dev/null +++ b/internal/vfs/serving/main_test.go @@ -0,0 +1,133 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//nolint +package serving_test + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "runtime" + "sort" + "strings" + "testing" + "time" +) + +var quietLog = log.New(io.Discard, "", 0) + +func TestMain(m *testing.M) { + v := m.Run() + if v == 0 && goroutineLeaked() { + os.Exit(1) + } + os.Exit(v) +} + +func interestingGoroutines() (gs []string) { + buf := make([]byte, 2<<20) + buf = buf[:runtime.Stack(buf, true)] + for _, g := range strings.Split(string(buf), "\n\n") { + sl := strings.SplitN(g, "\n", 2) + if len(sl) != 2 { + continue + } + stack := strings.TrimSpace(sl[1]) + if stack == "" || + strings.Contains(stack, "testing.(*M).before.func1") || + strings.Contains(stack, "os/signal.signal_recv") || + strings.Contains(stack, "created by net.startServer") || + strings.Contains(stack, "created by testing.RunTests") || + strings.Contains(stack, "closeWriteAndWait") || + strings.Contains(stack, "testing.Main(") || + // These only show up with GOTRACEBACK=2; Issue 5005 (comment 28) + strings.Contains(stack, "runtime.goexit") || + strings.Contains(stack, "created by runtime.gc") || + strings.Contains(stack, "vfs/serving_test.interestingGoroutines") || + strings.Contains(stack, "runtime.MHeap_Scavenger") { + continue + } + gs = append(gs, stack) + } + sort.Strings(gs) + return +} + +// Verify the other tests didn't leave any goroutines running. +func goroutineLeaked() bool { + if testing.Short() || runningBenchmarks() { + // Don't worry about goroutine leaks in -short mode or in + // benchmark mode. Too distracting when there are false positives. + return false + } + + var stackCount map[string]int + for i := 0; i < 5; i++ { + n := 0 + stackCount = make(map[string]int) + gs := interestingGoroutines() + for _, g := range gs { + stackCount[g]++ + n++ + } + if n == 0 { + return false + } + // Wait for goroutines to schedule and die off: + time.Sleep(100 * time.Millisecond) + } + fmt.Fprintf(os.Stderr, "Too many goroutines running after net/http test(s).\n") + for stack, count := range stackCount { + fmt.Fprintf(os.Stderr, "%d instances of:\n%s\n", count, stack) + } + return true +} + +func runningBenchmarks() bool { + for i, arg := range os.Args { + if strings.HasPrefix(arg, "-test.bench=") && !strings.HasSuffix(arg, "=") { + return true + } + if arg == "-test.bench" && i < len(os.Args)-1 && os.Args[i+1] != "" { + return true + } + } + return false +} + +func afterTest(t testing.TB) { + http.DefaultTransport.(*http.Transport).CloseIdleConnections() + if testing.Short() { + return + } + var bad string + badSubstring := map[string]string{ + ").readLoop(": "a Transport", + ").writeLoop(": "a Transport", + "created by net/http/httptest.(*Server).Start": "an httptest.Server", + "timeoutHandler": "a TimeoutHandler", + "net.(*netFD).connect(": "a timing out dial", + ").noteClientGone(": "a closenotifier sender", + } + var stacks string + for i := 0; i < 10; i++ { + bad = "" + stacks = strings.Join(interestingGoroutines(), "\n\n") + for substr, what := range badSubstring { + if strings.Contains(stacks, substr) { + bad = what + } + } + if bad == "" { + return + } + // Bad stuff found, but goroutines might just still be + // shutting down, so give it some time. + time.Sleep(250 * time.Millisecond) + } + t.Errorf("Test appears to have leaked %s:\n%s", bad, stacks) +} diff --git a/internal/vfs/serving/serving.go b/internal/vfs/serving/serving.go new file mode 100644 index 00000000..b797fed5 --- /dev/null +++ b/internal/vfs/serving/serving.go @@ -0,0 +1,264 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//nolint +package serving + +import ( + "errors" + "io" + "net/http" + "net/textproto" + "strings" + "time" + + "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" + "gitlab.com/gitlab-org/gitlab-pages/internal/logging" + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" + "gitlab.com/gitlab-org/labkit/errortracking" +) + +var errUnknownContentType = errors.New("serving: unknown content type") + +// ServeCompressedFile is a modified version of https://github.com/golang/go/blob/go1.16.10/src/net/http/fs.go#L192 +func ServeCompressedFile(w http.ResponseWriter, req *http.Request, modtime time.Time, content vfs.File) { + serveContent(w, req, modtime, content) +} + +// serveContent is a modified version of https://github.com/golang/go/blob/go1.16.10/src/net/http/fs.go#L221 +// this function relies on the assumption that a Content-Type header is set +func serveContent(w http.ResponseWriter, r *http.Request, modtime time.Time, content vfs.File) { + setLastModified(w, modtime) + done := checkPreconditions(w, r, modtime) + if done { + return + } + + code := http.StatusOK + + _, haveType := w.Header()["Content-Type"] + if !haveType { + // this shouldn't happen + errortracking.Capture(errUnknownContentType, errortracking.WithRequest(r)) + logging.LogRequest(r).WithError(errUnknownContentType).Error("could not serve content") + httperrors.Serve500(w) + + return + } + + w.WriteHeader(code) + + if r.Method != "HEAD" { + io.Copy(w, content) + } +} + +// scanETag determines if a syntactically valid ETag is present at s. If so, +// the ETag and remaining text after consuming ETag is returned. Otherwise, +// it returns "", "". +func scanETag(s string) (etag string, remain string) { + s = textproto.TrimString(s) + start := 0 + if strings.HasPrefix(s, "W/") { + start = 2 + } + if len(s[start:]) < 2 || s[start] != '"' { + return "", "" + } + // ETag is either W/"text" or "text". + // See RFC 7232 2.3. + for i := start + 1; i < len(s); i++ { + c := s[i] + switch { + // Character values allowed in ETags. + case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80: + case c == '"': + return s[:i+1], s[i+1:] + default: + return "", "" + } + } + return "", "" +} + +// etagStrongMatch reports whether a and b match using strong ETag comparison. +// Assumes a and b are valid ETags. +func etagStrongMatch(a, b string) bool { + return a == b && a != "" && a[0] == '"' +} + +// etagWeakMatch reports whether a and b match using weak ETag comparison. +// Assumes a and b are valid ETags. +func etagWeakMatch(a, b string) bool { + return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/") +} + +// condResult is the result of an HTTP request precondition check. +// See https://tools.ietf.org/html/rfc7232 section 3. +type condResult int + +const ( + condNone condResult = iota + condTrue + condFalse +) + +func checkIfMatch(w http.ResponseWriter, r *http.Request) condResult { + im := r.Header.Get("If-Match") + if im == "" { + return condNone + } + for { + im = textproto.TrimString(im) + if len(im) == 0 { + break + } + if im[0] == ',' { + im = im[1:] + continue + } + if im[0] == '*' { + return condTrue + } + etag, remain := scanETag(im) + if etag == "" { + break + } + if etagStrongMatch(etag, w.Header().Get("Etag")) { + return condTrue + } + im = remain + } + + return condFalse +} + +func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult { + ius := r.Header.Get("If-Unmodified-Since") + if ius == "" || isZeroTime(modtime) { + return condNone + } + t, err := http.ParseTime(ius) + if err != nil { + return condNone + } + + // The Last-Modified header truncates sub-second precision so + // the modtime needs to be truncated too. + modtime = modtime.Truncate(time.Second) + if modtime.Before(t) || modtime.Equal(t) { + return condTrue + } + return condFalse +} + +func checkIfNoneMatch(w http.ResponseWriter, r *http.Request) condResult { + inm := r.Header.Get("If-None-Match") + if inm == "" { + return condNone + } + buf := inm + for { + buf = textproto.TrimString(buf) + if len(buf) == 0 { + break + } + if buf[0] == ',' { + buf = buf[1:] + continue + } + if buf[0] == '*' { + return condFalse + } + etag, remain := scanETag(buf) + if etag == "" { + break + } + if etagWeakMatch(etag, w.Header().Get("Etag")) { + return condFalse + } + buf = remain + } + return condTrue +} + +func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult { + if r.Method != "GET" && r.Method != "HEAD" { + return condNone + } + ims := r.Header.Get("If-Modified-Since") + if ims == "" || isZeroTime(modtime) { + return condNone + } + t, err := http.ParseTime(ims) + if err != nil { + return condNone + } + // The Last-Modified header truncates sub-second precision so + // the modtime needs to be truncated too. + modtime = modtime.Truncate(time.Second) + if modtime.Before(t) || modtime.Equal(t) { + return condFalse + } + return condTrue +} + +var unixEpochTime = time.Unix(0, 0) + +// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0). +func isZeroTime(t time.Time) bool { + return t.IsZero() || t.Equal(unixEpochTime) +} + +func setLastModified(w http.ResponseWriter, modtime time.Time) { + if !isZeroTime(modtime) { + w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) + } +} + +func writeNotModified(w http.ResponseWriter) { + // RFC 7232 section 4.1: + // a sender SHOULD NOT generate representation metadata other than the + // above listed fields unless said metadata exists for the purpose of + // guiding cache updates (e.g., Last-Modified might be useful if the + // response does not have an ETag field). + h := w.Header() + delete(h, "Content-Type") + delete(h, "Content-Length") + if h.Get("Etag") != "" { + delete(h, "Last-Modified") + } + w.WriteHeader(http.StatusNotModified) +} + +// checkPreconditions evaluates request preconditions and reports whether a precondition +// resulted in sending StatusNotModified or StatusPreconditionFailed. +func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) (done bool) { + // This function carefully follows RFC 7232 section 6. + ch := checkIfMatch(w, r) + if ch == condNone { + ch = checkIfUnmodifiedSince(r, modtime) + } + if ch == condFalse { + w.WriteHeader(http.StatusPreconditionFailed) + return true + } + switch checkIfNoneMatch(w, r) { + case condFalse: + if r.Method == "GET" || r.Method == "HEAD" { + writeNotModified(w) + return true + } else { + w.WriteHeader(http.StatusPreconditionFailed) + return true + } + case condNone: + if checkIfModifiedSince(r, modtime) == condFalse { + writeNotModified(w) + return true + } + } + + return false +} diff --git a/internal/vfs/serving/serving_test.go b/internal/vfs/serving/serving_test.go new file mode 100644 index 00000000..01ce60fc --- /dev/null +++ b/internal/vfs/serving/serving_test.go @@ -0,0 +1,290 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//nolint +package serving_test + +import ( + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs/serving" +) + +var ( + style = io.NopCloser(strings.NewReader("p{text-transform: none;}")) + index = io.NopCloser(strings.NewReader("<!doctype html><meta charset=utf-8><title>hello</title>")) + lastMod = time.Now() +) + +func TestServeContent(t *testing.T) { + defer afterTest(t) + type serveParam struct { + file vfs.File + modtime time.Time + contentType string + etag string + } + servec := make(chan serveParam, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := <-servec + if p.etag != "" { + w.Header().Set("ETag", p.etag) + } + if p.contentType != "" { + w.Header().Set("Content-Type", p.contentType) + } + serving.ServeCompressedFile(w, r, p.modtime, p.file) + })) + defer ts.Close() + + type testCase struct { + // One of file or content must be set: + file vfs.File + + modtime time.Time + serveETag string // optional + serveContentType string // optional + reqHeader map[string]string + wantLastMod string + wantContentType string + wantContentRange string + wantStatus int + } + tests := map[string]testCase{ + "no_last_modified": { + file: style, + serveContentType: "text/css; charset=utf-8", + wantContentType: "text/css; charset=utf-8", + wantStatus: 200, + }, + "with_last_modified": { + file: index, + serveContentType: "text/html; charset=utf-8", + wantContentType: "text/html; charset=utf-8", + modtime: lastMod, + wantLastMod: lastMod.UTC().Format(http.TimeFormat), + wantStatus: 200, + }, + "not_modified_modtime": { + file: style, + serveETag: `"foo"`, // Last-Modified sent only when no ETag + modtime: lastMod, + reqHeader: map[string]string{ + "If-Modified-Since": lastMod.UTC().Format(http.TimeFormat), + }, + wantStatus: 304, + }, + "not_modified_modtime_with_contenttype": { + file: style, + serveContentType: "text/css", // explicit content type + serveETag: `"foo"`, // Last-Modified sent only when no ETag + modtime: lastMod, + reqHeader: map[string]string{ + "If-Modified-Since": lastMod.UTC().Format(http.TimeFormat), + }, + wantStatus: 304, + }, + "not_modified_etag": { + file: style, + serveETag: `"foo"`, + reqHeader: map[string]string{ + "If-None-Match": `"foo"`, + }, + wantStatus: 304, + }, + "if_none_match_mismatch": { + file: style, + serveETag: `"foo"`, + reqHeader: map[string]string{ + "If-None-Match": `"Foo"`, + }, + wantStatus: 200, + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + }, + "if_none_match_malformed": { + file: style, + serveETag: `"foo"`, + reqHeader: map[string]string{ + "If-None-Match": `,`, + }, + wantStatus: 200, + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + }, + "ifmatch_matches": { + file: style, + serveETag: `"A"`, + reqHeader: map[string]string{ + "If-Match": `"Z", "A"`, + }, + wantStatus: 200, + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + }, + "ifmatch_star": { + file: style, + serveETag: `"A"`, + reqHeader: map[string]string{ + "If-Match": `*`, + }, + wantStatus: 200, + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + }, + "ifmatch_failed": { + file: style, + serveETag: `"A"`, + reqHeader: map[string]string{ + "If-Match": `"B"`, + }, + wantStatus: 412, + }, + "ifmatch_fails_on_weak_etag": { + file: style, + serveETag: `W/"A"`, + reqHeader: map[string]string{ + "If-Match": `W/"A"`, + }, + wantStatus: 412, + }, + "if_unmodified_since_true": { + file: style, + modtime: lastMod, + reqHeader: map[string]string{ + "If-Unmodified-Since": lastMod.UTC().Format(http.TimeFormat), + }, + wantStatus: 200, + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + wantLastMod: lastMod.UTC().Format(http.TimeFormat), + }, + "if_unmodified_since_false": { + file: style, + modtime: lastMod, + reqHeader: map[string]string{ + "If-Unmodified-Since": lastMod.Add(-2 * time.Second).UTC().Format(http.TimeFormat), + }, + wantStatus: 412, + wantLastMod: lastMod.UTC().Format(http.TimeFormat), + }, + // additional tests + "missin_content_type": { + file: index, + wantContentType: "text/html; charset=utf-8", + wantStatus: http.StatusInternalServerError, + }, + "if_modified_since_malformed": { + file: style, + modtime: lastMod, + wantLastMod: lastMod.UTC().Format(http.TimeFormat), + reqHeader: map[string]string{ + "If-Modified-Since": "foo", + }, + wantStatus: http.StatusOK, + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + }, + "if_unmodified_since_malformed": { + file: style, + modtime: lastMod, + wantLastMod: lastMod.UTC().Format(http.TimeFormat), + reqHeader: map[string]string{ + "If-Unmodified-Since": "foo", + }, + wantStatus: http.StatusOK, + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + }, + "if_modified_since_true": { + file: style, + modtime: lastMod, + reqHeader: map[string]string{ + "If-Modified-Since": lastMod.Add(-2 * time.Second).UTC().Format(http.TimeFormat), + }, + wantStatus: http.StatusOK, + wantLastMod: lastMod.UTC().Format(http.TimeFormat), + wantContentType: "text/css; charset=utf-8", + serveContentType: "text/css; charset=utf-8", + }, + } + for testName, tt := range tests { + for _, method := range []string{"GET", "HEAD"} { + servec <- serveParam{ + file: tt.file, + modtime: tt.modtime, + etag: tt.serveETag, + contentType: tt.serveContentType, + } + req, err := http.NewRequest(method, ts.URL, nil) + if err != nil { + t.Fatal(err) + } + for k, v := range tt.reqHeader { + req.Header.Set(k, v) + } + + c := ts.Client() + res, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, res.Body) + res.Body.Close() + if res.StatusCode != tt.wantStatus { + t.Errorf("test %q using %q: got status = %d; want %d", testName, method, res.StatusCode, tt.wantStatus) + } + if g, e := res.Header.Get("Content-Type"), tt.wantContentType; g != e { + t.Errorf("test %q using %q: got content-type = %q, want %q", testName, method, g, e) + } + if g, e := res.Header.Get("Content-Range"), tt.wantContentRange; g != e { + t.Errorf("test %q using %q: got content-range = %q, want %q", testName, method, g, e) + } + if g, e := res.Header.Get("Last-Modified"), tt.wantLastMod; g != e { + t.Errorf("test %q using %q: got last-modified = %q, want %q", testName, method, g, e) + } + } + } +} + +type panicOnSeek struct{ io.ReadSeeker } + +func Test_scanETag(t *testing.T) { + tests := []struct { + in string + wantETag string + wantRemain string + }{ + {`W/"etag-1"`, `W/"etag-1"`, ""}, + {`"etag-2"`, `"etag-2"`, ""}, + {`"etag-1", "etag-2"`, `"etag-1"`, `, "etag-2"`}, + {"", "", ""}, + {"W/", "", ""}, + {`W/"truc`, "", ""}, + {`w/"case-sensitive"`, "", ""}, + {`"spaced etag"`, "", ""}, + } + for _, test := range tests { + etag, remain := serving.ExportScanETag(test.in) + if etag != test.wantETag || remain != test.wantRemain { + t.Errorf("scanETag(%q)=%q %q, want %q %q", test.in, etag, remain, test.wantETag, test.wantRemain) + } + } +} + +func mustStat(t *testing.T, fileName string) fs.FileInfo { + fi, err := os.Stat(fileName) + if err != nil { + t.Fatal(err) + } + return fi +} diff --git a/test/acceptance/zip_test.go b/test/acceptance/zip_test.go index 97a35e2b..f7623340 100644 --- a/test/acceptance/zip_test.go +++ b/test/acceptance/zip_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -96,6 +97,59 @@ func TestZipServing(t *testing.T) { } } +func TestZipServingCache(t *testing.T) { + _, cleanup := newZipFileServerURL(t, "../../shared/pages/group/zip.gitlab.io/public.zip") + t.Cleanup(cleanup) + + RunPagesProcess(t, + withListeners([]ListenSpec{httpListener}), + ) + + tests := map[string]struct { + host string + urlSuffix string + expectedStatusCode int + expectedContent string + extraHeaders http.Header + }{ + "base_domain_if_modified": { + host: "zip.gitlab.io", + urlSuffix: "/", + expectedStatusCode: http.StatusNotModified, + extraHeaders: http.Header{ + "If-Modified-Since": {time.Now().Format(http.TimeFormat)}, + }, + }, + "base_domain_if_unmodified": { + host: "zip.gitlab.io", + urlSuffix: "/", + expectedStatusCode: http.StatusPreconditionFailed, + extraHeaders: http.Header{ + "If-Unmodified-Since": {time.Now().AddDate(-10, 0, 0).Format(http.TimeFormat)}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + response, err := GetPageFromListenerWithHeaders(t, httpListener, tt.host, tt.urlSuffix, tt.extraHeaders) + require.NoError(t, err) + defer response.Body.Close() + + require.Equal(t, tt.expectedStatusCode, response.StatusCode) + + if tt.expectedStatusCode == http.StatusOK { + require.NotEmpty(t, response.Header.Get("Last-Modified")) + } + + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + + require.Contains(t, string(body), tt.expectedContent, "content mismatch") + }) + } +} + func TestZipServingFromDisk(t *testing.T) { RunPagesProcess(t, withListeners([]ListenSpec{httpListener}), |