Gitfed
bastien-mrq/gitfed / internal / admin / admin_test.go
package admin

import (
	"crypto/ed25519"
	"crypto/rand"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"strings"
	"testing"

	gossh "golang.org/x/crypto/ssh"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/ca"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)

// testCA gives each test its own throwaway CA — GrantCollaborator's
// federated-notify side effect needs a.CA to be non-nil, even for tests
// that never grant a remote collaborator.
func testCA(t *testing.T) *ca.CA {
	t.Helper()
	c, err := ca.LoadOrCreate(t.TempDir())
	if err != nil {
		t.Fatalf("load test CA: %v", err)
	}
	return c
}

// TestCreateUserStripsKeyComment guards against a real bug: a key pasted
// straight from a .pub file carries a "user@host" comment, but the SSH
// protocol never transmits comments during pubkey auth, so the server
// re-derives a bare "algo base64" string from the wire and compares it
// exactly against what's stored. If the comment isn't stripped at
// creation time, the user can never actually log in with that key.
func TestCreateUserStripsKeyComment(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	pub, _, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatalf("generate key: %v", err)
	}
	sshPub, err := gossh.NewPublicKey(pub)
	if err != nil {
		t.Fatalf("ssh public key: %v", err)
	}
	bare := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub)))
	withComment := bare + " alice@laptop"

	if err := a.CreateUser("alice", withComment); err != nil {
		t.Fatalf("CreateUser: %v", err)
	}

	u, err := s.GetUser("alice")
	if err != nil {
		t.Fatalf("GetUser: %v", err)
	}
	if len(u.PubKeys) != 1 || u.PubKeys[0] != bare {
		t.Fatalf("stored key = %q, want comment stripped to %q", u.PubKeys, bare)
	}

	// This is exactly what internal/ssh.checkRawKey does with the key
	// offered on the wire: no comment involved at all.
	if _, err := s.FindUserByKey(bare); err != nil {
		t.Fatalf("FindUserByKey(bare) after CreateUser: %v (login would fail)", err)
	}
}

func TestPasswordLoginFlow(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	if err := a.CreateUser("alice", validTestKey(t)); err != nil {
		t.Fatalf("CreateUser: %v", err)
	}

	// No password set yet: VerifyPassword must fail closed, not error.
	_, ok, err := a.VerifyPassword("alice", "whatever")
	if err != nil {
		t.Fatalf("VerifyPassword before SetPassword: %v", err)
	}
	if ok {
		t.Fatal("VerifyPassword succeeded with no password ever set")
	}

	if err := a.SetPassword("alice", "correct horse battery staple"); err != nil {
		t.Fatalf("SetPassword: %v", err)
	}

	if _, ok, err := a.VerifyPassword("alice", "wrong password"); err != nil || ok {
		t.Fatalf("VerifyPassword with wrong password: ok=%v err=%v", ok, err)
	}

	isAdmin, ok, err := a.VerifyPassword("alice", "correct horse battery staple")
	if err != nil {
		t.Fatalf("VerifyPassword with correct password: %v", err)
	}
	if !ok {
		t.Fatal("VerifyPassword rejected the correct password")
	}
	if isAdmin {
		t.Fatal("alice should not be admin yet")
	}

	if err := a.SetUserAdmin("alice", true); err != nil {
		t.Fatalf("SetUserAdmin: %v", err)
	}
	isAdmin, ok, err = a.VerifyPassword("alice", "correct horse battery staple")
	if err != nil || !ok || !isAdmin {
		t.Fatalf("VerifyPassword after SetUserAdmin: isAdmin=%v ok=%v err=%v", isAdmin, ok, err)
	}
}

func TestSetPasswordRejectsShortPassword(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	if err := a.CreateUser("alice", validTestKey(t)); err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	if err := a.SetPassword("alice", "short"); err == nil {
		t.Fatal("expected an error for a too-short password")
	}
}

func TestCheckAccessRespectsRoles(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	if err := a.CreateRepo("alice/demo", "alice"); err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}

	if _, ok, err := a.CheckAccess("alice/demo", "alice@local.test", store.RoleAdmin); err != nil || !ok {
		t.Fatalf("owner CheckAccess admin: ok=%v err=%v", ok, err)
	}
	if _, ok, err := a.CheckAccess("alice/demo", "mallory@elsewhere", store.RoleRead); err != nil || ok {
		t.Fatalf("stranger CheckAccess on private repo: ok=%v err=%v, want denied", ok, err)
	}
}

