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