package web import ( "bytes" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "webfilebrowser/internal/auth" ) func TestUnauthenticatedRequestsNeverReachProtectedRoutes(t *testing.T) { _, handler := newTestApplication(t) for _, target := range []string{"/", "/files/media", "/download/media/report.pdf", "/stream/media/video.mp4", "/admin/users"} { t.Run(target, func(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil)) if response.Code != http.StatusSeeOther { t.Errorf("GET %s status = %d, want %d", target, response.Code, http.StatusSeeOther) } if location := response.Header().Get("Location"); location != loginPath { t.Errorf("GET %s Location = %q, want %q", target, location, loginPath) } if strings.Contains(response.Body.String(), "File browser is being prepared") { t.Errorf("GET %s returned protected content", target) } }) } } func TestLoginForcesPasswordChangeBeforeAccessingOtherRoutes(t *testing.T) { t.Setenv(insecureCookiesEnvironment, "") _, handler := newTestApplication(t) loginResponse := postForm(handler, loginPath, url.Values{ "email": {auth.SeedAdminEmail}, "password": {auth.SeedAdminPassword}, }, nil) if loginResponse.Code != http.StatusSeeOther { t.Fatalf("POST %s status = %d, want %d", loginPath, loginResponse.Code, http.StatusSeeOther) } if location := loginResponse.Header().Get("Location"); location != changePasswordPath { t.Fatalf("POST %s Location = %q, want %q", loginPath, location, changePasswordPath) } cookie := responseCookie(t, loginResponse, sessionCookieName) if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode { t.Errorf("session cookie security attributes = HttpOnly:%t Secure:%t SameSite:%v, want true:true:Lax", cookie.HttpOnly, cookie.Secure, cookie.SameSite) } if cookie.Expires.IsZero() || cookie.Path != "/" { t.Errorf("default session cookie lifetime/path = Expires:%v Path:%q, want a persistent expiry and /", cookie.Expires, cookie.Path) } for _, target := range []string{"/", "/files/media", "/download/media/report.pdf", "/stream/media/video.mp4", "/admin/users"} { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, target, nil) request.AddCookie(cookie) handler.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != changePasswordPath { t.Errorf("GET %s during forced change = (%d, %q), want (%d, %q)", target, response.Code, response.Header().Get("Location"), http.StatusSeeOther, changePasswordPath) } } samePasswordResponse := postForm(handler, changePasswordPath, url.Values{ "current_password": {auth.SeedAdminPassword}, "new_password": {auth.SeedAdminPassword}, }, cookie) if samePasswordResponse.Code != http.StatusBadRequest { t.Errorf("POST %s with unchanged password status = %d, want %d", changePasswordPath, samePasswordResponse.Code, http.StatusBadRequest) } changedPasswordResponse := postForm(handler, changePasswordPath, url.Values{ "current_password": {auth.SeedAdminPassword}, "new_password": {"A-new-password-123!"}, }, cookie) if changedPasswordResponse.Code != http.StatusSeeOther || changedPasswordResponse.Header().Get("Location") != "/" { t.Errorf("POST %s status/location = (%d, %q), want (%d, %q)", changePasswordPath, changedPasswordResponse.Code, changedPasswordResponse.Header().Get("Location"), http.StatusSeeOther, "/") } rootResponse := httptest.NewRecorder() rootRequest := httptest.NewRequest(http.MethodGet, "/", nil) rootRequest.AddCookie(cookie) handler.ServeHTTP(rootResponse, rootRequest) if rootResponse.Code != http.StatusOK { t.Errorf("GET / after changing password status = %d, want %d", rootResponse.Code, http.StatusOK) } } func TestInsecureCookieOptInSupportsPlainHTTPLogin(t *testing.T) { t.Setenv(insecureCookiesEnvironment, "true") _, handler := newTestApplication(t) server := httptest.NewServer(handler) t.Cleanup(server.Close) jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("cookiejar.New() error = %v", err) } client := &http.Client{ Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } loginResponse, err := client.PostForm(server.URL+loginPath, url.Values{ "email": {auth.SeedAdminEmail}, "password": {auth.SeedAdminPassword}, }) if err != nil { t.Fatalf("POST %s error = %v", loginPath, err) } defer func() { _ = loginResponse.Body.Close() }() if loginResponse.StatusCode != http.StatusSeeOther || loginResponse.Header.Get("Location") != changePasswordPath { t.Fatalf("plain HTTP login = (%d, %q), want (%d, %q)", loginResponse.StatusCode, loginResponse.Header.Get("Location"), http.StatusSeeOther, changePasswordPath) } var sessionCookie *http.Cookie for _, cookie := range loginResponse.Cookies() { if cookie.Name == sessionCookieName { sessionCookie = cookie break } } if sessionCookie == nil { t.Fatalf("plain HTTP login did not set cookie %q", sessionCookieName) } if sessionCookie.Secure || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteLaxMode || sessionCookie.Expires.IsZero() || sessionCookie.Path != "/" { t.Errorf("insecure session cookie attributes = Secure:%t HttpOnly:%t SameSite:%v Expires:%v Path:%q, want false:true:Lax:non-zero:/", sessionCookie.Secure, sessionCookie.HttpOnly, sessionCookie.SameSite, sessionCookie.Expires, sessionCookie.Path) } forcedChangeResponse, err := client.Get(server.URL + "/") if err != nil { t.Fatalf("GET / after plain HTTP login error = %v", err) } defer func() { _ = forcedChangeResponse.Body.Close() }() if forcedChangeResponse.StatusCode != http.StatusSeeOther || forcedChangeResponse.Header.Get("Location") != changePasswordPath { t.Fatalf("GET / after plain HTTP login = (%d, %q), want (%d, %q)", forcedChangeResponse.StatusCode, forcedChangeResponse.Header.Get("Location"), http.StatusSeeOther, changePasswordPath) } changeResponse, err := client.PostForm(server.URL+changePasswordPath, url.Values{ "current_password": {auth.SeedAdminPassword}, "new_password": {"A-new-password-123!"}, }) if err != nil { t.Fatalf("POST %s error = %v", changePasswordPath, err) } defer func() { _ = changeResponse.Body.Close() }() if changeResponse.StatusCode != http.StatusSeeOther || changeResponse.Header.Get("Location") != "/" { t.Fatalf("plain HTTP password change = (%d, %q), want (%d, %q)", changeResponse.StatusCode, changeResponse.Header.Get("Location"), http.StatusSeeOther, "/") } rootResponse, err := client.Get(server.URL + "/") if err != nil { t.Fatalf("GET / after plain HTTP password change error = %v", err) } defer func() { _ = rootResponse.Body.Close() }() if rootResponse.StatusCode != http.StatusOK { t.Errorf("GET / after plain HTTP password change status = %d, want %d", rootResponse.StatusCode, http.StatusOK) } } func TestLogoutDeletesTheServerSession(t *testing.T) { _, handler := newTestApplication(t) cookie := loginSeedAdmin(t, handler) logoutResponse := postForm(handler, logoutPath, nil, cookie) if logoutResponse.Code != http.StatusSeeOther || logoutResponse.Header().Get("Location") != loginPath { t.Fatalf("POST %s status/location = (%d, %q), want (%d, %q)", logoutPath, logoutResponse.Code, logoutResponse.Header().Get("Location"), http.StatusSeeOther, loginPath) } protectedResponse := httptest.NewRecorder() protectedRequest := httptest.NewRequest(http.MethodGet, "/", nil) protectedRequest.AddCookie(cookie) handler.ServeHTTP(protectedResponse, protectedRequest) if protectedResponse.Code != http.StatusSeeOther || protectedResponse.Header().Get("Location") != loginPath { t.Errorf("GET / after logout status/location = (%d, %q), want (%d, %q)", protectedResponse.Code, protectedResponse.Header().Get("Location"), http.StatusSeeOther, loginPath) } } func newTestApplication(t *testing.T) (*auth.Store, http.Handler) { t.Helper() store, err := auth.Open(filepath.Join(t.TempDir(), "webfilebrowser.db"), auth.Options{}) if err != nil { t.Fatalf("Open() error = %v", err) } t.Cleanup(func() { _ = store.Close() }) return store, New(store) } func loginSeedAdmin(t *testing.T, handler http.Handler) *http.Cookie { t.Helper() response := postForm(handler, loginPath, url.Values{ "email": {auth.SeedAdminEmail}, "password": {auth.SeedAdminPassword}, }, nil) if response.Code != http.StatusSeeOther { t.Fatalf("POST %s status = %d, want %d", loginPath, response.Code, http.StatusSeeOther) } return responseCookie(t, response, sessionCookieName) } func postForm(handler http.Handler, target string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { var body io.Reader if values != nil { body = bytes.NewBufferString(values.Encode()) } request := httptest.NewRequest(http.MethodPost, target, body) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") if cookie != nil { request.AddCookie(cookie) } response := httptest.NewRecorder() handler.ServeHTTP(response, request) return response } func responseCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie { t.Helper() for _, cookie := range response.Result().Cookies() { if cookie.Name == name { return cookie } } t.Fatalf("response did not set cookie %q", name) return nil }