// TestCloseMergeRequestDoesNotOverwriteMerged is a regression test for a
// real bug: CloseMergeRequest used to set Status = MRClosed unconditionally,
// so closing an already-merged MR (a stale page, a double-click, or two
// people acting on the same MR at once) silently erased the fact that it
// had actually landed — the merge commit stayed real in the branch, but
// gitfed's own record started claiming "closed without merging".
func TestCloseMergeRequestDoesNotOverwriteMerged(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	mr, err := s.CreateMergeRequest(store.MergeRequest{
		Repo: "alice/repo", Title: "t", Author: "alice@local.test",
		SourceBranch: "feature", TargetBranch: "main",
	})
	if err != nil {
		t.Fatalf("CreateMergeRequest: %v", err)
	}

	// Simulate what MergeMergeRequest does after a clean gitexec.MergeBranches.
	mr.Status = store.MRMerged
	mr.MergedBy = "alice@local.test"
	mr.MergeCommit = "deadbeef"
	if err := s.UpdateMergeRequest(mr); err != nil {
		t.Fatalf("UpdateMergeRequest: %v", err)
	}

	if err := a.CloseMergeRequest(mr.Repo, mr.Number); err != nil {
		t.Fatalf("CloseMergeRequest: %v", err)
	}

	got, err := s.GetMergeRequest(mr.Repo, mr.Number)
	if err != nil {
		t.Fatalf("GetMergeRequest: %v", err)
	}
	if got.Status != store.MRMerged {
		t.Fatalf("status = %q after closing an already-merged MR, want %q — the merge record was overwritten", got.Status, store.MRMerged)
	}
	if got.MergeCommit != "deadbeef" {
		t.Fatalf("MergeCommit = %q, want it preserved as %q", got.MergeCommit, "deadbeef")
	}

	// Closing a genuinely open MR should still work.
	mr2, err := s.CreateMergeRequest(store.MergeRequest{
		Repo: "alice/repo", Title: "t2", Author: "alice@local.test",
		SourceBranch: "feature2", TargetBranch: "main",
	})
	if err != nil {
		t.Fatalf("CreateMergeRequest: %v", err)
	}
	if err := a.CloseMergeRequest(mr2.Repo, mr2.Number); err != nil {
		t.Fatalf("CloseMergeRequest (open MR): %v", err)
	}
	got2, err := s.GetMergeRequest(mr2.Repo, mr2.Number)
	if err != nil {
		t.Fatalf("GetMergeRequest: %v", err)
	}
	if got2.Status != store.MRClosed {
		t.Fatalf("status = %q, want %q", got2.Status, store.MRClosed)
	}
}

// runGit is a minimal test helper to populate a real bare repo via a work
// checkout — same pattern as internal/gitexec's test suite.
func runGit(t *testing.T, dir string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(),
		"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@local.test",
		"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@local.test")
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git %v: %v\n%s", args, err, out)
	}
}

