auth_test.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "io"
8 "io/fs"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "path/filepath"
13 "strings"
14 "testing"
15
16 "github.com/go-chi/chi/v5"
17 gossh "golang.org/x/crypto/ssh"
18
19 hearthforge "hearthforge"
20 "hearthforge/internal/config"
21 "hearthforge/internal/db"
22)
23
24const testAdminPassword = "correct-horse"
25
26// newTestServer builds a server on a throwaway database with the admin
27// account already created.
28func newTestServer(t *testing.T) (*Server, http.Handler) {
29 t.Helper()
30 dir := t.TempDir()
31 database, err := db.Open(filepath.Join(dir, "test.db"))
32 if err != nil {
33 t.Fatalf("open db: %v", err)
34 }
35 t.Cleanup(func() { database.Close() })
36 if _, err := database.InitAdmin(context.Background(), testAdminPassword); err != nil {
37 t.Fatalf("init admin: %v", err)
38 }
39
40 s := &Server{
41 DB: database,
42 Cfg: &config.Config{
43 DataDir: dir,
44 OwnerDisplayName: "Owner",
45 BaseURL: "http://localhost:3000",
46 PublicOrigin: "http://localhost:3000",
47 RegistrationType: "enabled",
48 MaxUsernameBytes: 64,
49 MaxPasswordBytes: 1024,
50 MaxTextBodyBytes: 100000,
51 MaxUserUploadBytes: 2 << 20,
52 RateLimitDisabled: true,
53 },
54 }
55
56 r := chi.NewRouter()
57 r.Use(s.withSession)
58 static, _ := fs.Sub(hearthforge.StaticFS, "web/static")
59 r.Handle("/assets/*", http.FileServerFS(static))
60 s.authRoutes(r)
61 s.settingsRoutes(r)
62 s.avatarRoutes(r)
63 return s, r
64}
65
66// postForm sends a form request with the given cookies.
67func postForm(t *testing.T, h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
68 t.Helper()
69 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
70 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
71 for _, c := range cookies {
72 req.AddCookie(c)
73 }
74 rec := httptest.NewRecorder()
75 h.ServeHTTP(rec, req)
76 return rec
77}
78
79// loginAsAdmin signs in and returns the session cookie.
80func loginAsAdmin(t *testing.T, h http.Handler) *http.Cookie {
81 t.Helper()
82 rec := postForm(t, h, "/login", url.Values{
83 "username": {db.AdminUsername},
84 "password": {testAdminPassword},
85 })
86 if rec.Code != http.StatusFound {
87 t.Fatalf("login status = %d, want 302", rec.Code)
88 }
89 for _, c := range rec.Result().Cookies() {
90 if c.Name == sessionCookie && c.Value != "" {
91 return c
92 }
93 }
94 t.Fatal("login set no session cookie")
95 return nil
96}
97
98func TestLoginPage(t *testing.T) {
99 _, h := newTestServer(t)
100 rec := httptest.NewRecorder()
101 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
102 if rec.Code != http.StatusOK {
103 t.Fatalf("status = %d, want 200", rec.Code)
104 }
105 if !strings.Contains(rec.Body.String(), "Sign in with password") {
106 t.Error("login form is missing")
107 }
108}
109
110func TestLoginWrongPassword(t *testing.T) {
111 _, h := newTestServer(t)
112 rec := postForm(t, h, "/login", url.Values{
113 "username": {db.AdminUsername},
114 "password": {"wrong"},
115 })
116 if rec.Code != http.StatusOK {
117 t.Fatalf("status = %d, want 200", rec.Code)
118 }
119 if !strings.Contains(rec.Body.String(), "Invalid username or password") {
120 t.Error("error message is missing")
121 }
122 if len(rec.Result().Cookies()) != 0 {
123 t.Error("a failed login must not set a cookie")
124 }
125}
126
127func TestLoginSuccessAndSettings(t *testing.T) {
128 _, h := newTestServer(t)
129 cookie := loginAsAdmin(t, h)
130
131 req := httptest.NewRequest(http.MethodGet, "/settings", nil)
132 req.AddCookie(cookie)
133 rec := httptest.NewRecorder()
134 h.ServeHTTP(rec, req)
135 if rec.Code != http.StatusOK {
136 t.Fatalf("settings status = %d, want 200", rec.Code)
137 }
138 if !strings.Contains(rec.Body.String(), "SSH Keys") {
139 t.Error("settings page is missing the SSH key section")
140 }
141}
142
143func TestAddSSHKeyStoresFingerprint(t *testing.T) {
144 s, h := newTestServer(t)
145 cookie := loginAsAdmin(t, h)
146
147 pub, _, err := ed25519.GenerateKey(rand.Reader)
148 if err != nil {
149 t.Fatal(err)
150 }
151 sshPub, err := gossh.NewPublicKey(pub)
152 if err != nil {
153 t.Fatal(err)
154 }
155 line := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub))) + " me@host"
156
157 rec := postForm(t, h, "/settings/ssh-keys", url.Values{
158 "name": {"My Laptop"},
159 "public_key": {line},
160 }, cookie)
161 if got := rec.Header().Get("Location"); !strings.Contains(got, "success=ssh_key_added") {
162 t.Fatalf("Location = %q, body = %q", got, rec.Body.String())
163 }
164
165 keys, err := s.DB.ListSSHKeys(context.Background(), 1)
166 if err != nil {
167 t.Fatal(err)
168 }
169 if len(keys) != 1 {
170 t.Fatalf("stored %d keys, want 1", len(keys))
171 }
172 parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keys[0].PublicKey))
173 if err != nil {
174 t.Fatal(err)
175 }
176 if want := gossh.FingerprintSHA256(parsed); keys[0].Fingerprint != want {
177 t.Errorf("fingerprint = %q, want %q", keys[0].Fingerprint, want)
178 }
179 if keys[0].Name != "My Laptop" {
180 t.Errorf("name = %q", keys[0].Name)
181 }
182}
183
184func TestAddSSHKeyRejectsBadType(t *testing.T) {
185 _, h := newTestServer(t)
186 cookie := loginAsAdmin(t, h)
187 rec := postForm(t, h, "/settings/ssh-keys", url.Values{
188 "name": {"bad"},
189 "public_key": {"ssh-dss AAAAB3NzaC1kc3M="},
190 }, cookie)
191 if got := rec.Header().Get("Location"); !strings.Contains(got, "error=") {
192 t.Fatalf("Location = %q", got)
193 }
194}
195
196func TestAvatarRoutes(t *testing.T) {
197 _, h := newTestServer(t)
198
199 // The admin avatar is written on demand, and only with a version query.
200 rec := httptest.NewRecorder()
201 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/1.png", nil))
202 if rec.Code != http.StatusNotFound {
203 t.Errorf("status without ?v = %d, want 404", rec.Code)
204 }
205
206 rec = httptest.NewRecorder()
207 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/1.png?v=1", nil))
208 if rec.Code != http.StatusOK {
209 t.Fatalf("status = %d, want 200", rec.Code)
210 }
211 if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
212 t.Errorf("Content-Type = %q", ct)
213 }
214 if !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
215 t.Errorf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
216 }
217
218 rec = httptest.NewRecorder()
219 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/999.png?v=1", nil))
220 if rec.Code != http.StatusNotFound {
221 t.Errorf("unknown user status = %d, want 404", rec.Code)
222 }
223}
224
225// The literal /assets/code-theme.css must win over the /assets/* static file
226// handler that server.go mounts first.
227func TestCodeThemeBeatsStaticMount(t *testing.T) {
228 _, h := newTestServer(t)
229 rec := httptest.NewRecorder()
230 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/code-theme.css", nil))
231 if rec.Code != http.StatusOK {
232 t.Fatalf("status = %d, want 200", rec.Code)
233 }
234 if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
235 t.Errorf("Content-Type = %q", ct)
236 }
237 if !strings.Contains(rec.Body.String(), ".ch-k {") {
238 t.Error("body is not the generated chroma stylesheet")
239 }
240
241 // The static mount still answers for a real file.
242 rec = httptest.NewRecorder()
243 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/theme.js", nil))
244 if rec.Code != http.StatusOK {
245 t.Errorf("static asset status = %d, want 200", rec.Code)
246 }
247}
248
249func TestRegisterCreatesSessionAndAvatar(t *testing.T) {
250 s, h := newTestServer(t)
251 rec := postForm(t, h, "/register", url.Values{
252 "username": {"newbie"},
253 "password": {"hunter2hunter2"},
254 "password2": {"hunter2hunter2"},
255 })
256 if rec.Code != http.StatusFound {
257 t.Fatalf("status = %d, want 302, body %q", rec.Code, rec.Body.String())
258 }
259 user, err := s.DB.UserByName(context.Background(), "newbie")
260 if err != nil || user == nil {
261 t.Fatalf("user not created: %v", err)
262 }
263
264 req := httptest.NewRequest(http.MethodGet, "/avatars/2.png?v=1", nil)
265 rr := httptest.NewRecorder()
266 h.ServeHTTP(rr, req)
267 if rr.Code != http.StatusOK {
268 t.Errorf("default avatar status = %d, want 200", rr.Code)
269 }
270 if body, _ := io.ReadAll(rr.Body); len(body) == 0 {
271 t.Error("default avatar is empty")
272 }
273}
274
275func TestRegisterRejectsShortPassword(t *testing.T) {
276 _, h := newTestServer(t)
277 rec := postForm(t, h, "/register", url.Values{
278 "username": {"shorty"},
279 "password": {"abc"},
280 "password2": {"abc"},
281 })
282 if !strings.Contains(rec.Body.String(), "at least 8 characters") {
283 t.Errorf("missing error, body = %q", rec.Body.String())
284 }
285}
286
287func TestSettingsRequiresAuth(t *testing.T) {
288 _, h := newTestServer(t)
289 rec := httptest.NewRecorder()
290 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil))
291 if rec.Code != http.StatusFound {
292 t.Fatalf("status = %d, want 302", rec.Code)
293 }
294 if got := rec.Header().Get("Location"); got != "/login" {
295 t.Errorf("Location = %q", got)
296 }
297}
298
299func TestPasskeyRegisterOptionsNeedsSession(t *testing.T) {
300 _, h := newTestServer(t)
301 rec := postForm(t, h, "/auth/passkey/register/options", nil)
302 if rec.Code != http.StatusUnauthorized {
303 t.Fatalf("status = %d, want 401", rec.Code)
304 }
305}
306
307// The begin endpoints must send the bare WebAuthn JSON options, because
308// @simplewebauthn/browser is passed the body unchanged.
309func TestPasskeyLoginOptionsShape(t *testing.T) {
310 _, h := newTestServer(t)
311 rec := postForm(t, h, "/auth/passkey/login/options", nil)
312 if rec.Code != http.StatusOK {
313 t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
314 }
315 body := rec.Body.String()
316 for _, want := range []string{`"challenge"`, `"rpId":"localhost"`, `"userVerification":"required"`} {
317 if !strings.Contains(body, want) {
318 t.Errorf("body %q is missing %s", body, want)
319 }
320 }
321 if strings.Contains(body, `"publicKey"`) {
322 t.Error("options must not be wrapped in a publicKey object")
323 }
324}
325