auth_test.go
⎇
Raw
1package e2e
2
3import (
4 "net/http"
5 "net/url"
6 "strings"
7 "testing"
8
9 "hearthforge/internal/db"
10)
11
12func 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
79func 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
111func 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