gitmote

gitmote / internal / auth / auth_test.go  ·  history  ·  branches

11627 bytes · raw

package auth

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"testing"
	"time"

	"github.com/atmin/gitmote/internal/meta"
)

func openMeta(t *testing.T) *meta.Metadata {
	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() })
	return m
}

func TestMintRoundTrips(t *testing.T) {
	raw, selector, verifier, err := Mint()
	if err != nil {
		t.Fatalf("Mint: %v", err)
	}
	gotSel, gotSecret, ok := split(raw)
	if !ok {
		t.Fatalf("split(%q) failed", raw)
	}
	if gotSel != selector {
		t.Errorf("selector = %q, want %q", gotSel, selector)
	}
	if hashSecret(gotSecret) != verifier {
		t.Error("hashSecret(secret) != verifier")
	}
	// Two mints never collide.
	raw2, _, _, _ := Mint()
	if raw2 == raw {
		t.Error("two Mint calls produced the same token")
	}
}

func TestSplitRejectsMalformed(t *testing.T) {
	for _, raw := range []string{"", "nope", "gmt_", "gmt_onlyselector", "gmt_.secret", "gmt_sel.", "sel.secret"} {
		if _, _, ok := split(raw); ok {
			t.Errorf("split(%q) = ok, want rejected", raw)
		}
	}
}

// seedToken creates a user + token and returns the raw token and user id.
func seedToken(t *testing.T, m *meta.Metadata) (string, int64) {
	t.Helper()
	ctx := context.Background()
	u, err := m.CreateUser(ctx, "atmin")
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	raw, selector, verifier, err := 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)
	}
	return raw, u.ID
}

// bearer builds a request carrying repoName and an optional bearer token.
func bearer(token string) *http.Request {
	r := httptest.NewRequest(http.MethodGet, "/", nil)
	if token != "" {
		r.Header.Set("Authorization", "Bearer "+token)
	}
	return r
}

func TestAuthorize(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)

	repo, err := m.CreateRepo(ctx, "repo", "main")
	if err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}
	raw, userID := seedToken(t, m)

	// No token / malformed / unknown / wrong secret → ErrUnauthorized.
	sel, _, _ := split(raw)
	unauth := map[string]*http.Request{
		"no token":     bearer(""),
		"malformed":    bearer("not-a-token"),
		"unknown":      bearer("gmt_ffffffffffffffffffffffffffffffff.1234"),
		"wrong secret": bearer("gmt_" + sel + ".wrongsecret"),
	}
	for name, r := range unauth {
		if _, err := g.Authorize(r, "repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
			t.Errorf("%s: Authorize = %v, want ErrUnauthorized", name, err)
		}
	}

	// Valid token, no ACL → ErrForbidden.
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); !errors.Is(err, ErrForbidden) {
		t.Errorf("no ACL: Authorize = %v, want ErrForbidden", err)
	}

	// Valid token, unknown repo → meta.ErrNotFound.
	if _, err := g.Authorize(bearer(raw), "nope", meta.PermRead); !errors.Is(err, meta.ErrNotFound) {
		t.Errorf("unknown repo: Authorize = %v, want meta.ErrNotFound", err)
	}

	// Grant read: read is allowed, write is not.
	if err := m.SetACL(ctx, repo.ID, userID, meta.PermRead); err != nil {
		t.Fatalf("SetACL: %v", err)
	}
	user, err := g.Authorize(bearer(raw), "repo", meta.PermRead)
	if err != nil {
		t.Fatalf("read with read ACL: %v", err)
	}
	if user.ID != userID {
		t.Errorf("authorized user = %d, want %d", user.ID, userID)
	}
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
		t.Errorf("write with read ACL: Authorize = %v, want ErrForbidden", err)
	}

	// Admin covers read.
	if err := m.SetACL(ctx, repo.ID, userID, meta.PermAdmin); err != nil {
		t.Fatalf("SetACL admin: %v", err)
	}
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); err != nil {
		t.Errorf("read with admin ACL: %v", err)
	}

	// A successful authorization stamps last_used.
	toks, _ := m.ListTokens(ctx, userID)
	if len(toks) != 1 || toks[0].LastUsed == nil {
		t.Errorf("last_used not stamped after Authorize: %+v", toks)
	}
}

