package e2e import ( "crypto/ed25519" "crypto/rand" "net/url" "strings" "testing" gossh "golang.org/x/crypto/ssh" ) // settingsPubKey returns a throwaway ed25519 public key in authorized_keys // form. The TS suite shelled out to ssh-keygen for the same thing. func settingsPubKey(t *testing.T) string { t.Helper() pub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } key, err := gossh.NewPublicKey(pub) if err != nil { t.Fatal(err) } return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))) + " e2e@hearthforge" } // settingsLocation asserts a redirect and returns the decoded Location. func settingsLocation(t *testing.T, r *response) string { t.Helper() r.mustRedirect("") loc, err := url.QueryUnescape(r.Location()) if err != nil { t.Fatal(err) } return loc } func TestSettings(t *testing.T) { e := newEnv(t) admin := e.admin() alice := e.register("alice", "password123") e.createRepo(admin, "my-repo") e.seedRepo("my-repo", nil) testPubKey := settingsPubKey(t) t.Run("settings", func(t *testing.T) { t.Run("settings page requires auth", func(t *testing.T) { e.anon().get("/settings").mustRedirect("/login") }) t.Run("settings page loads for logged-in user", func(t *testing.T) { if got := admin.get("/settings").Text("h1.page-title"); got != "Settings" { t.Errorf("page title = %q", got) } }) // ── Password ────────────────────────────────────────────────────── t.Run("password change with mismatched passwords shows error", func(t *testing.T) { r := alice.post("/settings/password", url.Values{ "new_password": {"newpass123"}, "confirm_password": {"different456"}, }) if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) t.Run("password change with wrong current password shows error", func(t *testing.T) { r := alice.post("/settings/password", url.Values{ "current_password": {"wrongpassword"}, "new_password": {"newpass123"}, "confirm_password": {"newpass123"}, }) if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) t.Run("password change too short shows error", func(t *testing.T) { r := alice.post("/settings/password", url.Values{ "current_password": {"password123"}, "new_password": {"short"}, "confirm_password": {"short"}, }) if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) // ── SSH keys ────────────────────────────────────────────────────── t.Run("add SSH key with unsupported key type shows error", func(t *testing.T) { r := admin.post("/settings/ssh-keys", url.Values{ "name": {"Bad key"}, "public_key": {"ssh-invalid AAAABBBBCCCC test@test"}, }) if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) t.Run("add valid SSH key shows success and key appears in list", func(t *testing.T) { r := admin.post("/settings/ssh-keys", url.Values{ "name": {"My Laptop"}, "public_key": {testPubKey}, }) if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=ssh_key_added") { t.Errorf("location = %q", loc) } if got := admin.get("/settings").Text(".ssh-key-name"); !strings.Contains(got, "My Laptop") { t.Errorf("ssh key name = %q", got) } }) t.Run("add duplicate SSH key shows error", func(t *testing.T) { r := admin.post("/settings/ssh-keys", url.Values{ "name": {"Duplicate"}, "public_key": {testPubKey}, }) if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) t.Run("delete SSH key removes it from list", func(t *testing.T) { page := admin.get("/settings") id := page.Attr(`form[action="/settings/ssh-keys/delete"] input[name=id]`, "value") if id == "" { t.Fatal("no ssh key delete form") } r := admin.post("/settings/ssh-keys/delete", url.Values{"id": {id}}) if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=ssh_key_deleted") { t.Errorf("location = %q", loc) } if n := admin.get("/settings").Count(".ssh-key-name"); n != 0 { t.Errorf("ssh keys left = %d", n) } }) // ── Admin user management ──────────────────────────────────────── t.Run("admin can create a new user account", func(t *testing.T) { r := admin.post("/admin/users", url.Values{ "username": {"charlie"}, "password": {"charliepw1"}, }) if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=user_created") { t.Errorf("location = %q", loc) } }) t.Run("admin cannot create duplicate username", func(t *testing.T) { r := admin.post("/admin/users", url.Values{ "username": {"charlie"}, "password": {"charliepw1"}, }) if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) t.Run("admin cannot create user with invalid username characters", func(t *testing.T) { r := admin.post("/admin/users", url.Values{ "username": {"bad user!"}, "password": {"password123"}, }) r.mustStatus(302) if !strings.Contains(r.Location(), "error") { t.Errorf("location = %q", r.Location()) } }) t.Run("non-admin gets 403 when creating user", func(t *testing.T) { alice.post("/admin/users", url.Values{ "username": {"hacker"}, "password": {"password123"}, }).mustStatus(403) }) t.Run("admin can delete user account", func(t *testing.T) { r := admin.post("/admin/users/delete", url.Values{"username": {"charlie"}}) r.mustStatus(302) if !strings.Contains(r.Location(), "success=user_deleted") { t.Errorf("location = %q", r.Location()) } }) t.Run("admin cannot delete the admin account", func(t *testing.T) { r := admin.post("/admin/users/delete", url.Values{"username": {"admin"}}) r.mustStatus(302) if !strings.Contains(r.Location(), "error") { t.Errorf("location = %q", r.Location()) } }) t.Run("settings page has no git identity section", func(t *testing.T) { r := admin.get("/settings") if r.Contains("Git Identity") { t.Error("git identity section present") } if r.Has("[name=git_name]") || r.Has("[name=git_email]") { t.Error("git identity fields present") } }) t.Run("git identity route no longer exists", func(t *testing.T) { admin.post("/settings/git-identity", url.Values{ "git_name": {"Test"}, "git_email": {"test@example.com"}, }).mustStatus(404) }) }) t.Run("repository deletion", func(t *testing.T) { e.createRepo(admin, "deleteme-repo") t.Run("admin can delete repository", func(t *testing.T) { r := admin.post("/deleteme-repo/settings/delete", nil) r.mustStatus(302) if r.Location() != "/" { t.Errorf("location = %q", r.Location()) } }) t.Run("deleted repository returns 404", func(t *testing.T) { admin.get("/deleteme-repo").mustStatus(404) }) t.Run("deleted repository no longer appears in list", func(t *testing.T) { for _, name := range admin.get("/").Texts(".repo-name") { if name == "deleteme-repo" { t.Error("deleted repo still listed") } } }) t.Run("non-admin cannot delete repository", func(t *testing.T) { alice.post("/my-repo/settings/delete", nil).mustStatus(403) }) }) t.Run("repository rename", func(t *testing.T) { e.createRepo(admin, "renameme-repo") e.createRepo(admin, "rename-other") t.Run("rejects invalid name", func(t *testing.T) { r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"bad name"}}) r.mustStatus(302) if !strings.Contains(r.Location(), "/renameme-repo/settings?error=") { t.Errorf("location = %q", r.Location()) } if loc := settingsLocation(t, r); !strings.Contains(loc, "Invalid") { t.Errorf("location = %q", loc) } }) t.Run("rejects no-op rename", func(t *testing.T) { r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"renameme-repo"}}) if loc := settingsLocation(t, r); !strings.Contains(loc, "same as the current name") { t.Errorf("location = %q", loc) } }) t.Run("rejects duplicate name", func(t *testing.T) { r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"rename-other"}}) if loc := settingsLocation(t, r); !strings.Contains(loc, "already taken") { t.Errorf("location = %q", loc) } }) t.Run("non-admin cannot rename", func(t *testing.T) { alice.post("/renameme-repo/settings/rename", url.Values{"new_name": {"hijack"}}).mustStatus(403) }) t.Run("admin can rename repository", func(t *testing.T) { r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"renamed-repo"}}) r.mustStatus(302) if !strings.Contains(r.Location(), "/renamed-repo/settings?success=") { t.Errorf("location = %q", r.Location()) } admin.get("/renameme-repo").mustStatus(404) admin.get("/renamed-repo").mustStatus(200) }) }) t.Run("404 handling", func(t *testing.T) { t.Run("non-existent repository returns 404", func(t *testing.T) { admin.get("/no-such-repo").mustStatus(404) }) t.Run("non-existent issue returns 404", func(t *testing.T) { admin.get("/my-repo/issues/99999").mustStatus(404) }) t.Run("non-existent commit returns 404", func(t *testing.T) { admin.get("/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").mustStatus(404) }) t.Run("non-existent file blob returns 404", func(t *testing.T) { admin.get("/my-repo/blob/main/no-such-file.txt").mustStatus(404) }) t.Run("non-existent patch returns 404", func(t *testing.T) { admin.get("/my-repo/patches/99999").mustStatus(404) }) t.Run("non-existent release returns 404", func(t *testing.T) { admin.get("/my-repo/releases/99999").mustStatus(404) }) }) }