gitmote

gitmote / internal / meta / meta_test.go  ·  history  ·  branches

11343 bytes · raw

package meta

import (
	"context"
	"errors"
	"path/filepath"
	"testing"
	"time"
)

// open returns a fresh, local-only Metadata at a temp path (no replication).
func open(t *testing.T) *Metadata {
	t.Helper()
	return openAt(t, filepath.Join(t.TempDir(), "meta.sqlite3"))
}

func openAt(t *testing.T, path string) *Metadata {
	t.Helper()
	m, err := Open(context.Background(), Config{LocalPath: path})
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	t.Cleanup(func() { _ = m.Close() })
	return m
}

// seedRepo inserts a repo and returns it; a helper for the ref/ACL tests.
func seedRepo(t *testing.T, m *Metadata, name string) *Repo {
	t.Helper()
	r, err := m.CreateRepo(context.Background(), name, "")
	if err != nil {
		t.Fatalf("CreateRepo(%q): %v", name, err)
	}
	return r
}

func TestMigrationsIdempotentAndFreshSchema(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "meta.sqlite3")

	// Fresh DB: schema is usable — a round-trip proves the tables exist.
	m := openAt(t, path)
	r, err := m.CreateRepo(ctx, "dotfiles", "")
	if err != nil {
		t.Fatalf("CreateRepo on fresh schema: %v", err)
	}
	if r.DefaultBranch != "main" {
		t.Errorf("default branch = %q, want main", r.DefaultBranch)
	}
	if r.Visibility != VisibilityPrivate {
		t.Errorf("visibility = %q, want private", r.Visibility)
	}
	if err := m.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}

	// Re-open the same file: migrations are a no-op and data survives.
	m2 := openAt(t, path)
	got, err := m2.GetRepo(ctx, "dotfiles")
	if err != nil {
		t.Fatalf("GetRepo after re-open: %v", err)
	}
	if got.ID != r.ID {
		t.Errorf("repo id after re-open = %d, want %d", got.ID, r.ID)
	}
}

func TestScopedTokenColumnsMigrateAndPersist(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "meta.sqlite3")

	// A DB whose tokens table predates the scoped columns: create it by hand
	// without them, plus a legacy row, then let Open's guarded migration add them.
	m := openAt(t, path)
	u, err := m.CreateUser(ctx, "atmin")
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	if _, err := m.CreateToken(ctx, u.ID, "legacy-sel", "legacy-ver", "old"); err != nil {
		t.Fatalf("CreateToken: %v", err)
	}
	if _, err := m.db.ExecContext(ctx, `ALTER TABLE tokens DROP COLUMN expires_at`); err != nil {
		t.Fatalf("drop expires_at: %v", err)
	}
	if _, err := m.db.ExecContext(ctx, `ALTER TABLE tokens DROP COLUMN repo_scope`); err != nil {
		t.Fatalf("drop repo_scope: %v", err)
	}
	if _, err := m.db.ExecContext(ctx, `ALTER TABLE tokens DROP COLUMN read_only`); err != nil {
		t.Fatalf("drop read_only: %v", err)
	}
	if err := m.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}

	// Re-open: migrate adds the columns. Open again: idempotent (no error).
	m2 := openAt(t, path)
	// The legacy row reads back with NULL/0 defaults.
	ta, err := m2.TokenBySelector(ctx, "legacy-sel")
	if err != nil {
		t.Fatalf("TokenBySelector(legacy): %v", err)
	}
	if ta.ExpiresAt != nil || ta.RepoScope != nil || ta.ReadOnly {
		t.Errorf("legacy token = %+v, want no expiry/scope, not read-only", ta)
	}
	if err := m2.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}
	m3 := openAt(t, path) // second migrate is a no-op
	r := seedRepo(t, m3, "repo")
	exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
	if _, err := m3.CreateScopedToken(ctx, u.ID, "sel2", "ver2", "scoped", &r.ID, true, exp); err != nil {
		t.Fatalf("CreateScopedToken: %v", err)
	}
	ta2, err := m3.TokenBySelector(ctx, "sel2")
	if err != nil {
		t.Fatalf("TokenBySelector(sel2): %v", err)
	}
	if ta2.RepoScope == nil || *ta2.RepoScope != r.ID || !ta2.ReadOnly ||
		ta2.ExpiresAt == nil || !ta2.ExpiresAt.Equal(exp) {
		t.Errorf("scoped token = %+v, want scope %d, read-only, expiry %v", ta2, r.ID, exp)
	}
}