func TestVerifyToken(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)
	raw, userID := seedToken(t, m)
	sel, _, _ := split(raw)

	// Valid raw token resolves to its owner — the web UI login path.
	user, err := g.VerifyToken(ctx, raw)
	if err != nil {
		t.Fatalf("VerifyToken(valid): %v", err)
	}
	if user.ID != userID {
		t.Errorf("user = %d, want %d", user.ID, userID)
	}

	// Malformed, unknown selector, and wrong secret all → ErrUnauthorized.
	for name, tok := range map[string]string{
		"malformed":    "not-a-token",
		"unknown":      "gmt_ffffffffffffffffffffffffffffffff.1234",
		"wrong secret": "gmt_" + sel + ".wrongsecret",
	} {
		if _, err := g.VerifyToken(ctx, tok); !errors.Is(err, ErrUnauthorized) {
			t.Errorf("%s: VerifyToken = %v, want ErrUnauthorized", name, err)
		}
	}
}

func TestBasicAuthCredentials(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)
	repo, _ := m.CreateRepo(ctx, "repo", "main")
	raw, userID := seedToken(t, m)
	if err := m.SetACL(ctx, repo.ID, userID, meta.PermRead); err != nil {
		t.Fatalf("SetACL: %v", err)
	}

	// Token as Basic password (git's default) and as Basic username (token-only)
	// both authenticate.
	for _, creds := range []struct {
		user, pass string
	}{
		{"git", raw}, // password carries the token
		{raw, ""},    // username carries the token
	} {
		r := httptest.NewRequest(http.MethodGet, "/", nil)
		r.SetBasicAuth(creds.user, creds.pass)
		if _, err := g.Authorize(r, "repo", meta.PermRead); err != nil {
			t.Errorf("Basic(%q,%q): Authorize = %v, want ok", creds.user, creds.pass, err)
		}
	}
}

func TestTokenExpiry(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)
	repo, _ := m.CreateRepo(ctx, "repo", "main")
	u, _ := m.CreateUser(ctx, "atmin")
	if err := m.SetACL(ctx, repo.ID, u.ID, meta.PermRead); err != nil {
		t.Fatalf("SetACL: %v", err)
	}

	expiry := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
	raw, err := g.MintScoped(ctx, u.ID, "ci", nil, false, expiry)
	if err != nil {
		t.Fatalf("MintScoped: %v", err)
	}

	// Before expiry: authorizes.
	g.now = func() time.Time { return expiry.Add(-time.Minute) }
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); err != nil {
		t.Errorf("before expiry: Authorize = %v, want ok", err)
	}

	// At/after expiry: rejected as unauthorized.
	g.now = func() time.Time { return expiry }
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
		t.Errorf("at expiry: Authorize = %v, want ErrUnauthorized", err)
	}
	g.now = func() time.Time { return expiry.Add(time.Hour) }
	if _, err := g.VerifyToken(ctx, raw); !errors.Is(err, ErrUnauthorized) {
		t.Errorf("after expiry: VerifyToken = %v, want ErrUnauthorized", err)
	}
}

func TestRepoScope(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)
	repoA, _ := m.CreateRepo(ctx, "a", "main")
	repoB, _ := m.CreateRepo(ctx, "b", "main")
	u, _ := m.CreateUser(ctx, "atmin")
	// The owner has read on BOTH repos.
	if err := m.SetACL(ctx, repoA.ID, u.ID, meta.PermRead); err != nil {
		t.Fatalf("SetACL a: %v", err)
	}
	if err := m.SetACL(ctx, repoB.ID, u.ID, meta.PermRead); err != nil {
		t.Fatalf("SetACL b: %v", err)
	}

	// A token scoped to A authorizes A but is denied B despite the ACL on B.
	raw, err := g.MintScoped(ctx, u.ID, "scoped-a", &repoA.ID, false, time.Time{})
	if err != nil {
		t.Fatalf("MintScoped: %v", err)
	}
	if _, err := g.Authorize(bearer(raw), "a", meta.PermRead); err != nil {
		t.Errorf("scoped repo: Authorize = %v, want ok", err)
	}
	if _, err := g.Authorize(bearer(raw), "b", meta.PermRead); !errors.Is(err, ErrForbidden) {
		t.Errorf("other repo: Authorize = %v, want ErrForbidden", err)
	}
}

