e2e/lfs_test.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/e2e/lfs_test.go history · blame · raw

176 lines · 6206 bytes

  1package e2e
  2
  3import (
  4	"bytes"
  5	"crypto/rand"
  6	"crypto/sha256"
  7	"encoding/hex"
  8	"encoding/json"
  9	"fmt"
 10	"net"
 11	"net/http"
 12	"os"
 13	"os/exec"
 14	"path/filepath"
 15	"strings"
 16	"testing"
 17	"time"
 18)
 19
 20func waitForPort(t *testing.T, port int) {
 21	t.Helper()
 22	deadline := time.Now().Add(10 * time.Second)
 23	for {
 24		conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
 25		if err == nil {
 26			conn.Close()
 27			return
 28		}
 29		if time.Now().After(deadline) {
 30			t.Fatal("listener did not come back")
 31		}
 32		time.Sleep(50 * time.Millisecond)
 33	}
 34}
 35
 36func TestLFS(t *testing.T) {
 37	t.Parallel()
 38	if _, err := exec.LookPath("git-lfs"); err != nil {
 39		t.Skip("git-lfs client not installed")
 40	}
 41	inst := startInstance(t)
 42	// LFS hands clients absolute hrefs built from site_url; point it at
 43	// the live HTTP listener so the real git-lfs client can follow them.
 44	inst.proc.Process.Kill()
 45	inst.proc.Wait()
 46	raw, err := os.ReadFile(inst.config)
 47	if err != nil {
 48		t.Fatal(err)
 49	}
 50	raw = bytes.Replace(raw, []byte(`site_url = "https://gitbay.test"`),
 51		[]byte(fmt.Sprintf(`site_url = "http://127.0.0.1:%d"`, inst.httpPort)), 1)
 52	os.WriteFile(inst.config, raw, 0o600)
 53	inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
 54	inst.proc.Stderr = os.Stderr
 55	if err := inst.proc.Start(); err != nil {
 56		t.Fatal(err)
 57	}
 58	waitForPort(t, inst.port)
 59
 60	aliceKey := inst.newKey(t, "alice")
 61	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
 62
 63	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/big"); code != 0 {
 64		t.Fatalf("repo create: %s", errOut)
 65	}
 66	env := inst.gitEnv(aliceKey)
 67	work := t.TempDir()
 68	mustGit(t, work, env, "clone", inst.sshURL("alice/big"), "w")
 69	dir := filepath.Join(work, "w")
 70	mustGit(t, dir, env, "lfs", "install", "--local")
 71	mustGit(t, dir, env, "lfs", "track", "*.bin")
 72	payload := make([]byte, 1<<20)
 73	rand.Read(payload)
 74	os.WriteFile(filepath.Join(dir, "data.bin"), payload, 0o644)
 75	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 76	mustGit(t, dir, env, "add", ".")
 77	mustGit(t, dir, env, "commit", "-q", "-m", "big file")
 78	mustGit(t, dir, env, "push", "-q", "origin", "main")
 79
 80	// The object landed in content-addressed storage, not in git.
 81	oid := sha256.Sum256(payload)
 82	oidHex := hex.EncodeToString(oid[:])
 83	stored := filepath.Join(inst.root, "lfs", oidHex[:2], oidHex[2:4], oidHex)
 84	if fi, err := os.Stat(stored); err != nil || fi.Size() != int64(len(payload)) {
 85		t.Fatalf("object not in lfs store: %v", err)
 86	}
 87
 88	// A fresh SSH clone round-trips the content through the smudge filter.
 89	work2 := t.TempDir()
 90	mustGit(t, work2, env, "clone", inst.sshURL("alice/big"), "w")
 91	dir2 := filepath.Join(work2, "w")
 92	mustGit(t, dir2, env, "lfs", "install", "--local")
 93	mustGit(t, dir2, env, "lfs", "pull", "origin")
 94	got, err := os.ReadFile(filepath.Join(dir2, "data.bin"))
 95	if err != nil || !bytes.Equal(got, payload) {
 96		t.Fatalf("ssh round-trip: %v, %d bytes", err, len(got))
 97	}
 98
 99	// Anonymous HTTPS: public repos serve LFS downloads with no credentials.