func TestRepoCRUD(t *testing.T) {
	ctx := context.Background()
	m := open(t)

	if _, err := m.GetRepo(ctx, "nope"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("GetRepo(missing) = %v, want ErrNotFound", err)
	}

	seedRepo(t, m, "one")
	b := seedRepo(t, m, "two")
	if b.DefaultBranch != "main" {
		t.Errorf("default branch = %q, want main", b.DefaultBranch)
	}

	repos, err := m.ListRepos(ctx)
	if err != nil {
		t.Fatalf("ListRepos: %v", err)
	}
	if len(repos) != 2 || repos[0].Name != "one" || repos[1].Name != "two" {
		t.Errorf("ListRepos = %+v, want [one two]", repos)
	}

	// name is UNIQUE.
	if _, err := m.CreateRepo(ctx, "one", ""); err == nil {
		t.Error("CreateRepo(duplicate name) succeeded, want error")
	}
}

// TestRepoNameValidation covers the flat single-segment namespace rules: a
// trailing ".git" is stripped, reserved globals and structural forms are
// rejected with a clear error, and a ".git" clone URL resolves via GetRepo.
func TestRepoNameValidation(t *testing.T) {
	ctx := context.Background()

	// A trailing ".git" is stripped at creation and tolerated at lookup.
	m := open(t)
	r, err := m.CreateRepo(ctx, "gitmote.git", "")
	if err != nil {
		t.Fatalf("CreateRepo(gitmote.git): %v", err)
	}
	if r.Name != "gitmote" {
		t.Errorf("normalized name = %q, want gitmote", r.Name)
	}
	if got, err := m.GetRepo(ctx, "gitmote.git"); err != nil || got.ID != r.ID {
		t.Errorf("GetRepo(gitmote.git) = (%v, %v), want the same repo", got, err)
	}

	// Rejected names: reserved globals, a leading dot, the bare dash, a slash, empty.
	for _, name := range []string{"login", "api", "internal", ".hidden", "-", "a/b", ""} {
		if _, err := m.CreateRepo(ctx, name, ""); err == nil {
			t.Errorf("CreateRepo(%q) succeeded, want a validation error", name)
		}
	}
}

// TestSetVisibility flips a repo public and back, and rejects an invalid value.
func TestSetVisibility(t *testing.T) {
	ctx := context.Background()
	m := open(t)
	r := seedRepo(t, m, "repo")
	if r.Public() {
		t.Fatal("new repo should default to private")
	}

	if err := m.SetVisibility(ctx, r.ID, VisibilityPublic); err != nil {
		t.Fatalf("SetVisibility(public): %v", err)
	}
	got, _ := m.GetRepo(ctx, "repo")
	if !got.Public() {
		t.Errorf("visibility = %q, want public", got.Visibility)
	}

	// The CHECK constraint rejects an unknown value.
	if err := m.SetVisibility(ctx, r.ID, "secret"); err == nil {
		t.Error("SetVisibility(secret) succeeded, want a CHECK violation")
	}
	// Unknown repo id.
	if err := m.SetVisibility(ctx, 9999, VisibilityPublic); !errors.Is(err, ErrNotFound) {
		t.Errorf("SetVisibility(unknown id) = %v, want ErrNotFound", err)
	}
}

// TestCanRead covers the visibility-aware repo-read shared by git and browse:
// a public repo is anyone-readable, a private repo needs an ACL.
func TestCanRead(t *testing.T) {
	ctx := context.Background()
	m := open(t)
	priv := seedRepo(t, m, "priv")
	pub := seedRepo(t, m, "pub")
	if err := m.SetVisibility(ctx, pub.ID, VisibilityPublic); err != nil {
		t.Fatalf("SetVisibility: %v", err)
	}
	pub, _ = m.GetRepo(ctx, "pub")

	u, _ := m.CreateUser(ctx, "spectator")

	cases := []struct {
		name   string
		repo   *Repo
		userID int64
		want   bool
	}{
		{"public anonymous", pub, 0, true},
		{"public user", pub, u.ID, true},
		{"private anonymous", priv, 0, false},
		{"private no ACL", priv, u.ID, false},
	}
	for _, c := range cases {
		if got, err := m.CanRead(ctx, c.repo, c.userID); err != nil || got != c.want {
			t.Errorf("%s: CanRead = (%v, %v), want %v", c.name, got, err, c.want)
		}
	}

	// A read ACL opens the private repo.
	if err := m.SetACL(ctx, priv.ID, u.ID, PermRead); err != nil {
		t.Fatalf("SetACL: %v", err)
	}
	if got, err := m.CanRead(ctx, priv, u.ID); err != nil || !got {
		t.Errorf("private with read ACL: CanRead = (%v, %v), want true", got, err)
	}
}

