gitmote

gitmote / internal / githttp / backend_test.go  ·  history  ·  branches

9353 bytes · raw

package githttp

import (
	"bytes"
	"context"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/atmin/gitmote/internal/auth"
	"github.com/atmin/gitmote/internal/meta"
	"github.com/atmin/gitmote/internal/repo"
	"github.com/atmin/gitmote/internal/store"
)

const repoName = "dotfiles"

// git runs a git command hermetically and returns its trimmed combined output,
// failing the test on a nonzero exit.
func git(t *testing.T, dir string, args ...string) string {
	t.Helper()
	// Disable any credential helper so a token in one URL is never cached and
	// silently reused by a later "anonymous" request to the same host:port.
	cmd := exec.CommandContext(context.Background(), "git", append([]string{"-c", "credential.helper="}, args...)...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(),
		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
		"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
		"GIT_TERMINAL_PROMPT=0",
	)
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	return strings.TrimSpace(string(out))
}

// loadObjects mirrors a source repo's loose/packed objects into the store under
// the repo's prefix — the durable objects a read hydrates from.
func loadObjects(t *testing.T, s store.Store, src, name string) {
	t.Helper()
	objRoot := filepath.Join(src, ".git", "objects")
	err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return err
		}
		rel, err := filepath.Rel(objRoot, p)
		if err != nil {
			return err
		}
		data, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		return s.Put(context.Background(), name+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
	})
	if err != nil {
		t.Fatalf("load objects: %v", err)
	}
}

// newServer wires meta + mem store + materializer behind a guarded read-path
// handler on an httptest server.
func newServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store) {
	t.Helper()
	m, err := meta.Open(context.Background(), meta.Config{
		LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
	})
	if err != nil {
		t.Fatalf("meta.Open: %v", err)
	}
	t.Cleanup(func() { _ = m.Close() })

	s := store.NewMem()
	h, err := New(Config{
		Materializer: repo.New(m, s, t.TempDir()),
		Authorizer:   auth.NewGuard(m),
		Logger:       slog.New(slog.DiscardHandler),
	})
	if err != nil {
		t.Fatalf("githttp.New: %v", err)
	}
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	return srv, m, s
}

// seedRepo builds a one-commit source repo, loads its objects, and records the
// repo + main ref, returning (repoID, headSHA).
func seedRepo(t *testing.T, m *meta.Metadata, s store.Store, src string) (int64, string) {
	t.Helper()
	ctx := context.Background()
	git(t, src, "init", "-b", "main", ".")
	if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "add", "README.md")
	git(t, src, "commit", "-m", "first")
	loadObjects(t, s, src, repoName)

	head := git(t, src, "rev-parse", "HEAD")
	r, err := m.CreateRepo(ctx, repoName, "main")
	if err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}
	if err := m.CASRef(ctx, r.ID, "refs/heads/main", meta.ZeroSHA, head); err != nil {
		t.Fatalf("CASRef: %v", err)
	}
	return r.ID, head
}

// mintUser creates a user with a fresh token and returns the raw token. When
// perm is non-empty it also grants that permission on repoID.
func mintUser(t *testing.T, m *meta.Metadata, repoID int64, handle string, perm meta.Perm) string {
	t.Helper()
	ctx := context.Background()
	u, err := m.CreateUser(ctx, handle)
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	raw, selector, verifier, err := auth.Mint()
	if err != nil {
		t.Fatalf("Mint: %v", err)
	}
	if _, err := m.CreateToken(ctx, u.ID, selector, verifier, "test"); err != nil {
		t.Fatalf("CreateToken: %v", err)
	}
	if perm != "" {
		if err := m.SetACL(ctx, repoID, u.ID, perm); err != nil {
			t.Fatalf("SetACL: %v", err)
		}
	}
	return raw
}

// authedURL embeds Basic credentials (git's native HTTP auth) into a base URL.
func authedURL(base, raw string) string {
	return strings.Replace(base, "://", "://git:"+raw+"@", 1)
}

