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))
}