100	httpURL := fmt.Sprintf("http://127.0.0.1:%d/alice/big.git", inst.httpPort)
101	work3 := t.TempDir()
102	mustGit(t, work3, env, "clone", httpURL, "w")
103	dir3 := filepath.Join(work3, "w")
104	mustGit(t, dir3, env, "lfs", "install", "--local")
105	mustGit(t, dir3, env, "lfs", "pull", "origin")
106	if got, err := os.ReadFile(filepath.Join(dir3, "data.bin")); err != nil || !bytes.Equal(got, payload) {
107		t.Fatalf("anonymous http round-trip: %v, %d bytes", err, len(got))
108	}
109
110	// Anonymous upload is refused; so is anything on a private repo.
111	batch := func(repo, op, auth string) int {
112		body := fmt.Sprintf(`{"operation":%q,"transfers":["basic"],"objects":[{"oid":%q,"size":4}]}`, op, oidHex)
113		req, _ := http.NewRequest("POST",
114			fmt.Sprintf("http://127.0.0.1:%d/alice/%s.git/info/lfs/objects/batch", inst.httpPort, repo),
115			strings.NewReader(body))
116		req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
117		if auth != "" {
118			req.Header.Set("Authorization", auth)
119		}
120		resp, err := http.DefaultClient.Do(req)
121		if err != nil {
122			t.Fatal(err)
123		}
124		resp.Body.Close()
125		return resp.StatusCode
126	}
127	if code := batch("big", "upload", ""); code != 403 {
128		t.Fatalf("anonymous upload: %d", code)
129	}
130	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/vault", "--private"); code != 0 {
131		t.Fatal("private repo create failed")
132	}
133	if code := batch("vault", "download", ""); code != 404 {
134		t.Fatalf("anonymous private batch: %d", code)
135	}
136
137	// Access rules over SSH: a stranger's authenticate on a private repo
138	// reads as nonexistence; upload needs write.
139	bobKey := inst.newKey(t, "bob")
140	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
141	if _, errOut, code := inst.ssh(t, bobKey, "", "git-lfs-authenticate", "alice/vault", "download"); code != 3 || !strings.Contains(errOut, "not found") {
142		t.Fatalf("stranger authenticate: exit %d, %s", code, errOut)
143	}
144	if _, errOut, code := inst.ssh(t, bobKey, "", "git-lfs-authenticate", "alice/big", "upload"); code != 4 || !strings.Contains(errOut, "denied") {
145		t.Fatalf("read-only upload authenticate: exit %d, %s", code, errOut)
146	}
147
148	// A corrupt upload is refused and stores nothing: mint an upload token
149	// via authenticate, then PUT a body that does not match the oid.
150	out, _, code := inst.ssh(t, aliceKey, "", "git-lfs-authenticate", "alice/big", "upload")
151	if code != 0 {
152		t.Fatalf("authenticate: %s", out)
153	}
154	var grant struct {
155		Header map[string]string `json:"header"`
156	}
157	if err := json.Unmarshal([]byte(out), &grant); err != nil {
158		t.Fatalf("authenticate JSON: %v\n%s", err, out)
159	}
160	fakeOID := strings.Repeat("ab", 32)
161	req, _ := http.NewRequest("PUT",
162		fmt.Sprintf("http://127.0.0.1:%d/alice/big.git/info/lfs/objects/%s", inst.httpPort, fakeOID),
163		strings.NewReader("not the content"))
164	req.Header.Set("Authorization", grant.Header["Authorization"])
165	resp, err := http.DefaultClient.Do(req)
166	if err != nil {
167		t.Fatal(err)
168	}
169	resp.Body.Close()
170	if resp.StatusCode != 422 {
171		t.Fatalf("corrupt upload: %d", resp.StatusCode)
172	}
173	if _, err := os.Stat(filepath.Join(inst.root, "lfs", "ab", "ab", fakeOID)); err == nil {
174		t.Fatal("corrupt object was stored")
175	}
176}