func TestListReposForViewer(t *testing.T) {
	ctx := context.Background()
	m := open(t)
	pub := seedRepo(t, m, "pub")
	if err := m.SetVisibility(ctx, pub.ID, VisibilityPublic); err != nil {
		t.Fatalf("SetVisibility: %v", err)
	}
	seedRepo(t, m, "priv") // admin-only, no ACL
	mine := seedRepo(t, m, "mine")
	u, _ := m.CreateUser(ctx, "alice")
	if err := m.SetACL(ctx, mine.ID, u.ID, PermRead); err != nil {
		t.Fatalf("SetACL: %v", err)
	}

	names := func(userID int64) []string {
		repos, err := m.ListReposForViewer(ctx, userID)
		if err != nil {
			t.Fatalf("ListReposForViewer: %v", err)
		}
		var out []string
		for _, r := range repos {
			out = append(out, r.Name)
		}
		return out
	}

	// Anonymous (userID 0) sees only public repos.
	if got := names(0); len(got) != 1 || got[0] != "pub" {
		t.Errorf("anonymous viewer repos = %v, want [pub]", got)
	}
	// alice sees public + the one she has an ACL on (ordered by name).
	if got := names(u.ID); len(got) != 2 || got[0] != "mine" || got[1] != "pub" {
		t.Errorf("alice viewer repos = %v, want [mine pub]", got)
	}
}

func TestTokenStorage(t *testing.T) {
	ctx := context.Background()
	m := open(t)

	u, err := m.CreateUser(ctx, "atmin")
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}

	const (
		selector = "0123456789abcdef"
		verifier = "deadbeef" // stands in for SHA-256(secret); opaque to meta
	)
	tok, err := m.CreateToken(ctx, u.ID, selector, verifier, "laptop")
	if err != nil {
		t.Fatalf("CreateToken: %v", err)
	}

	// Lookup by selector returns the verifier + owner, and does not touch
	// last_used.
	ta, err := m.TokenBySelector(ctx, selector)
	if err != nil {
		t.Fatalf("TokenBySelector: %v", err)
	}
	if ta.TokenID != tok.ID || ta.Verifier != verifier || ta.User.ID != u.ID || ta.User.Handle != "atmin" {
		t.Errorf("TokenBySelector = %+v, want token %d verifier %q user %d atmin", ta, tok.ID, verifier, u.ID)
	}

	before, err := m.ListTokens(ctx, u.ID)
	if err != nil {
		t.Fatalf("ListTokens: %v", err)
	}
	if len(before) != 1 || before[0].Label != "laptop" || before[0].LastUsed != nil {
		t.Errorf("ListTokens = %+v, want one 'laptop' token with last_used unset", before)
	}

	// TouchToken stamps last_used.
	if err := m.TouchToken(ctx, tok.ID); err != nil {
		t.Fatalf("TouchToken: %v", err)
	}
	after, _ := m.ListTokens(ctx, u.ID)
	if after[0].LastUsed == nil {
		t.Error("last_used still unset after TouchToken")
	}

	// An unknown selector is ErrNotFound.
	if _, err := m.TokenBySelector(ctx, "nope"); !errors.Is(err, ErrNotFound) {
		t.Errorf("TokenBySelector(unknown) = %v, want ErrNotFound", err)
	}

	// selector is UNIQUE.
	if _, err := m.CreateToken(ctx, u.ID, selector, "other", "dup"); err == nil {
		t.Error("CreateToken(duplicate selector) succeeded, want error")
	}
}

func TestACLLookup(t *testing.T) {
	ctx := context.Background()
	m := open(t)

	r := seedRepo(t, m, "repo")
	u, err := m.CreateUser(ctx, "atmin")
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}

	if _, err := m.GetACL(ctx, r.ID, u.ID); !errors.Is(err, ErrNotFound) {
		t.Fatalf("GetACL(unset) = %v, want ErrNotFound", err)
	}

	if err := m.SetACL(ctx, r.ID, u.ID, PermRead); err != nil {
		t.Fatalf("SetACL(read): %v", err)
	}
	if p, _ := m.GetACL(ctx, r.ID, u.ID); p != PermRead {
		t.Errorf("GetACL = %q, want read", p)
	}

	// Upsert replaces the level.
	if err := m.SetACL(ctx, r.ID, u.ID, PermAdmin); err != nil {
		t.Fatalf("SetACL(admin): %v", err)
	}
	if p, _ := m.GetACL(ctx, r.ID, u.ID); p != PermAdmin {
		t.Errorf("GetACL after upsert = %q, want admin", p)
	}

	// The CHECK constraint rejects a bogus level.
	if err := m.SetACL(ctx, r.ID, u.ID, Perm("root")); err == nil {
		t.Error("SetACL(bogus perm) succeeded, want CHECK violation")
	}
}