summaryrefslogtreecommitdiff
path: root/password_login_test.go
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-06-26 11:42:17 -0600
committerT <t@tjp.lol>2025-07-01 17:50:49 -0600
commit639ad6a02cbb4b713434671ec09f309aa5410921 (patch)
tree7dde9cce8136636d11f2f7c961072984cfc705e7 /password_login_test.go
Create authentic_kate: user authentication for go HTTP applications
Diffstat (limited to 'password_login_test.go')
-rw-r--r--password_login_test.go385
1 files changed, 385 insertions, 0 deletions
diff --git a/password_login_test.go b/password_login_test.go
new file mode 100644
index 0000000..eec1ba7
--- /dev/null
+++ b/password_login_test.go
@@ -0,0 +1,385 @@
+package kate
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// Mock implementation of PasswordUserDataStore for testing
+type mockPasswordUserDataStore[T any] struct {
+ users map[string]T
+ getHash func(T) string
+}
+
+func newMockPasswordUserDataStore[T any](users map[string]T, getHash func(T) string) *mockPasswordUserDataStore[T] {
+ return &mockPasswordUserDataStore[T]{
+ users: users,
+ getHash: getHash,
+ }
+}
+
+func (m *mockPasswordUserDataStore[T]) Fetch(username string) (T, bool, error) {
+ user, exists := m.users[username]
+ return user, exists, nil
+}
+
+func (m *mockPasswordUserDataStore[T]) GetPassHash(userData T) string {
+ return m.getHash(userData)
+}
+
+func TestPasswordLoginHandler(t *testing.T) {
+ auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{
+ SerDes: testUserSerDes{},
+ CookieName: "test_session",
+ })
+
+ testHash, err := HashPassword("password123", nil)
+ if err != nil {
+ t.Fatalf("Failed to hash password: %v", err)
+ }
+
+ users := map[string]testUser{
+ "john": {Username: "john", Hash: testHash, ID: 1},
+ "jane": {Username: "jane", Hash: testHash, ID: 2},
+ }
+
+ mockStore := newMockPasswordUserDataStore(users, func(user testUser) string {
+ return user.Hash
+ })
+
+ config := PasswordLoginConfig[testUser]{
+ UserData: mockStore,
+ Redirects: Redirects{
+ Default: "/dashboard",
+ AllowedPrefixes: []string{"/app/", "/admin/"},
+ FieldName: "redirect",
+ },
+ }
+
+ handler := auth.PasswordLoginHandler(config)
+
+ tests := []struct {
+ name string
+ method string
+ formData url.Values
+ expectedStatus int
+ expectedRedirect string
+ checkCookie bool
+ }{
+ {
+ name: "successful login",
+ method: "POST",
+ formData: url.Values{"username": {"john"}, "password": {"password123"}},
+ expectedStatus: http.StatusSeeOther,
+ expectedRedirect: "/dashboard",
+ checkCookie: true,
+ },
+ {
+ name: "successful login with redirect",
+ method: "POST",
+ formData: url.Values{"username": {"john"}, "password": {"password123"}, "redirect": {"/app/settings"}},
+ expectedStatus: http.StatusSeeOther,
+ expectedRedirect: "/app/settings",
+ checkCookie: true,
+ },
+ {
+ name: "invalid redirect falls back to default",
+ method: "POST",
+ formData: url.Values{"username": {"john"}, "password": {"password123"}, "redirect": {"/evil/"}},
+ expectedStatus: http.StatusSeeOther,
+ expectedRedirect: "/dashboard",
+ checkCookie: true,
+ },
+ {
+ name: "wrong password",
+ method: "POST",
+ formData: url.Values{"username": {"john"}, "password": {"wrongpass"}},
+ expectedStatus: http.StatusUnauthorized,
+ checkCookie: false,
+ },
+ {
+ name: "nonexistent user",
+ method: "POST",
+ formData: url.Values{"username": {"nobody"}, "password": {"password123"}},
+ expectedStatus: http.StatusUnauthorized,
+ checkCookie: false,
+ },
+ {
+ name: "missing username",
+ method: "POST",
+ formData: url.Values{"password": {"password123"}},
+ expectedStatus: http.StatusBadRequest,
+ checkCookie: false,
+ },
+ {
+ name: "missing password",
+ method: "POST",
+ formData: url.Values{"username": {"john"}},
+ expectedStatus: http.StatusBadRequest,
+ checkCookie: false,
+ },
+ {
+ name: "GET method not allowed",
+ method: "GET",
+ formData: url.Values{},
+ expectedStatus: http.StatusMethodNotAllowed,
+ checkCookie: false,
+ },
+ {
+ name: "PUT method not allowed",
+ method: "PUT",
+ formData: url.Values{},
+ expectedStatus: http.StatusMethodNotAllowed,
+ checkCookie: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var req *http.Request
+
+ if tt.method == "POST" {
+ req = httptest.NewRequest(tt.method, "/login", strings.NewReader(tt.formData.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ } else {
+ req = httptest.NewRequest(tt.method, "/login", nil)
+ }
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != tt.expectedStatus {
+ t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
+ }
+
+ if tt.expectedRedirect != "" {
+ location := rr.Header().Get("Location")
+ if location != tt.expectedRedirect {
+ t.Errorf("expected redirect to %s, got %s", tt.expectedRedirect, location)
+ }
+ }
+
+ if tt.checkCookie {
+ cookies := rr.Result().Cookies()
+ found := false
+ for _, cookie := range cookies {
+ if cookie.Name == "test_session" && cookie.Value != "" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("expected authentication cookie to be set")
+ }
+ }
+ })
+ }
+}
+
+func TestPasswordLoginHandlerCustomFields(t *testing.T) {
+ auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{
+ SerDes: testUserSerDes{},
+ CookieName: "test_session",
+ })
+
+ testHash, err := HashPassword("secret", nil)
+ if err != nil {
+ t.Fatalf("Failed to hash password: %v", err)
+ }
+
+ users := map[string]testUser{
+ "admin": {Username: "admin", Hash: testHash, ID: 99},
+ }
+
+ mockStore := newMockPasswordUserDataStore(users, func(user testUser) string {
+ return user.Hash
+ })
+
+ config := PasswordLoginConfig[testUser]{
+ UserData: mockStore,
+ UsernameField: "email",
+ PasswordField: "pass",
+ Redirects: Redirects{
+ FieldName: "next",
+ Default: "/home",
+ },
+ }
+
+ handler := auth.PasswordLoginHandler(config)
+
+ formData := url.Values{
+ "email": {"admin"},
+ "pass": {"secret"},
+ "next": {"/custom"},
+ }
+
+ req := httptest.NewRequest("POST", "/login", strings.NewReader(formData.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusSeeOther {
+ t.Errorf("expected status %d, got %d", http.StatusSeeOther, rr.Code)
+ }
+
+ location := rr.Header().Get("Location")
+ if location != "/custom" {
+ t.Errorf("expected redirect to /custom, got %s", location)
+ }
+}
+
+func TestPasswordLoginHandlerParseFormError(t *testing.T) {
+ auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{
+ SerDes: testUserSerDes{},
+ CookieName: "test_session",
+ })
+
+ users := map[string]testUser{
+ "test": {Username: "test", Hash: "", ID: 1},
+ }
+
+ mockStore := newMockPasswordUserDataStore(users, func(user testUser) string {
+ return user.Hash
+ })
+
+ config := PasswordLoginConfig[testUser]{
+ UserData: mockStore,
+ }
+
+ handler := auth.PasswordLoginHandler(config)
+
+ req := httptest.NewRequest("POST", "/login", strings.NewReader("username=%"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
+ }
+}
+
+func TestPasswordLoginHandlerGetPassHashError(t *testing.T) {
+ auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{
+ SerDes: testUserSerDes{},
+ CookieName: "test_session",
+ })
+
+ users := map[string]testUser{
+ "user": {Username: "user", Hash: "", ID: 1},
+ }
+
+ mockStore := newMockPasswordUserDataStore(users, func(user testUser) string {
+ return user.Hash // Empty hash will cause password comparison to fail
+ })
+
+ config := PasswordLoginConfig[testUser]{
+ UserData: mockStore,
+ }
+
+ handler := auth.PasswordLoginHandler(config)
+
+ formData := url.Values{"username": {"user"}, "password": {"pass"}}
+ req := httptest.NewRequest("POST", "/login", strings.NewReader(formData.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusUnauthorized {
+ t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
+ }
+}
+
+func TestPasswordLoginHandlerMalformedHash(t *testing.T) {
+ auth := New("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", AuthConfig[testUser]{
+ SerDes: testUserSerDes{},
+ CookieName: "test_session",
+ })
+
+ users := map[string]testUser{
+ "user": {Username: "user", Hash: "invalid-hash", ID: 1},
+ }
+
+ mockStore := newMockPasswordUserDataStore(users, func(user testUser) string {
+ return user.Hash
+ })
+
+ config := PasswordLoginConfig[testUser]{
+ UserData: mockStore,
+ }
+
+ handler := auth.PasswordLoginHandler(config)
+
+ formData := url.Values{"username": {"user"}, "password": {"pass"}}
+ req := httptest.NewRequest("POST", "/login", strings.NewReader(formData.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusUnauthorized {
+ t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
+ }
+}
+
+func TestRedirectsIsValid(t *testing.T) {
+ tests := []struct {
+ name string
+ target string
+ allowedPrefixes []string
+ expected bool
+ }{
+ {
+ name: "no restrictions allows anything",
+ target: "/anything",
+ allowedPrefixes: []string{},
+ expected: true,
+ },
+ {
+ name: "valid prefix match",
+ target: "/app/dashboard",
+ allowedPrefixes: []string{"/app/", "/admin/"},
+ expected: true,
+ },
+ {
+ name: "invalid prefix",
+ target: "/evil/",
+ allowedPrefixes: []string{"/app/", "/admin/"},
+ expected: false,
+ },
+ {
+ name: "absolute URL rejected",
+ target: "https://evil.com/",
+ allowedPrefixes: []string{"/app/"},
+ expected: false,
+ },
+ {
+ name: "protocol relative URL rejected",
+ target: "//evil.com/",
+ allowedPrefixes: []string{"/app/"},
+ expected: false,
+ },
+ {
+ name: "invalid URL rejected",
+ target: ":/invalid",
+ allowedPrefixes: []string{"/app/"},
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ redirects := Redirects{AllowedPrefixes: tt.allowedPrefixes}
+ result := redirects.isValid(tt.target)
+ if result != tt.expected {
+ t.Errorf("Redirects{AllowedPrefixes: %v}.isValid(%q) = %v, want %v", tt.allowedPrefixes, tt.target, result, tt.expected)
+ }
+ })
+ }
+}
+