// TestRepoDiskUsage guards the admin repos page's size column: a repo's
// reported usage must actually grow once real content is pushed to it, and
// an unknown repo name must error rather than silently reporting 0.
func TestRepoDiskUsage(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	if err := a.CreateRepo("alice/repo", "alice"); err != nil {
		t.Fatalf("create repo: %v", err)
	}
	before, err := a.RepoDiskUsage("alice/repo")
	if err != nil {
		t.Fatalf("RepoDiskUsage (empty repo): %v", err)
	}

	repo, err := s.GetRepo("alice/repo")
	if err != nil {
		t.Fatalf("get repo: %v", err)
	}
	work := filepath.Join(t.TempDir(), "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	runGit(t, work, "init", "-q", "-b", "main")
	if err := os.WriteFile(filepath.Join(work, "big.bin"), make([]byte, 100_000), 0644); err != nil {
		t.Fatal(err)
	}
	runGit(t, work, "add", ".")
	runGit(t, work, "commit", "-q", "-m", "seed")
	runGit(t, work, "remote", "add", "origin", repo.Path)
	runGit(t, work, "push", "-q", "origin", "main")

	after, err := a.RepoDiskUsage("alice/repo")
	if err != nil {
		t.Fatalf("RepoDiskUsage (after push): %v", err)
	}
	if after <= before {
		t.Fatalf("disk usage after pushing a 100KB file = %d, want > %d (before)", after, before)
	}

	if _, err := a.RepoDiskUsage("does-not-exist"); err == nil {
		t.Fatal("RepoDiskUsage on an unknown repo: want an error, got nil")
	}
}

func TestReadmeLanguageVariants(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	if err := a.CreateRepo("alice/repo", "alice"); err != nil {
		t.Fatalf("create repo: %v", err)
	}
	repo, err := s.GetRepo("alice/repo")
	if err != nil {
		t.Fatalf("get repo: %v", err)
	}

	work := filepath.Join(t.TempDir(), "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	runGit(t, work, "init", "-q", "-b", "main")
	for _, f := range []string{"README.md", "README.fr.md", "README.es.md", "CONTRIBUTING.md"} {
		if err := os.WriteFile(filepath.Join(work, f), []byte("content of "+f), 0644); err != nil {
			t.Fatal(err)
		}
	}
	runGit(t, work, "add", ".")
	runGit(t, work, "commit", "-q", "-m", "seed")
	runGit(t, work, "remote", "add", "origin", repo.Path)
	runGit(t, work, "push", "-q", "origin", "main")

	langs, err := a.ListRepoReadmeLanguages("alice/repo")
	if err != nil {
		t.Fatalf("ListRepoReadmeLanguages: %v", err)
	}
	// Sorted, and CONTRIBUTING.md must not be mistaken for a variant.
	if want := []string{"es", "fr"}; !reflect.DeepEqual(langs, want) {
		t.Fatalf("langs = %v, want %v", langs, want)
	}

	content, found, err := a.GetRepoReadmeLang("alice/repo", "fr")
	if err != nil {
		t.Fatalf("GetRepoReadmeLang: %v", err)
	}
	if !found || content != "content of README.fr.md" {
		t.Fatalf("GetRepoReadmeLang(fr) = %q, found=%v, want %q, true", content, found, "content of README.fr.md")
	}

	// A language with no sibling file just isn't found — not an error.
	_, found, err = a.GetRepoReadmeLang("alice/repo", "de")
	if err != nil {
		t.Fatalf("GetRepoReadmeLang(de): %v", err)
	}
	if found {
		t.Fatal("GetRepoReadmeLang(de) found=true, want false — no README.de.md was pushed")
	}
}

// TestMergeRequestNotifications guards the two local-activity notification
// triggers added alongside the notifications UI: opening an MR notifies the
// repo owner, and commenting notifies the MR's author — but never when the
// actor is notifying themselves, and accepting one of these must not pin a
// repo the way accepting a federation grant notification does (there's
// nothing to "remember" about your own repo).
func TestMergeRequestNotifications(t *testing.T) {
	s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer s.Close()
	a := New(s, nil, "local.test", t.TempDir(), testCA(t))

	if err := a.CreateRepo("repo", "alice"); err != nil {
		t.Fatalf("create repo: %v", err)
	}
	owner := "alice@local.test"
	other := "bob@local.test"

	// bob opens an MR against alice's repo — alice should be notified.
	mr, err := a.CreateMergeRequest("repo", "Add feature", "", other, "feature", "main")
	if err != nil {
		t.Fatalf("CreateMergeRequest: %v", err)
	}
	notifs, err := s.ListNotifications(owner)
	if err != nil {
		t.Fatalf("ListNotifications(owner): %v", err)
	}
	if len(notifs) != 1 {
		t.Fatalf("owner has %d notifications, want 1", len(notifs))
	}
	if notifs[0].Kind != store.NotificationMROpened || notifs[0].Actor != other || notifs[0].MRNumber != mr.Number {
		t.Fatalf("notification = %+v, want kind=mr_opened actor=%s mr=%d", notifs[0], other, mr.Number)
	}

	// alice (the owner) opening her own MR must not notify herself.
	if _, err := a.CreateMergeRequest("repo", "Self MR", "", owner, "feature2", "main"); err != nil {
		t.Fatalf("CreateMergeRequest (self): %v", err)
	}
	notifs, _ = s.ListNotifications(owner)
	if len(notifs) != 1 {
		t.Fatalf("owner has %d notifications after opening their own MR, want still 1 (no self-notify)", len(notifs))
	}

	// alice comments on bob's MR — bob (the author) should be notified.
	if _, err := a.AddMRComment("repo", mr.Number, owner, "looks good"); err != nil {
		t.Fatalf("AddMRComment: %v", err)
	}
	notifs, err = s.ListNotifications(other)
	if err != nil {
		t.Fatalf("ListNotifications(other): %v", err)
	}
	if len(notifs) != 1 || notifs[0].Kind != store.NotificationMRComment || notifs[0].Actor != owner {
		t.Fatalf("bob's notifications = %+v, want one mr_comment from %s", notifs, owner)
	}

	// bob commenting on his own MR must not notify himself.
	if _, err := a.AddMRComment("repo", mr.Number, other, "actually let me fix one more thing"); err != nil {
		t.Fatalf("AddMRComment (self): %v", err)
	}
	notifs, _ = s.ListNotifications(other)
	if len(notifs) != 1 {
		t.Fatalf("bob has %d notifications after commenting on his own MR, want still 1 (no self-notify)", len(notifs))
	}

	// Accepting the mr_opened notification must NOT pin the repo — unlike
	// a grant notification, there's nothing to bookmark.
	aliceNotifs, _ := s.ListNotifications(owner)
	if err := a.AcceptNotification(owner, aliceNotifs[0].ID); err != nil {
		t.Fatalf("AcceptNotification: %v", err)
	}
	pinned, err := s.ListPinnedRepos(owner)
	if err != nil {
		t.Fatalf("ListPinnedRepos: %v", err)
	}
	if len(pinned) != 0 {
		t.Fatalf("pinned = %+v, want none — accepting an mr_opened notification shouldn't pin anything", pinned)
	}
}

func validTestKey(t *testing.T) string {
	t.Helper()
	pub, _, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatalf("generate key: %v", err)
	}
	sshPub, err := gossh.NewPublicKey(pub)
	if err != nil {
		t.Fatalf("ssh public key: %v", err)
	}
	return string(gossh.MarshalAuthorizedKey(sshPub))
}