settings_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "crypto/rand" |
| 6 | "net/url" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | gossh "golang.org/x/crypto/ssh" |
| 11 | ) |
| 12 | |
| 13 | // settingsPubKey returns a throwaway ed25519 public key in authorized_keys |
| 14 | // form. The TS suite shelled out to ssh-keygen for the same thing. |
| 15 | func settingsPubKey(t *testing.T) string { |
| 16 | t.Helper() |
| 17 | pub, _, err := ed25519.GenerateKey(rand.Reader) |
| 18 | if err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | key, err := gossh.NewPublicKey(pub) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))) + " e2e@hearthforge" |
| 26 | } |
| 27 | |
| 28 | // settingsLocation asserts a redirect and returns the decoded Location. |
| 29 | func settingsLocation(t *testing.T, r *response) string { |
| 30 | t.Helper() |
| 31 | r.mustRedirect("") |
| 32 | loc, err := url.QueryUnescape(r.Location()) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | return loc |
| 37 | } |
| 38 | |
| 39 | func TestSettings(t *testing.T) { |
| 40 | e := newEnv(t) |
| 41 | admin := e.admin() |
| 42 | alice := e.register("alice", "password123") |
| 43 | e.createRepo(admin, "my-repo") |
| 44 | e.seedRepo("my-repo", nil) |
| 45 | |
| 46 | testPubKey := settingsPubKey(t) |
| 47 | |
| 48 | t.Run("settings", func(t *testing.T) { |
| 49 | t.Run("settings page requires auth", func(t *testing.T) { |
| 50 | e.anon().get("/settings").mustRedirect("/login") |
| 51 | }) |
| 52 | |
| 53 | t.Run("settings page loads for logged-in user", func(t *testing.T) { |
| 54 | if got := admin.get("/settings").Text("h1.page-title"); got != "Settings" { |
| 55 | t.Errorf("page title = %q", got) |
| 56 | } |
| 57 | }) |
| 58 | |
| 59 | // ── Password ────────────────────────────────────────────────────── |
| 60 | |
| 61 | t.Run("password change with mismatched passwords shows error", func(t *testing.T) { |
| 62 | r := alice.post("/settings/password", url.Values{ |
| 63 | "new_password": {"newpass123"}, "confirm_password": {"different456"}, |
| 64 | }) |
| 65 | if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { |
| 66 | t.Errorf("location = %q", loc) |
| 67 | } |
| 68 | }) |
| 69 | |
| 70 | t.Run("password change with wrong current password shows error", func(t *testing.T) { |
| 71 | r := alice.post("/settings/password", url.Values{ |
| 72 | "current_password": {"wrongpassword"}, |
| 73 | "new_password": {"newpass123"}, "confirm_password": {"newpass123"}, |
| 74 | }) |
| 75 | if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { |
| 76 | t.Errorf("location = %q", loc) |
| 77 | } |
| 78 | }) |
| 79 | |
| 80 | t.Run("password change too short shows error", func(t *testing.T) { |
| 81 | r := alice.post("/settings/password", url.Values{ |
| 82 | "current_password": {"password123"}, |
| 83 | "new_password": {"short"}, "confirm_password": {"short"}, |
| 84 | }) |
| 85 | if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { |
| 86 | t.Errorf("location = %q", loc) |
| 87 | } |
| 88 | }) |
| 89 | |
| 90 | // ── SSH keys ────────────────────────────────────────────────────── |
| 91 | |
| 92 | t.Run("add SSH key with unsupported key type shows error", func(t *testing.T) { |
| 93 | r := admin.post("/settings/ssh-keys", url.Values{ |
| 94 | "name": {"Bad key"}, "public_key": {"ssh-invalid AAAABBBBCCCC test@test"}, |
| 95 | }) |
| 96 | if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { |
| 97 | t.Errorf("location = %q", loc) |
| 98 | } |
| 99 | }) |
| 100 | |
| 101 | t.Run("add valid SSH key shows success and key appears in list", func(t *testing.T) { |
| 102 | r := admin.post("/settings/ssh-keys", url.Values{ |
| 103 | "name": {"My Laptop"}, "public_key": {testPubKey}, |
| 104 | }) |
| 105 | if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=ssh_key_added") { |
| 106 | t.Errorf("location = %q", loc) |
| 107 | } |
| 108 | if got := admin.get("/settings").Text(".ssh-key-name"); !strings.Contains(got, "My Laptop") { |
| 109 | t.Errorf("ssh key name = %q", got) |
| 110 | } |
| 111 | }) |
| 112 | |
| 113 | t.Run("add duplicate SSH key shows error", func(t *testing.T) { |
| 114 | r := admin.post("/settings/ssh-keys", url.Values{ |
| 115 | "name": {"Duplicate"}, "public_key": {testPubKey}, |
| 116 | }) |
| 117 | if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { |
| 118 | t.Errorf("location = %q", loc) |
| 119 | } |
| 120 | }) |
| 121 | |
| 122 | t.Run("delete SSH key removes it from list", func(t *testing.T) { |
| 123 | page := admin.get("/settings") |
| 124 | id := page.Attr(`form[action="/settings/ssh-keys/delete"] input[name=id]`, "value") |
| 125 | if id == "" { |
| 126 | t.Fatal("no ssh key delete form") |
| 127 | } |
| 128 | r := admin.post("/settings/ssh-keys/delete", url.Values{"id": {id}}) |
| 129 | if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=ssh_key_deleted") { |
| 130 | t.Errorf("location = %q", loc) |
| 131 | } |
| 132 | if n := admin.get("/settings").Count(".ssh-key-name"); n != 0 { |
| 133 | t.Errorf("ssh keys left = %d", n) |
| 134 | } |
| 135 | }) |
| 136 | |
| 137 | // ── Admin user management ──────────────────────────────────────── |
| 138 | |
| 139 | t.Run("admin can create a new user account", func(t *testing.T) { |
| 140 | r := admin.post("/admin/users", url.Values{ |
| 141 | "username": {"charlie"}, "password": {"charliepw1"}, |
| 142 | }) |
| 143 | if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=user_created") { |
| 144 | t.Errorf("location = %q", loc) |
| 145 | } |
| 146 | }) |
| 147 | |
| 148 | t.Run("admin cannot create duplicate username", func(t *testing.T) { |
| 149 | r := admin.post("/admin/users", url.Values{ |
| 150 | "username": {"charlie"}, "password": {"charliepw1"}, |
| 151 | }) |
| 152 | if loc := settingsLocation(t, r); !strings.Contains(loc, "error") { |
| 153 | t.Errorf("location = %q", loc) |
| 154 | } |
| 155 | }) |
| 156 | |
| 157 | t.Run("admin cannot create user with invalid username characters", func(t *testing.T) { |
| 158 | r := admin.post("/admin/users", url.Values{ |
| 159 | "username": {"bad user!"}, "password": {"password123"}, |
| 160 | }) |
| 161 | r.mustStatus(302) |
| 162 | if !strings.Contains(r.Location(), "error") { |
| 163 | t.Errorf("location = %q", r.Location()) |
| 164 | } |
| 165 | }) |
| 166 | |
| 167 | t.Run("non-admin gets 403 when creating user", func(t *testing.T) { |
| 168 | alice.post("/admin/users", url.Values{ |
| 169 | "username": {"hacker"}, "password": {"password123"}, |
| 170 | }).mustStatus(403) |
| 171 | }) |
| 172 | |
| 173 | t.Run("admin can delete user account", func(t *testing.T) { |
| 174 | r := admin.post("/admin/users/delete", url.Values{"username": {"charlie"}}) |
| 175 | r.mustStatus(302) |
| 176 | if !strings.Contains(r.Location(), "success=user_deleted") { |
| 177 | t.Errorf("location = %q", r.Location()) |
| 178 | } |
| 179 | }) |
| 180 | |
| 181 | t.Run("admin cannot delete the admin account", func(t *testing.T) { |
| 182 | r := admin.post("/admin/users/delete", url.Values{"username": {"admin"}}) |
| 183 | r.mustStatus(302) |
| 184 | if !strings.Contains(r.Location(), "error") { |
| 185 | t.Errorf("location = %q", r.Location()) |
| 186 | } |
| 187 | }) |
| 188 | |
| 189 | t.Run("settings page has no git identity section", func(t *testing.T) { |
| 190 | r := admin.get("/settings") |
| 191 | if r.Contains("Git Identity") { |
| 192 | t.Error("git identity section present") |
| 193 | } |
| 194 | if r.Has("[name=git_name]") || r.Has("[name=git_email]") { |
| 195 | t.Error("git identity fields present") |
| 196 | } |
| 197 | }) |
| 198 | |
| 199 | t.Run("git identity route no longer exists", func(t *testing.T) { |
| 200 | admin.post("/settings/git-identity", url.Values{ |
| 201 | "git_name": {"Test"}, "git_email": {"test@example.com"}, |
| 202 | }).mustStatus(404) |
| 203 | }) |
| 204 | }) |
| 205 | |
| 206 | t.Run("repository deletion", func(t *testing.T) { |
| 207 | e.createRepo(admin, "deleteme-repo") |
| 208 | |
| 209 | t.Run("admin can delete repository", func(t *testing.T) { |
| 210 | r := admin.post("/deleteme-repo/settings/delete", nil) |
| 211 | r.mustStatus(302) |
| 212 | if r.Location() != "/" { |
| 213 | t.Errorf("location = %q", r.Location()) |
| 214 | } |
| 215 | }) |
| 216 | |
| 217 | t.Run("deleted repository returns 404", func(t *testing.T) { |
| 218 | admin.get("/deleteme-repo").mustStatus(404) |
| 219 | }) |
| 220 | |
| 221 | t.Run("deleted repository no longer appears in list", func(t *testing.T) { |
| 222 | for _, name := range admin.get("/").Texts(".repo-name") { |
| 223 | if name == "deleteme-repo" { |
| 224 | t.Error("deleted repo still listed") |
| 225 | } |
| 226 | } |
| 227 | }) |
| 228 | |
| 229 | t.Run("non-admin cannot delete repository", func(t *testing.T) { |
| 230 | alice.post("/my-repo/settings/delete", nil).mustStatus(403) |
| 231 | }) |
| 232 | }) |
| 233 | |
| 234 | t.Run("repository rename", func(t *testing.T) { |
| 235 | e.createRepo(admin, "renameme-repo") |
| 236 | e.createRepo(admin, "rename-other") |
| 237 | |
| 238 | t.Run("rejects invalid name", func(t *testing.T) { |
| 239 | r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"bad name"}}) |
| 240 | r.mustStatus(302) |
| 241 | if !strings.Contains(r.Location(), "/renameme-repo/settings?error=") { |
| 242 | t.Errorf("location = %q", r.Location()) |
| 243 | } |
| 244 | if loc := settingsLocation(t, r); !strings.Contains(loc, "Invalid") { |
| 245 | t.Errorf("location = %q", loc) |
| 246 | } |
| 247 | }) |
| 248 | |
| 249 | t.Run("rejects no-op rename", func(t *testing.T) { |
| 250 | r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"renameme-repo"}}) |
| 251 | if loc := settingsLocation(t, r); !strings.Contains(loc, "same as the current name") { |
| 252 | t.Errorf("location = %q", loc) |
| 253 | } |
| 254 | }) |
| 255 | |
| 256 | t.Run("rejects duplicate name", func(t *testing.T) { |
| 257 | r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"rename-other"}}) |
| 258 | if loc := settingsLocation(t, r); !strings.Contains(loc, "already taken") { |
| 259 | t.Errorf("location = %q", loc) |
| 260 | } |
| 261 | }) |
| 262 | |
| 263 | t.Run("non-admin cannot rename", func(t *testing.T) { |
| 264 | alice.post("/renameme-repo/settings/rename", url.Values{"new_name": {"hijack"}}).mustStatus(403) |
| 265 | }) |
| 266 | |
| 267 | t.Run("admin can rename repository", func(t *testing.T) { |
| 268 | r := admin.post("/renameme-repo/settings/rename", url.Values{"new_name": {"renamed-repo"}}) |
| 269 | r.mustStatus(302) |
| 270 | if !strings.Contains(r.Location(), "/renamed-repo/settings?success=") { |
| 271 | t.Errorf("location = %q", r.Location()) |
| 272 | } |
| 273 | admin.get("/renameme-repo").mustStatus(404) |
| 274 | admin.get("/renamed-repo").mustStatus(200) |
| 275 | }) |
| 276 | }) |
| 277 | |
| 278 | t.Run("404 handling", func(t *testing.T) { |
| 279 | t.Run("non-existent repository returns 404", func(t *testing.T) { |
| 280 | admin.get("/no-such-repo").mustStatus(404) |
| 281 | }) |
| 282 | t.Run("non-existent issue returns 404", func(t *testing.T) { |
| 283 | admin.get("/my-repo/issues/99999").mustStatus(404) |
| 284 | }) |
| 285 | t.Run("non-existent commit returns 404", func(t *testing.T) { |
| 286 | admin.get("/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").mustStatus(404) |
| 287 | }) |
| 288 | t.Run("non-existent file blob returns 404", func(t *testing.T) { |
| 289 | admin.get("/my-repo/blob/main/no-such-file.txt").mustStatus(404) |
| 290 | }) |
| 291 | t.Run("non-existent patch returns 404", func(t *testing.T) { |
| 292 | admin.get("/my-repo/patches/99999").mustStatus(404) |
| 293 | }) |
| 294 | t.Run("non-existent release returns 404", func(t *testing.T) { |
| 295 | admin.get("/my-repo/releases/99999").mustStatus(404) |
| 296 | }) |
| 297 | }) |
| 298 | } |
| 299 |