func TestReadOnlyToken(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)
	repo, _ := m.CreateRepo(ctx, "repo", "main")
	u, _ := m.CreateUser(ctx, "atmin")
	// The owner can write, but the token is read-only.
	if err := m.SetACL(ctx, repo.ID, u.ID, meta.PermWrite); err != nil {
		t.Fatalf("SetACL: %v", err)
	}

	raw, err := g.MintScoped(ctx, u.ID, "ro", nil, true, time.Time{})
	if err != nil {
		t.Fatalf("MintScoped: %v", err)
	}
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); err != nil {
		t.Errorf("read with read-only token: Authorize = %v, want ok", err)
	}
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
		t.Errorf("write with read-only token: Authorize = %v, want ErrForbidden", err)
	}
}

func TestUnscopedTokenUnaffected(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)
	repo, _ := m.CreateRepo(ctx, "repo", "main")
	raw, userID := seedToken(t, m) // ordinary CreateToken PAT
	if err := m.SetACL(ctx, repo.ID, userID, meta.PermWrite); err != nil {
		t.Fatalf("SetACL: %v", err)
	}

	// No expiry, any owned repo, read + write per ACL — even far in the future.
	g.now = func() time.Time { return time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC) }
	if _, err := g.Authorize(bearer(raw), "repo", meta.PermWrite); err != nil {
		t.Errorf("unscoped token write: Authorize = %v, want ok", err)
	}
}

func TestAuthorizeVisibility(t *testing.T) {
	ctx := context.Background()
	m := openMeta(t)
	g := NewGuard(m)

	pub, _ := m.CreateRepo(ctx, "pub", "main")
	if err := m.SetVisibility(ctx, pub.ID, meta.VisibilityPublic); err != nil {
		t.Fatalf("SetVisibility: %v", err)
	}
	if _, err := m.CreateRepo(ctx, "priv", "main"); err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}

	// Anonymous read of a public repo is allowed — a nil user, no error.
	if u, err := g.Authorize(bearer(""), "pub", meta.PermRead); err != nil || u != nil {
		t.Errorf("anon read public = (%v, %v), want (nil, nil)", u, err)
	}
	// Writes are never anonymous, public or not.
	if _, err := g.Authorize(bearer(""), "pub", meta.PermWrite); !errors.Is(err, ErrUnauthorized) {
		t.Errorf("anon write public = %v, want ErrUnauthorized", err)
	}
	// Anonymous read of a private repo is refused (and existence is hidden).
	if _, err := g.Authorize(bearer(""), "priv", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
		t.Errorf("anon read private = %v, want ErrUnauthorized", err)
	}
	// Anonymous read of a missing repo is also 401, not 404 — no inventory leak.
	if _, err := g.Authorize(bearer(""), "missing", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
		t.Errorf("anon read missing = %v, want ErrUnauthorized", err)
	}

	// A valid token with no ACL still reads a public repo, but not a private one.
	raw, _ := seedToken(t, m)
	if _, err := g.Authorize(bearer(raw), "pub", meta.PermRead); err != nil {
		t.Errorf("token read public (no ACL) = %v, want ok", err)
	}
	if _, err := g.Authorize(bearer(raw), "priv", meta.PermRead); !errors.Is(err, ErrForbidden) {
		t.Errorf("token read private (no ACL) = %v, want ErrForbidden", err)
	}
}

func TestAllows(t *testing.T) {
	cases := []struct {
		granted, required meta.Perm
		want              bool
	}{
		{meta.PermRead, meta.PermRead, true},
		{meta.PermWrite, meta.PermRead, true},
		{meta.PermAdmin, meta.PermWrite, true},
		{meta.PermRead, meta.PermWrite, false},
		{meta.PermWrite, meta.PermAdmin, false},
		{"", meta.PermRead, false},
		{meta.PermRead, "", false},
	}
	for _, c := range cases {
		if got := allows(c.granted, c.required); got != c.want {
			t.Errorf("allows(%q, %q) = %v, want %v", c.granted, c.required, got, c.want)
		}
	}
}