package e2e import ( "net/http" "net/url" "strings" "testing" "hearthforge/internal/db" ) func TestAuth(t *testing.T) { e := newEnv(t) t.Run("homepage loads", func(t *testing.T) { r := e.anon().get("/").mustStatus(200) if !strings.Contains(r.Text("title"), "Hearthforge") { t.Errorf("title = %q", r.Text("title")) } }) t.Run("wrong password shows error", func(t *testing.T) { r := e.anon().post("/login", url.Values{"username": {"admin"}, "password": {"wrongpassword"}}) if !strings.Contains(r.Text(".form-error"), "Invalid") { t.Errorf("error = %q", r.Text(".form-error")) } }) t.Run("correct credentials redirect to homepage", func(t *testing.T) { s := e.admin() if !s.get("/").Has(".nav-user") { t.Error("nav-user missing after login") } }) t.Run("register new user", func(t *testing.T) { s := e.register("alice", "password123") if got := s.get("/").Text(".nav-user"); got != "alice" { t.Errorf("nav-user = %q", got) } }) t.Run("register with mismatched passwords shows error", func(t *testing.T) { r := e.anon().post("/register", url.Values{ "username": {"bob"}, "password": {"password123"}, "password2": {"different456"}, }) if !strings.Contains(r.Text(".form-error"), "match") { t.Errorf("error = %q", r.Text(".form-error")) } }) t.Run("register with duplicate username shows error", func(t *testing.T) { r := e.anon().post("/register", url.Values{ "username": {"alice"}, "password": {"password123"}, "password2": {"password123"}, }) if !strings.Contains(r.Text(".form-error"), "taken") { t.Errorf("error = %q", r.Text(".form-error")) } }) t.Run("cross-origin POST is rejected", func(t *testing.T) { e.anon().post("/login", url.Values{"username": {"admin"}, "password": {adminPass}}, "Origin", "http://evil.example").mustStatus(http.StatusForbidden) }) t.Run("logout clears session", func(t *testing.T) { s := e.admin() s.post("/logout", nil).mustRedirect("/") r := s.get("/") if r.Has(".nav-user") { t.Error("nav-user still shown after logout") } if !r.Has(`a[href="/login"]`) { t.Error("sign-in link missing after logout") } }) } func TestCSRFDevMode(t *testing.T) { e := newEnv(t) bad := url.Values{"username": {"admin"}, "password": {"wrong"}} t.Run("no HSTS header", func(t *testing.T) { if v := e.anon().get("/health").Header.Get("Strict-Transport-Security"); v != "" { t.Errorf("HSTS = %q", v) } }) t.Run("POST with no Origin is allowed", func(t *testing.T) { if r := e.anon().post("/login", bad); r.Code == 403 { t.Error("rejected") } }) t.Run("POST with same-origin Origin is allowed", func(t *testing.T) { if r := e.anon().post("/login", bad, "Origin", e.Base); r.Code == 403 { t.Error("rejected") } }) t.Run("POST with mismatched Origin is rejected", func(t *testing.T) { e.anon().post("/login", bad, "Origin", "http://attacker.example").mustStatus(403) }) t.Run("login Set-Cookie omits Secure", func(t *testing.T) { r := e.anon().post("/login", url.Values{"username": {db.AdminUsername}, "password": {adminPass}}) r.mustRedirect("/") c := r.Header.Get("Set-Cookie") if !strings.Contains(c, "session=") || strings.Contains(c, "Secure") { t.Errorf("Set-Cookie = %q", c) } }) } func TestCSRFHTTPSMode(t *testing.T) { // The client still talks plain HTTP to localhost. The app trusts BASE_URL, // not the transport of the proxy hop. e := newEnv(t, "BASE_URL", "https://forge.test") bad := url.Values{"username": {"admin"}, "password": {"wrong"}} t.Run("POST with no Origin is allowed", func(t *testing.T) { if r := e.anon().post("/login", bad); r.Code == 403 { t.Error("rejected") } }) t.Run("POST with matching public Origin is allowed", func(t *testing.T) { if r := e.anon().post("/login", bad, "Origin", "https://forge.test"); r.Code == 403 { t.Error("rejected") } }) t.Run("Origin matching only Host is rejected", func(t *testing.T) { e.anon().post("/login", bad, "Origin", e.Base).mustStatus(403) }) t.Run("attacker Origin is rejected", func(t *testing.T) { e.anon().post("/login", bad, "Origin", "https://attacker.example").mustStatus(403) }) t.Run("login Set-Cookie includes Secure", func(t *testing.T) { r := e.anon().post("/login", url.Values{"username": {db.AdminUsername}, "password": {adminPass}}, "Origin", "https://forge.test") r.mustRedirect("/") c := r.Header.Get("Set-Cookie") if !strings.Contains(c, "session=") || !strings.Contains(c, "Secure") { t.Errorf("Set-Cookie = %q", c) } }) t.Run("responses include HSTS", func(t *testing.T) { if v := e.anon().get("/health").Header.Get("Strict-Transport-Security"); v != "max-age=31536000; includeSubDomains" { t.Errorf("HSTS = %q", v) } }) }