auth_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "hearthforge/internal/db" |
| 10 | ) |
| 11 | |
| 12 | func TestAuth(t *testing.T) { |
| 13 | e := newEnv(t) |
| 14 | |
| 15 | t.Run("homepage loads", func(t *testing.T) { |
| 16 | r := e.anon().get("/").mustStatus(200) |
| 17 | if !strings.Contains(r.Text("title"), "Hearthforge") { |
| 18 | t.Errorf("title = %q", r.Text("title")) |
| 19 | } |
| 20 | }) |
| 21 | |
| 22 | t.Run("wrong password shows error", func(t *testing.T) { |
| 23 | r := e.anon().post("/login", url.Values{"username": {"admin"}, "password": {"wrongpassword"}}) |
| 24 | if !strings.Contains(r.Text(".form-error"), "Invalid") { |
| 25 | t.Errorf("error = %q", r.Text(".form-error")) |
| 26 | } |
| 27 | }) |
| 28 | |
| 29 | t.Run("correct credentials redirect to homepage", func(t *testing.T) { |
| 30 | s := e.admin() |
| 31 | if !s.get("/").Has(".nav-user") { |
| 32 | t.Error("nav-user missing after login") |
| 33 | } |
| 34 | }) |
| 35 | |
| 36 | t.Run("register new user", func(t *testing.T) { |
| 37 | s := e.register("alice", "password123") |
| 38 | if got := s.get("/").Text(".nav-user"); got != "alice" { |
| 39 | t.Errorf("nav-user = %q", got) |
| 40 | } |
| 41 | }) |
| 42 | |
| 43 | t.Run("register with mismatched passwords shows error", func(t *testing.T) { |
| 44 | r := e.anon().post("/register", url.Values{ |
| 45 | "username": {"bob"}, "password": {"password123"}, "password2": {"different456"}, |
| 46 | }) |
| 47 | if !strings.Contains(r.Text(".form-error"), "match") { |
| 48 | t.Errorf("error = %q", r.Text(".form-error")) |
| 49 | } |
| 50 | }) |
| 51 | |
| 52 | t.Run("register with duplicate username shows error", func(t *testing.T) { |
| 53 | r := e.anon().post("/register", url.Values{ |
| 54 | "username": {"alice"}, "password": {"password123"}, "password2": {"password123"}, |
| 55 | }) |
| 56 | if !strings.Contains(r.Text(".form-error"), "taken") { |
| 57 | t.Errorf("error = %q", r.Text(".form-error")) |
| 58 | } |
| 59 | }) |
| 60 | |
| 61 | t.Run("cross-origin POST is rejected", func(t *testing.T) { |
| 62 | e.anon().post("/login", url.Values{"username": {"admin"}, "password": {adminPass}}, |
| 63 | "Origin", "http://evil.example").mustStatus(http.StatusForbidden) |
| 64 | }) |
| 65 | |
| 66 | t.Run("logout clears session", func(t *testing.T) { |
| 67 | s := e.admin() |
| 68 | s.post("/logout", nil).mustRedirect("/") |
| 69 | r := s.get("/") |
| 70 | if r.Has(".nav-user") { |
| 71 | t.Error("nav-user still shown after logout") |
| 72 | } |
| 73 | if !r.Has(`a[href="/login"]`) { |
| 74 | t.Error("sign-in link missing after logout") |
| 75 | } |
| 76 | }) |
| 77 | } |
| 78 | |
| 79 | func TestCSRFDevMode(t *testing.T) { |
| 80 | e := newEnv(t) |
| 81 | bad := url.Values{"username": {"admin"}, "password": {"wrong"}} |
| 82 | |
| 83 | t.Run("no HSTS header", func(t *testing.T) { |
| 84 | if v := e.anon().get("/health").Header.Get("Strict-Transport-Security"); v != "" { |
| 85 | t.Errorf("HSTS = %q", v) |
| 86 | } |
| 87 | }) |
| 88 | t.Run("POST with no Origin is allowed", func(t *testing.T) { |
| 89 | if r := e.anon().post("/login", bad); r.Code == 403 { |
| 90 | t.Error("rejected") |
| 91 | } |
| 92 | }) |
| 93 | t.Run("POST with same-origin Origin is allowed", func(t *testing.T) { |
| 94 | if r := e.anon().post("/login", bad, "Origin", e.Base); r.Code == 403 { |
| 95 | t.Error("rejected") |
| 96 | } |
| 97 | }) |
| 98 | t.Run("POST with mismatched Origin is rejected", func(t *testing.T) { |
| 99 | e.anon().post("/login", bad, "Origin", "http://attacker.example").mustStatus(403) |
| 100 | }) |
| 101 | t.Run("login Set-Cookie omits Secure", func(t *testing.T) { |
| 102 | r := e.anon().post("/login", url.Values{"username": {db.AdminUsername}, "password": {adminPass}}) |
| 103 | r.mustRedirect("/") |
| 104 | c := r.Header.Get("Set-Cookie") |
| 105 | if !strings.Contains(c, "session=") || strings.Contains(c, "Secure") { |
| 106 | t.Errorf("Set-Cookie = %q", c) |
| 107 | } |
| 108 | }) |
| 109 | } |
| 110 | |
| 111 | func TestCSRFHTTPSMode(t *testing.T) { |
| 112 | // The client still talks plain HTTP to localhost. The app trusts BASE_URL, |
| 113 | // not the transport of the proxy hop. |
| 114 | e := newEnv(t, "BASE_URL", "https://forge.test") |
| 115 | bad := url.Values{"username": {"admin"}, "password": {"wrong"}} |
| 116 | |
| 117 | t.Run("POST with no Origin is allowed", func(t *testing.T) { |
| 118 | if r := e.anon().post("/login", bad); r.Code == 403 { |
| 119 | t.Error("rejected") |
| 120 | } |
| 121 | }) |
| 122 | t.Run("POST with matching public Origin is allowed", func(t *testing.T) { |
| 123 | if r := e.anon().post("/login", bad, "Origin", "https://forge.test"); r.Code == 403 { |
| 124 | t.Error("rejected") |
| 125 | } |
| 126 | }) |
| 127 | t.Run("Origin matching only Host is rejected", func(t *testing.T) { |
| 128 | e.anon().post("/login", bad, "Origin", e.Base).mustStatus(403) |
| 129 | }) |
| 130 | t.Run("attacker Origin is rejected", func(t *testing.T) { |
| 131 | e.anon().post("/login", bad, "Origin", "https://attacker.example").mustStatus(403) |
| 132 | }) |
| 133 | t.Run("login Set-Cookie includes Secure", func(t *testing.T) { |
| 134 | r := e.anon().post("/login", url.Values{"username": {db.AdminUsername}, "password": {adminPass}}, |
| 135 | "Origin", "https://forge.test") |
| 136 | r.mustRedirect("/") |
| 137 | c := r.Header.Get("Set-Cookie") |
| 138 | if !strings.Contains(c, "session=") || !strings.Contains(c, "Secure") { |
| 139 | t.Errorf("Set-Cookie = %q", c) |
| 140 | } |
| 141 | }) |
| 142 | t.Run("responses include HSTS", func(t *testing.T) { |
| 143 | if v := e.anon().get("/health").Header.Get("Strict-Transport-Security"); v != "max-age=31536000; includeSubDomains" { |
| 144 | t.Errorf("HSTS = %q", v) |
| 145 | } |
| 146 | }) |
| 147 | } |
| 148 |