package api
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// bytesFile is an ops.File over a string, counting how often it is
// opened — the 304 test's whole point is that it is not.
type bytesFile struct {
body string
etag string
err error
opens *int
// moving marks a file whose URL can change what it names — an
// archive of a branch — which has an ETag and is not immutable.
moving bool
}
func (b bytesFile) Name() string { return "thing.zip" }
func (b bytesFile) ContentType() string { return "application/zip" }
func (b bytesFile) ETag() string { return b.etag }
func (b bytesFile) Immutable() bool { return b.etag != "" && !b.moving }
func (b bytesFile) Open() (io.ReadCloser, int64, error) {
*b.opens++
if b.err != nil {
return nil, 0, b.err
}
return io.NopCloser(strings.NewReader(b.body)), int64(len(b.body)), nil
}
type fileIn struct {
Repo string `json:"repo"`
}
func fileRouter(t *testing.T, f bytesFile) http.Handler {
t.Helper()
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[fileIn, bytesFile]{
Name: "file.test", Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/filetest", Doc: "test double: answers bytes",
Run: func(context.Context, fileIn) (bytesFile, error) { return f, nil },
}); err != nil {
t.Fatal(err)
}
h, err := RouterWithCaps(r, "test", func(_, _ string) CapSet { return oltp.CapSet("s") }, nil)
if err != nil {
t.Fatal(err)
}
return h
}
func getFile(h http.Handler, ifNoneMatch string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/v1/filetest", nil)
if ifNoneMatch != "" {
req.Header.Set("If-None-Match", ifNoneMatch)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// An op that answers an ops.File is served as the bytes: content type,
// disposition, length and ETag from the file, no JSON envelope. This is
// what replaced read-into-memory, base64-into-JSON, decode-in-the-web-
// route (6dbaeb21, a0770478).
func TestAFileResultIsServedAsBytes(t *testing.T) {
opens := 0
w := getFile(fileRouter(t, bytesFile{body: "PK...", etag: "abc-zip", opens: &opens}), "")
if w.Code != 200 || w.Body.String() != "PK..." {
t.Fatalf("FAIL: %d %q", w.Code, w.Body.String())
}
h := w.Header()
if h.Get("Content-Type") != "application/zip" || h.Get("Content-Length") != "5" ||
!strings.Contains(h.Get("Content-Disposition"), "thing.zip") || h.Get("ETag") != `"abc-zip"` {
t.Fatalf("FAIL: headers %v", h)
}
if opens != 1 {
t.Fatalf("FAIL: opened %d times, want once", opens)
}
t.Logf("ok: %s %s bytes=%s etag=%s", h.Get("Content-Type"), h.Get("Content-Length"), w.Body.String(), h.Get("ETag"))
}
// A reader who already has the bytes gets a 304 and Open is never
// called — so an archive that would have to be built is not built.
func TestAMatchingETagAnswers304WithoutOpening(t *testing.T) {
opens := 0
w := getFile(fileRouter(t, bytesFile{body: "PK...", etag: "abc-zip", opens: &opens}), `"abc-zip"`)
if w.Code != http.StatusNotModified {
t.Fatalf("FAIL: status %d, want 304", w.Code)
}
if opens != 0 {
t.Fatalf("FAIL: Open was called %d times on a 304; the build ran for nothing", opens)
}
t.Log("ok: 304, and Open never ran")
}
// Open's error is an op error and is mapped like one — a missing
// version is a 404 with a JSON body, not a truncated download.
func TestAFileOpenErrorIsMappedLikeAnOpError(t *testing.T) {
opens := 0
w := getFile(fileRouter(t, bytesFile{etag: "x", opens: &opens, err: fmt.Errorf("no version %q: %w", "zzz", ops.ErrNotFound)}), "")
if w.Code != 404 || !strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("FAIL: %d %q", w.Code, w.Body.String())
}
t.Logf("ok: %d %s", w.Code, strings.TrimSpace(w.Body.String()))
}
// A temporary archive lives exactly as long as the reader that asked
// for it: removed on Close, not before the bytes are sent.
func TestATemporaryArchiveIsRemovedOnClose(t *testing.T) {
dir := t.TempDir()
path := dir + "/a.zip"
if err := os.WriteFile(path, []byte("zip"), 0o600); err != nil {
t.Fatal(err)
}
f := archiveFile{name: "a.zip", ctype: "application/zip", version: "v", format: "zip",
build: func() (string, bool, error) { return path, true, nil }}
rc, size, err := f.Open()
if err != nil || size != 3 {
t.Fatalf("FAIL: open: %v size=%d", err, size)
}
if _, err := os.Stat(path); err != nil {
t.Fatal("FAIL: the temporary file was removed before it was read")
}
io.ReadAll(rc)
rc.Close()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("FAIL: the temporary file survived Close")
}
t.Log("ok: present while open, gone after Close")
}
// A file with no ETag — an archive of a moving ref — is never
// immutable: no ETag, revalidate every time, and If-None-Match is not
// honoured. A cache used to be told to keep trunk's archive for a year
// and a revalidating one got a wrong 304 after trunk moved (#1086).
func TestAFileWithoutAnETagIsNeverCachedAsImmutable(t *testing.T) {
opens := 0
h := fileRouter(t, bytesFile{body: "PK...", etag: "", opens: &opens})
w := getFile(h, `"tip-zip"`)
if w.Code != 200 || w.Header().Get("ETag") != "" || w.Header().Get("Cache-Control") != "public, no-cache" {
t.Fatalf("FAIL: %d etag=%q cc=%q", w.Code, w.Header().Get("ETag"), w.Header().Get("Cache-Control"))
}
t.Logf("ok: 200, no ETag, Cache-Control %q, If-None-Match ignored", w.Header().Get("Cache-Control"))
}
// A response that needed a credential is private, immutable or not.
func TestACredentialedFileIsPrivate(t *testing.T) {
opens := 0
h := fileRouter(t, bytesFile{body: "PK...", etag: "abc-zip", opens: &opens})
req := httptest.NewRequest("GET", "/api/v1/filetest", nil)
req.Header.Set("Authorization", "Basic x")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if cc := w.Header().Get("Cache-Control"); cc != "private, max-age=31536000, immutable" {
t.Fatalf("FAIL: Cache-Control %q", cc)
}
req.Header.Del("Authorization")
req.Header.Set("Cookie", "session=x")
w = httptest.NewRecorder()
h.ServeHTTP(w, req)
if cc := w.Header().Get("Cache-Control"); !strings.HasPrefix(cc, "private") {
t.Fatalf("FAIL: with a cookie, Cache-Control %q", cc)
}
t.Log("ok: private with Basic and with a cookie; public otherwise")
}
// A refusal carries no cache headers: "no such branch" is not a fact
// to keep for a year. The 404 used to carry the immutable header and
// an ETag made of the missing name.
func TestARefusedFileCarriesNoCacheHeaders(t *testing.T) {
opens := 0
h := fileRouter(t, bytesFile{etag: "nosuchbranch-tar", opens: &opens, err: fmt.Errorf("no version: %w", ops.ErrNotFound)})
w := getFile(h, "")
if w.Code != 404 || w.Header().Get("Cache-Control") != "" || w.Header().Get("ETag") != "" {
t.Fatalf("FAIL: %d cc=%q etag=%q", w.Code, w.Header().Get("Cache-Control"), w.Header().Get("ETag"))
}
t.Log("ok: 404 with no Cache-Control and no ETag")
}
// archive.get names its bytes only for a full hash.
// A file at a URL that moves — an archive of a branch, resolved to a
// hash — carries the resolved ETag and revalidates every time: 304
// when the hash still matches, never `immutable`. The first fix keyed
// immutability on having an ETag and left /archive/trunk cacheable
// for a year (#1086, re-measured on 33a96e72).
func TestAMovingFileRevalidatesAndIsNeverImmutable(t *testing.T) {
opens := 0
h := fileRouter(t, bytesFile{body: "PK...", etag: "34267e69-tar", opens: &opens, moving: true})
w := getFile(h, "")
if w.Code != 200 || w.Header().Get("ETag") != `"34267e69-tar"` || w.Header().Get("Cache-Control") != "public, no-cache" {
t.Fatalf("FAIL: %d etag=%q cc=%q", w.Code, w.Header().Get("ETag"), w.Header().Get("Cache-Control"))
}
w = getFile(h, `"34267e69-tar"`)
if w.Code != 304 || w.Header().Get("Cache-Control") != "public, no-cache" {
t.Fatalf("FAIL: revalidation: %d cc=%q", w.Code, w.Header().Get("Cache-Control"))
}
t.Log("ok: ETag present, no-cache, 304 on a matching ETag, never immutable")
}
// archive.get: the ETag follows the resolved version; immutability
// follows what the URL asked for.
func TestAnArchiveIsImmutableOnlyWhenTheURLNamedAHash(t *testing.T) {
h := strings.Repeat("ab", 32)
for _, c := range []struct {
requested, resolved string
etag, immutable bool
}{{"trunk", h, true, false}, {"tip", "tip", false, false}, {h, h, true, true}, {h[:12], h, true, false}} {
f := archiveFile{requested: c.requested, version: c.resolved, format: "zip"}
if (f.ETag() != "") != c.etag || f.Immutable() != c.immutable {
t.Errorf("FAIL: requested %q resolved %q: etag=%v immutable=%v, want %v %v", c.requested, c.resolved[:8], f.ETag() != "", f.Immutable(), c.etag, c.immutable)
}
}
t.Log("ok: trunk -> ETag, not immutable; tip -> neither; full hash -> both; prefix -> ETag only")
}
func TestAnArchiveOfAMovingRefHasNoETag(t *testing.T) {
for _, c := range []struct {
v string
want bool
}{{"tip", false}, {"trunk", false}, {"tag:x", false}, {strings.Repeat("ab", 20), true}, {strings.Repeat("ab", 32), true}, {"ABCDEF0123456789ABCDEF0123456789ABCDEF01", false}} {
got := archiveFile{version: c.v, format: "zip"}.ETag() != ""
if got != c.want {
t.Errorf("FAIL: version %q: etag present=%v, want %v", c.v, got, c.want)
}
}
t.Log("ok: an ETag only for a full lowercase hash")
}