Skip to content
quarry Artifact [7ef1029cbf]
Login

Artifact [7ef1029cbf]

Artifact 7ef1029cbfd9cf566cc657fe6dc1c17aeb78505828635c5604008177deaafe55:


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
}

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) 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.
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")
}