package web import ( "context" "crypto/ed25519" "crypto/rand" "io" "io/fs" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "github.com/go-chi/chi/v5" gossh "golang.org/x/crypto/ssh" hearthforge "hearthforge" "hearthforge/internal/config" "hearthforge/internal/db" ) const testAdminPassword = "correct-horse" // newTestServer builds a server on a throwaway database with the admin // account already created. func newTestServer(t *testing.T) (*Server, http.Handler) { t.Helper() dir := t.TempDir() database, err := db.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { database.Close() }) if _, err := database.InitAdmin(context.Background(), testAdminPassword); err != nil { t.Fatalf("init admin: %v", err) } s := &Server{ DB: database, Cfg: &config.Config{ DataDir: dir, OwnerDisplayName: "Owner", BaseURL: "http://localhost:3000", PublicOrigin: "http://localhost:3000", RegistrationType: "enabled", MaxUsernameBytes: 64, MaxPasswordBytes: 1024, MaxTextBodyBytes: 100000, MaxUserUploadBytes: 2 << 20, RateLimitDisabled: true, }, } r := chi.NewRouter() r.Use(s.withSession) static, _ := fs.Sub(hearthforge.StaticFS, "web/static") r.Handle("/assets/*", http.FileServerFS(static)) s.authRoutes(r) s.settingsRoutes(r) s.avatarRoutes(r) return s, r } // postForm sends a form request with the given cookies. func postForm(t *testing.T, h http.Handler, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // loginAsAdmin signs in and returns the session cookie. func loginAsAdmin(t *testing.T, h http.Handler) *http.Cookie { t.Helper() rec := postForm(t, h, "/login", url.Values{ "username": {db.AdminUsername}, "password": {testAdminPassword}, }) if rec.Code != http.StatusFound { t.Fatalf("login status = %d, want 302", rec.Code) } for _, c := range rec.Result().Cookies() { if c.Name == sessionCookie && c.Value != "" { return c } } t.Fatal("login set no session cookie") return nil } func TestLoginPage(t *testing.T) { _, h := newTestServer(t) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "Sign in with password") { t.Error("login form is missing") } } func TestLoginWrongPassword(t *testing.T) { _, h := newTestServer(t) rec := postForm(t, h, "/login", url.Values{ "username": {db.AdminUsername}, "password": {"wrong"}, }) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "Invalid username or password") { t.Error("error message is missing") } if len(rec.Result().Cookies()) != 0 { t.Error("a failed login must not set a cookie") } } func TestLoginSuccessAndSettings(t *testing.T) { _, h := newTestServer(t) cookie := loginAsAdmin(t, h) req := httptest.NewRequest(http.MethodGet, "/settings", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("settings status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "SSH Keys") { t.Error("settings page is missing the SSH key section") } } func TestAddSSHKeyStoresFingerprint(t *testing.T) { s, h := newTestServer(t) cookie := loginAsAdmin(t, h) pub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } sshPub, err := gossh.NewPublicKey(pub) if err != nil { t.Fatal(err) } line := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub))) + " me@host" rec := postForm(t, h, "/settings/ssh-keys", url.Values{ "name": {"My Laptop"}, "public_key": {line}, }, cookie) if got := rec.Header().Get("Location"); !strings.Contains(got, "success=ssh_key_added") { t.Fatalf("Location = %q, body = %q", got, rec.Body.String()) } keys, err := s.DB.ListSSHKeys(context.Background(), 1) if err != nil { t.Fatal(err) } if len(keys) != 1 { t.Fatalf("stored %d keys, want 1", len(keys)) } parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keys[0].PublicKey)) if err != nil { t.Fatal(err) } if want := gossh.FingerprintSHA256(parsed); keys[0].Fingerprint != want { t.Errorf("fingerprint = %q, want %q", keys[0].Fingerprint, want) } if keys[0].Name != "My Laptop" { t.Errorf("name = %q", keys[0].Name) } } func TestAddSSHKeyRejectsBadType(t *testing.T) { _, h := newTestServer(t) cookie := loginAsAdmin(t, h) rec := postForm(t, h, "/settings/ssh-keys", url.Values{ "name": {"bad"}, "public_key": {"ssh-dss AAAAB3NzaC1kc3M="}, }, cookie) if got := rec.Header().Get("Location"); !strings.Contains(got, "error=") { t.Fatalf("Location = %q", got) } } func TestAvatarRoutes(t *testing.T) { _, h := newTestServer(t) // The admin avatar is written on demand, and only with a version query. rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/1.png", nil)) if rec.Code != http.StatusNotFound { t.Errorf("status without ?v = %d, want 404", rec.Code) } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/1.png?v=1", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "image/png" { t.Errorf("Content-Type = %q", ct) } if !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") { t.Errorf("Cache-Control = %q", rec.Header().Get("Cache-Control")) } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/avatars/999.png?v=1", nil)) if rec.Code != http.StatusNotFound { t.Errorf("unknown user status = %d, want 404", rec.Code) } } // The literal /assets/code-theme.css must win over the /assets/* static file // handler that server.go mounts first. func TestCodeThemeBeatsStaticMount(t *testing.T) { _, h := newTestServer(t) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/code-theme.css", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") { t.Errorf("Content-Type = %q", ct) } if !strings.Contains(rec.Body.String(), ".ch-k {") { t.Error("body is not the generated chroma stylesheet") } // The static mount still answers for a real file. rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/theme.js", nil)) if rec.Code != http.StatusOK { t.Errorf("static asset status = %d, want 200", rec.Code) } } func TestRegisterCreatesSessionAndAvatar(t *testing.T) { s, h := newTestServer(t) rec := postForm(t, h, "/register", url.Values{ "username": {"newbie"}, "password": {"hunter2hunter2"}, "password2": {"hunter2hunter2"}, }) if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302, body %q", rec.Code, rec.Body.String()) } user, err := s.DB.UserByName(context.Background(), "newbie") if err != nil || user == nil { t.Fatalf("user not created: %v", err) } req := httptest.NewRequest(http.MethodGet, "/avatars/2.png?v=1", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("default avatar status = %d, want 200", rr.Code) } if body, _ := io.ReadAll(rr.Body); len(body) == 0 { t.Error("default avatar is empty") } } func TestRegisterRejectsShortPassword(t *testing.T) { _, h := newTestServer(t) rec := postForm(t, h, "/register", url.Values{ "username": {"shorty"}, "password": {"abc"}, "password2": {"abc"}, }) if !strings.Contains(rec.Body.String(), "at least 8 characters") { t.Errorf("missing error, body = %q", rec.Body.String()) } } func TestSettingsRequiresAuth(t *testing.T) { _, h := newTestServer(t) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil)) if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302", rec.Code) } if got := rec.Header().Get("Location"); got != "/login" { t.Errorf("Location = %q", got) } } func TestPasskeyRegisterOptionsNeedsSession(t *testing.T) { _, h := newTestServer(t) rec := postForm(t, h, "/auth/passkey/register/options", nil) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } } // The begin endpoints must send the bare WebAuthn JSON options, because // @simplewebauthn/browser is passed the body unchanged. func TestPasskeyLoginOptionsShape(t *testing.T) { _, h := newTestServer(t) rec := postForm(t, h, "/auth/passkey/login/options", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{`"challenge"`, `"rpId":"localhost"`} { if !strings.Contains(body, want) { t.Errorf("body %q is missing %s", body, want) } } if strings.Contains(body, `"publicKey"`) { t.Error("options must not be wrapped in a publicKey object") } }