func TestCloneAndFetch(t *testing.T) {
	ctx := context.Background()
	srv, m, s := newServer(t)

	src := t.TempDir()
	repoID, head1 := seedRepo(t, m, s, src)
	raw := mintUser(t, m, repoID, "atmin", meta.PermRead)

	// Golden path: a real clone over HTTP with a read token.
	dst := filepath.Join(t.TempDir(), "clone")
	git(t, "", "clone", authedURL(srv.URL, raw)+"/"+repoName, dst)

	if got := git(t, dst, "rev-parse", "HEAD"); got != head1 {
		t.Errorf("cloned HEAD = %q, want %q", got, head1)
	}
	if data, err := os.ReadFile(filepath.Join(dst, "README.md")); err != nil || string(data) != "hello\n" {
		t.Errorf("cloned README = %q (err %v), want \"hello\\n\"", data, err)
	}
	git(t, dst, "fsck", "--strict")

	// Seed a second commit server-side.
	if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "second")
	loadObjects(t, s, src, repoName)
	head2 := git(t, src, "rev-parse", "HEAD")
	r, _ := m.GetRepo(ctx, repoName)
	if err := m.CASRef(ctx, r.ID, "refs/heads/main", head1, head2); err != nil {
		t.Fatalf("CASRef advance: %v", err)
	}

	// An incremental fetch pulls the new commit (explicit URL carries creds).
	git(t, dst, "fetch", authedURL(srv.URL, raw)+"/"+repoName, "main")
	if got := git(t, dst, "rev-parse", "FETCH_HEAD"); got != head2 {
		t.Errorf("FETCH_HEAD after fetch = %q, want %q", got, head2)
	}
}

// infoRefs GETs the upload-pack advertisement with an optional bearer token and
// returns the status code.
func infoRefs(t *testing.T, srvURL, token string) int {
	t.Helper()
	req, err := http.NewRequest(http.MethodGet, srvURL+"/"+repoName+"/info/refs?service=git-upload-pack", nil)
	if err != nil {
		t.Fatal(err)
	}
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("GET info/refs: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusUnauthorized && resp.Header.Get("WWW-Authenticate") == "" {
		t.Error("401 without WWW-Authenticate challenge")
	}
	return resp.StatusCode
}

func TestReadAuthorization(t *testing.T) {
	srv, m, s := newServer(t)
	repoID, _ := seedRepo(t, m, s, t.TempDir())

	reader := mintUser(t, m, repoID, "reader", meta.PermRead)
	noACL := mintUser(t, m, repoID, "stranger", "")

	cases := []struct {
		name  string
		token string
		want  int
	}{
		{"no token", "", http.StatusUnauthorized},
		{"malformed token", "not-a-token", http.StatusUnauthorized},
		{"unknown token", "gmt_00000000000000000000000000000000.1111", http.StatusUnauthorized},
		{"valid token without ACL", noACL, http.StatusForbidden},
		{"valid read token", reader, http.StatusOK},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := infoRefs(t, srv.URL, c.token); got != c.want {
				t.Errorf("status = %d, want %d", got, c.want)
			}
		})
	}
}

func TestWrongSecretRejected(t *testing.T) {
	srv, m, s := newServer(t)
	repoID, _ := seedRepo(t, m, s, t.TempDir())
	raw := mintUser(t, m, repoID, "reader", meta.PermRead)

	// Same selector, tampered secret: the constant-time verifier compare fails.
	selector, _, _ := strings.Cut(strings.TrimPrefix(raw, "gmt_"), ".")
	forged := "gmt_" + selector + ".deadbeefdeadbeef"
	if got := infoRefs(t, srv.URL, forged); got != http.StatusUnauthorized {
		t.Errorf("forged-secret status = %d, want 401", got)
	}
}

func TestReceivePackNotServed(t *testing.T) {
	srv, m, s := newServer(t)
	repoID, _ := seedRepo(t, m, s, t.TempDir())
	raw := mintUser(t, m, repoID, "reader", meta.PermRead)

	// The write service advertisement is not part of the read path (task 07) —
	// 404 even for an authorized reader.
	req, _ := http.NewRequest(http.MethodGet, srv.URL+"/"+repoName+"/info/refs?service=git-receive-pack", nil)
	req.Header.Set("Authorization", "Bearer "+raw)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("GET: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Errorf("receive-pack advertisement status = %d, want 404", resp.StatusCode)
	}
}

func TestParseGitPath(t *testing.T) {
	cases := []struct {
		path         string
		wantRepo     string
		wantEndpoint string
		wantOK       bool
	}{
		{"/dotfiles/info/refs", "dotfiles", "info/refs", true},
		{"/dotfiles/git-upload-pack", "dotfiles", "git-upload-pack", true},
		{"/dotfiles/git-receive-pack", "dotfiles", "git-receive-pack", true},
		{"/one/info/refs", "one", "info/refs", true},
		{"/info/refs", "", "", false},       // no repo
		{"/git-upload-pack", "", "", false}, // no repo
		{"/dotfiles/HEAD", "", "", false},   // not a served endpoint
		{"/", "", "", false},
	}
	for _, c := range cases {
		gotRepo, gotEndpoint, ok := parseGitPath(c.path)
		if gotRepo != c.wantRepo || gotEndpoint != c.wantEndpoint || ok != c.wantOK {
			t.Errorf("parseGitPath(%q) = (%q, %q, %v), want (%q, %q, %v)",
				c.path, gotRepo, gotEndpoint, ok, c.wantRepo, c.wantEndpoint, c.wantOK)
		}
	}
}