e2e/lfs_test.go

v1.43.1
gitbay/e2e/lfs_test.go history · blame · raw

226 lines · 8226 bytes

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