settings_test.go
⎇
Raw
1package e2e
2
3import (
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.
15func 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.
29func 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
39func 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