aboutsummaryrefslogtreecommitdiffstats
path: root/web/web_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/web_test.go')
-rw-r--r--web/web_test.go647
1 files changed, 647 insertions, 0 deletions
diff --git a/web/web_test.go b/web/web_test.go
new file mode 100644
index 0000000..1a83798
--- /dev/null
+++ b/web/web_test.go
@@ -0,0 +1,647 @@
+package web
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "adammathes.com/neko/config"
+ "adammathes.com/neko/models"
+ "golang.org/x/crypto/bcrypt"
+)
+
+func setupTestDB(t *testing.T) {
+ t.Helper()
+ config.Config.DBFile = ":memory:"
+ models.InitDB()
+ t.Cleanup(func() {
+ if models.DB != nil {
+ models.DB.Close()
+ }
+ })
+}
+
+func seedData(t *testing.T) {
+ t.Helper()
+ _, err := models.DB.Exec("INSERT INTO feed(url, web_url, title, category) VALUES(?, ?, ?, ?)",
+ "https://example.com/feed", "https://example.com", "Example Feed", "tech")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = models.DB.Exec(`INSERT INTO item(title, url, description, publish_date, feed_id, read_state, starred)
+ VALUES(?, ?, ?, ?, ?, ?, ?)`,
+ "Test Item", "https://example.com/item1", "Description", "2024-01-01 00:00:00", 1, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func authCookie() *http.Cookie {
+ hash, _ := bcrypt.GenerateFromPassword([]byte("secret"), 0)
+ return &http.Cookie{Name: AuthCookie, Value: string(hash)}
+}
+
+// --- Authentication tests ---
+
+func TestAuthenticatedNoCookie(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ if Authenticated(req) {
+ t.Error("Should not be authenticated without cookie")
+ }
+}
+
+func TestAuthenticatedBadCookie(t *testing.T) {
+ config.Config.DigestPassword = "secret"
+ req := httptest.NewRequest("GET", "/", nil)
+ req.AddCookie(&http.Cookie{Name: AuthCookie, Value: "badvalue"})
+ if Authenticated(req) {
+ t.Error("Should not be authenticated with bad cookie")
+ }
+}
+
+func TestAuthenticatedValidCookie(t *testing.T) {
+ config.Config.DigestPassword = "secret"
+ hash, _ := bcrypt.GenerateFromPassword([]byte("secret"), 0)
+ req := httptest.NewRequest("GET", "/", nil)
+ req.AddCookie(&http.Cookie{Name: AuthCookie, Value: string(hash)})
+ if !Authenticated(req) {
+ t.Error("Should be authenticated with valid cookie")
+ }
+}
+
+func TestAuthenticatedWrongPassword(t *testing.T) {
+ config.Config.DigestPassword = "secret"
+ hash, _ := bcrypt.GenerateFromPassword([]byte("wrongpassword"), 0)
+ req := httptest.NewRequest("GET", "/", nil)
+ req.AddCookie(&http.Cookie{Name: AuthCookie, Value: string(hash)})
+ if Authenticated(req) {
+ t.Error("Should not be authenticated with wrong password hash")
+ }
+}
+
+// --- AuthWrap tests ---
+
+func TestAuthWrapUnauthenticated(t *testing.T) {
+ config.Config.DigestPassword = "secret"
+ handler := AuthWrap(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ req := httptest.NewRequest("GET", "/", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusTemporaryRedirect {
+ t.Errorf("Expected 307, got %d", rr.Code)
+ }
+}
+
+func TestAuthWrapAuthenticated(t *testing.T) {
+ config.Config.DigestPassword = "secret"
+ called := false
+ handler := AuthWrap(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ })
+ req := httptest.NewRequest("GET", "/", nil)
+ req.AddCookie(authCookie())
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if !called {
+ t.Error("Wrapped handler should be called")
+ }
+}
+
+// --- Login/Logout tests ---
+
+func TestLoginHandlerPostSuccess(t *testing.T) {
+ config.Config.DigestPassword = "testpass"
+ req := httptest.NewRequest("POST", "/login/", nil)
+ req.Form = map[string][]string{"password": {"testpass"}}
+ rr := httptest.NewRecorder()
+ loginHandler(rr, req)
+ if rr.Code != http.StatusTemporaryRedirect {
+ t.Errorf("Expected 307, got %d", rr.Code)
+ }
+}
+
+func TestLoginHandlerPostFail(t *testing.T) {
+ config.Config.DigestPassword = "testpass"
+ req := httptest.NewRequest("POST", "/login/", nil)
+ req.Form = map[string][]string{"password": {"wrongpass"}}
+ rr := httptest.NewRecorder()
+ loginHandler(rr, req)
+ if rr.Code != http.StatusUnauthorized {
+ t.Errorf("Expected 401, got %d", rr.Code)
+ }
+}
+
+func TestLoginHandlerBadMethod(t *testing.T) {
+ req := httptest.NewRequest("DELETE", "/login/", nil)
+ rr := httptest.NewRecorder()
+ loginHandler(rr, req)
+ if rr.Code != http.StatusInternalServerError {
+ t.Errorf("Expected 500, got %d", rr.Code)
+ }
+}
+
+func TestLogoutHandler(t *testing.T) {
+ req := httptest.NewRequest("GET", "/logout/", nil)
+ rr := httptest.NewRecorder()
+ logoutHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+ cookies := rr.Result().Cookies()
+ found := false
+ for _, c := range cookies {
+ if c.Name == AuthCookie {
+ found = true
+ if c.MaxAge != 0 {
+ t.Errorf("Logout should set MaxAge=0, got %d", c.MaxAge)
+ }
+ }
+ }
+ if !found {
+ t.Error("Logout should set auth cookie")
+ }
+ if rr.Body.String() != "you are logged out" {
+ t.Errorf("Expected logout message, got %q", rr.Body.String())
+ }
+}
+
+// --- Stream handler tests ---
+
+func TestStreamHandler(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+ config.Config.DigestPassword = "secret"
+
+ req := httptest.NewRequest("GET", "/stream/", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+
+ contentType := rr.Header().Get("Content-Type")
+ if contentType != "application/json" {
+ t.Errorf("Expected application/json, got %q", contentType)
+ }
+
+ var items []interface{}
+ err := json.Unmarshal(rr.Body.Bytes(), &items)
+ if err != nil {
+ t.Fatalf("Response should be valid JSON: %v", err)
+ }
+ if len(items) != 1 {
+ t.Errorf("Expected 1 item, got %d", len(items))
+ }
+}
+
+func TestStreamHandlerWithMaxId(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/stream/?max_id=999", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestStreamHandlerWithFeedUrl(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/stream/?feed_url=https://example.com/feed", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestStreamHandlerWithTag(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/stream/?tag=tech", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestStreamHandlerWithReadFilter(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/stream/?read_filter=all", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestStreamHandlerWithStarred(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/stream/?starred=1", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestStreamHandlerWithSearch(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/stream/?q=test", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+ // Search may or may not find results, but should not error
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Item handler tests ---
+
+func TestItemHandlerPut(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ body := `{"_id":"1","read":true,"starred":true}`
+ req := httptest.NewRequest("PUT", "/item/1", strings.NewReader(body))
+ rr := httptest.NewRecorder()
+ itemHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestItemHandlerPutBadJSON(t *testing.T) {
+ setupTestDB(t)
+
+ req := httptest.NewRequest("PUT", "/item/1", strings.NewReader("not json"))
+ rr := httptest.NewRecorder()
+ itemHandler(rr, req)
+ // Should not crash
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200 (error handled internally), got %d", rr.Code)
+ }
+}
+
+// --- Feed handler tests ---
+
+func TestFeedHandlerGet(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/feed/", nil)
+ rr := httptest.NewRecorder()
+ feedHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+
+ var feeds []interface{}
+ err := json.Unmarshal(rr.Body.Bytes(), &feeds)
+ if err != nil {
+ t.Fatalf("Response should be valid JSON: %v", err)
+ }
+ if len(feeds) != 1 {
+ t.Errorf("Expected 1 feed, got %d", len(feeds))
+ }
+}
+
+func TestFeedHandlerPut(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ body := `{"_id":1,"url":"https://example.com/feed","title":"Updated Feed","category":"updated"}`
+ req := httptest.NewRequest("PUT", "/feed/", strings.NewReader(body))
+ rr := httptest.NewRecorder()
+ feedHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestFeedHandlerDelete(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("DELETE", "/feed/1", nil)
+ rr := httptest.NewRecorder()
+ feedHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestFeedHandlerDeleteBadId(t *testing.T) {
+ setupTestDB(t)
+
+ req := httptest.NewRequest("DELETE", "/feed/notanumber", nil)
+ rr := httptest.NewRecorder()
+ feedHandler(rr, req)
+ // Should handle gracefully (returns early after logging error)
+}
+
+// --- Category handler tests ---
+
+func TestCategoryHandlerGet(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/tag/", nil)
+ rr := httptest.NewRecorder()
+ categoryHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+
+ var categories []interface{}
+ err := json.Unmarshal(rr.Body.Bytes(), &categories)
+ if err != nil {
+ t.Fatalf("Response should be valid JSON: %v", err)
+ }
+}
+
+func TestCategoryHandlerNonGet(t *testing.T) {
+ req := httptest.NewRequest("POST", "/tag/", nil)
+ rr := httptest.NewRecorder()
+ categoryHandler(rr, req)
+ // Non-GET is a no-op, just verify no crash
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Export handler tests ---
+
+func TestExportHandler(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/text", nil)
+ rr := httptest.NewRecorder()
+ exportHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+ if rr.Header().Get("content-type") != "text/plain" {
+ t.Errorf("Expected text/plain content type")
+ }
+}
+
+// --- Crawl handler tests ---
+
+func TestCrawlHandler(t *testing.T) {
+ setupTestDB(t)
+
+ req := httptest.NewRequest("GET", "/crawl/", nil)
+ rr := httptest.NewRecorder()
+ crawlHandler(rr, req)
+
+ body := rr.Body.String()
+ if !strings.Contains(body, "crawling...") {
+ t.Error("Expected 'crawling...' in response")
+ }
+ if !strings.Contains(body, "done...") {
+ t.Error("Expected 'done...' in response")
+ }
+}
+
+// --- FullText handler tests ---
+
+func TestFullTextHandlerNoId(t *testing.T) {
+ setupTestDB(t)
+
+ req := httptest.NewRequest("GET", "/0", nil)
+ rr := httptest.NewRecorder()
+ fullTextHandler(rr, req)
+ // Should return early with no content
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Image proxy handler tests ---
+
+func TestImageProxyHandlerIfNoneMatch(t *testing.T) {
+ req := httptest.NewRequest("GET", "/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWFnZS5qcGc=", nil)
+ req.Header.Set("If-None-Match", "https://example.com/image.jpg")
+ rr := httptest.NewRecorder()
+ imageProxyHandler(rr, req)
+ if rr.Code != http.StatusNotModified {
+ t.Errorf("Expected 304, got %d", rr.Code)
+ }
+}
+
+func TestSecondsInAYear(t *testing.T) {
+ expected := 60 * 60 * 24 * 365
+ if SecondsInAYear != expected {
+ t.Errorf("SecondsInAYear = %d, want %d", SecondsInAYear, expected)
+ }
+}
+
+// --- More image proxy handler tests ---
+
+func TestImageProxyHandlerEtag(t *testing.T) {
+ req := httptest.NewRequest("GET", "/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWFnZS5qcGc=", nil)
+ req.Header.Set("Etag", "https://example.com/image.jpg")
+ rr := httptest.NewRecorder()
+ imageProxyHandler(rr, req)
+ if rr.Code != http.StatusNotModified {
+ t.Errorf("Expected 304, got %d", rr.Code)
+ }
+}
+
+func TestImageProxyHandlerSuccess(t *testing.T) {
+ // Create test image server
+ imgServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.WriteHeader(200)
+ w.Write([]byte("fake-image-data"))
+ }))
+ defer imgServer.Close()
+
+ // Encode the URL as base64
+ encodedURL := base64.URLEncoding.EncodeToString([]byte(imgServer.URL + "/test.jpg"))
+
+ // Build request with URL set to just the encoded string
+ // (simulating http.StripPrefix behavior)
+ req := httptest.NewRequest("GET", "/"+encodedURL, nil)
+ req.URL = &url.URL{Path: encodedURL}
+ rr := httptest.NewRecorder()
+ imageProxyHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+ if rr.Body.String() != "fake-image-data" {
+ t.Errorf("Expected image data, got %q", rr.Body.String())
+ }
+ if rr.Header().Get("ETag") == "" {
+ t.Error("Expected ETag header")
+ }
+ if rr.Header().Get("Cache-Control") != "public" {
+ t.Error("Expected Cache-Control: public")
+ }
+}
+
+func TestImageProxyHandlerBadRemote(t *testing.T) {
+ // Encode a URL that will fail to connect
+ encodedURL := base64.URLEncoding.EncodeToString([]byte("http://127.0.0.1:1/bad"))
+ req := httptest.NewRequest("GET", "/"+encodedURL, nil)
+ req.URL = &url.URL{Path: encodedURL}
+ rr := httptest.NewRecorder()
+ imageProxyHandler(rr, req)
+ // Should return 404
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("Expected 404, got %d", rr.Code)
+ }
+}
+
+// --- More fulltext handler tests ---
+
+func TestFullTextHandlerNonNumericId(t *testing.T) {
+ setupTestDB(t)
+
+ req := httptest.NewRequest("GET", "/abc", nil)
+ rr := httptest.NewRecorder()
+ fullTextHandler(rr, req)
+ // Should return early since Atoi("abc") = 0
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Item handler GET test ---
+
+func TestItemHandlerGetNoId(t *testing.T) {
+ setupTestDB(t)
+
+ req := httptest.NewRequest("GET", "/0", nil)
+ rr := httptest.NewRecorder()
+ itemHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Additional export handler tests ---
+
+func TestExportHandlerOPML(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/opml", nil)
+ rr := httptest.NewRecorder()
+ exportHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestExportHandlerJSON(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/json", nil)
+ rr := httptest.NewRecorder()
+ exportHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+func TestExportHandlerHTML(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/html", nil)
+ rr := httptest.NewRecorder()
+ exportHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Feed handler PUT with bad JSON ---
+
+func TestFeedHandlerPutBadJSON(t *testing.T) {
+ setupTestDB(t)
+
+ req := httptest.NewRequest("PUT", "/feed/", strings.NewReader("not json"))
+ rr := httptest.NewRecorder()
+ feedHandler(rr, req)
+ // Should handle gracefully (logs error, attempts f.Update with zero values)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Item handler PUT with read/unread states ---
+
+func TestItemHandlerPutUnread(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ body := `{"_id":"1","read":false,"starred":false}`
+ req := httptest.NewRequest("PUT", "/item/1", strings.NewReader(body))
+ rr := httptest.NewRecorder()
+ itemHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Stream handler with multiple combined params ---
+
+func TestStreamHandlerCombinedParams(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("GET", "/stream/?max_id=999&read_filter=all&starred=0", nil)
+ rr := httptest.NewRecorder()
+ streamHandler(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}
+
+// --- Category handler PUT test ---
+
+func TestCategoryHandlerPut(t *testing.T) {
+ setupTestDB(t)
+ seedData(t)
+
+ req := httptest.NewRequest("PUT", "/tag/", nil)
+ rr := httptest.NewRecorder()
+ categoryHandler(rr, req)
+ // PUT is handled by the default case (no-op)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200, got %d", rr.Code)
+ }
+}