aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-02-16 23:31:54 +0000
committerClaude <noreply@anthropic.com>2026-02-16 23:31:54 +0000
commit5f5f8be8c6ca78f5d61372544bb24d692d9597f0 (patch)
tree7c864522e7c5aff3552d493136a692f6c167c528
parented1b953e1336aeda282b46acd1c5e21529fa0950 (diff)
downloadneko-5f5f8be8c6ca78f5d61372544bb24d692d9597f0.tar.gz
neko-5f5f8be8c6ca78f5d61372544bb24d692d9597f0.tar.bz2
neko-5f5f8be8c6ca78f5d61372544bb24d692d9597f0.zip
Add tests for CSRF exclusions, Filter includeContent, multi-feed, and routing
- CSRF: test excluded paths (/api/login, /api/logout), PUT/DELETE methods - Item model: test Filter includeContent flag, ItemById returns content, multiple feed_ids filtering - API: test read_filter=all param, feed_ids comma-separated filter, full_content exclusion from stream - Routing: add v3 frontend route test https://claude.ai/code/session_019Z4VJxzY7tcAuNkPAkvry9
-rw-r--r--.thicket/tickets.jsonl2
-rw-r--r--api/api_test.go59
-rw-r--r--models/item/item_test.go93
-rw-r--r--web/login_test.go86
-rw-r--r--web/routing_test.go7
5 files changed, 246 insertions, 1 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index 8ddf76f..3e2d7fa 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -115,7 +115,7 @@
{"id":"NK-mgmn5m","title":"serve \"legacy\" version UI at /v1/ instead of /","description":"Let's \"softly\" start to deprecated the legacy version by moving it to /v1/ -- ideally this won't require any changes but there may be some relative/absolute URLs to adjust in the static files there or in rouoting","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T16:41:04.710679944Z","updated":"2026-02-14T17:38:25.35292336Z"}
{"id":"NK-mpb1e1","title":"Mock RSS feeds in E2E tests","description":"Currently E2E tests fetch real feeds like CNN, which makes them slow (10s+) and potentially flaky depending on network. Use a local mock server or file-based mocks.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T23:43:56.494457169Z","updated":"2026-02-15T02:17:12.5439427Z"}
{"id":"NK-mwf9q2","title":"Implement Tag View","description":"Create frontend view for browsing items by tag/category. Use /tag/:id endpoint.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T15:04:12.441165286Z","updated":"2026-02-13T18:04:38.644796168Z"}
-{"id":"NK-mx8o1b","title":"review recent tickets / CLs and write tests","description":"Given the key behaviors/features/bugs are there any additional tests needed -- don't worry about e2e tests.","type":"epic","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-16T16:01:44.60710814Z","updated":"2026-02-16T16:01:44.60710814Z"}
+{"id":"NK-mx8o1b","title":"review recent tickets / CLs and write tests","description":"Given the key behaviors/features/bugs are there any additional tests needed -- don't worry about e2e tests.","type":"","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-16T16:01:44.60710814Z","updated":"2026-02-16T23:31:41.92974498Z"}
{"id":"NK-n7nuyy","title":"Fix TypeScript Lint Errors in Tests","description":"There are lint errors in test files regarding jest-dom matchers (toBeInTheDocument, etc). Ensure proper types are included.","type":"bug","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T21:50:15.140702806Z","updated":"2026-02-13T21:50:15.140702806Z"}
{"id":"NK-ngokc3","title":"Enhance CSRF protection for login page","description":"The /login/ endpoint is currently excluded from CSRF checks to allow standard form submission. We should explore adding a hidden CSRF token to the form or using Javascript to submit to /api/login for better protection.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-16T15:56:34.617681973Z","updated":"2026-02-16T22:02:51.841992054Z"}
{"id":"NK-nx8dhw","title":"fix github ci","description":"seems to be broken, are the right things still in there given all the refactorings","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T18:02:19.485418738Z","updated":"2026-02-14T18:08:16.790742187Z"}
diff --git a/api/api_test.go b/api/api_test.go
index ada824b..a2c3415 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -474,6 +474,65 @@ func TestStreamExcludesFullContent(t *testing.T) {
}
}
+func TestStreamReadFilterAll(t *testing.T) {
+ setupTestDB(t)
+ server := newTestServer()
+
+ f := &feed.Feed{Url: "http://example.com/rf", Title: "RF Feed"}
+ f.Create()
+ i := &item.Item{Title: "Read Item", Url: "http://example.com/rf/1", FeedId: f.Id}
+ _ = i.Create()
+ i.ReadState = true
+ i.Save()
+
+ // Default (unread only) should return 0 since item is read
+ req := httptest.NewRequest("GET", "/stream", nil)
+ rr := httptest.NewRecorder()
+ server.HandleStream(rr, req)
+ var items []item.Item
+ json.NewDecoder(rr.Body).Decode(&items)
+ if len(items) != 0 {
+ t.Errorf("Default stream should return 0 read items, got %d", len(items))
+ }
+
+ // read_filter=all should return the read item
+ req = httptest.NewRequest("GET", "/stream?read_filter=all", nil)
+ rr = httptest.NewRecorder()
+ server.HandleStream(rr, req)
+ json.NewDecoder(rr.Body).Decode(&items)
+ if len(items) != 1 {
+ t.Errorf("read_filter=all should return 1 item, got %d", len(items))
+ }
+}
+
+func TestStreamMultipleFeedIds(t *testing.T) {
+ setupTestDB(t)
+ server := newTestServer()
+
+ f1 := &feed.Feed{Url: "http://example.com/mf1", Title: "Feed 1"}
+ f1.Create()
+ f2 := &feed.Feed{Url: "http://example.com/mf2", Title: "Feed 2"}
+ f2.Create()
+ f3 := &feed.Feed{Url: "http://example.com/mf3", Title: "Feed 3"}
+ f3.Create()
+
+ (&item.Item{Title: "F1", Url: "http://example.com/mf1/1", FeedId: f1.Id}).Create()
+ (&item.Item{Title: "F2", Url: "http://example.com/mf2/1", FeedId: f2.Id}).Create()
+ (&item.Item{Title: "F3", Url: "http://example.com/mf3/1", FeedId: f3.Id}).Create()
+
+ // Filter by feed_ids=1,2
+ url := "/stream?feed_ids=" + strconv.FormatInt(f1.Id, 10) + "," + strconv.FormatInt(f2.Id, 10)
+ req := httptest.NewRequest("GET", url, nil)
+ rr := httptest.NewRecorder()
+ server.HandleStream(rr, req)
+
+ var items []item.Item
+ json.NewDecoder(rr.Body).Decode(&items)
+ if len(items) != 2 {
+ t.Errorf("feed_ids filter should return 2 items, got %d", len(items))
+ }
+}
+
func TestHandleCategorySuccess(t *testing.T) {
setupTestDB(t)
server := newTestServer()
diff --git a/models/item/item_test.go b/models/item/item_test.go
index 241a650..c5b557a 100644
--- a/models/item/item_test.go
+++ b/models/item/item_test.go
@@ -622,6 +622,99 @@ func TestGetFullContentWithMock(t *testing.T) {
}
}
+func TestFilterIncludeContent(t *testing.T) {
+ setupTestDB(t)
+ feedId := createTestFeed(t)
+
+ i := &Item{
+ Title: "Content Item",
+ Url: "https://example.com/content",
+ Description: "desc",
+ PublishDate: "2024-01-01 00:00:00",
+ FeedId: feedId,
+ }
+ i.Create()
+ models.DB.Exec("UPDATE item SET full_content=?, header_image=? WHERE id=?",
+ "<p>Full article</p>", "https://example.com/img.jpg", i.Id)
+
+ // Without includeContent (default) — should NOT have full_content
+ items, err := Filter(0, nil, "", false, false, 0, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("Expected 1 item, got %d", len(items))
+ }
+ if items[0].FullContent != "" {
+ t.Errorf("Expected empty FullContent without includeContent, got %q", items[0].FullContent)
+ }
+ if items[0].HeaderImage != "" {
+ t.Errorf("Expected empty HeaderImage without includeContent, got %q", items[0].HeaderImage)
+ }
+
+ // With includeContent=true — should have full_content
+ items, err = Filter(0, nil, "", false, false, 0, "", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("Expected 1 item, got %d", len(items))
+ }
+ if items[0].FullContent == "" {
+ t.Error("Expected FullContent with includeContent=true")
+ }
+ if items[0].HeaderImage == "" {
+ t.Error("Expected HeaderImage with includeContent=true")
+ }
+}
+
+func TestItemByIdIncludesContent(t *testing.T) {
+ setupTestDB(t)
+ feedId := createTestFeed(t)
+
+ i := &Item{
+ Title: "ById Content",
+ Url: "https://example.com/byidcontent",
+ Description: "desc",
+ PublishDate: "2024-01-01 00:00:00",
+ FeedId: feedId,
+ }
+ i.Create()
+ models.DB.Exec("UPDATE item SET full_content=? WHERE id=?", "<p>Article body</p>", i.Id)
+
+ found := ItemById(i.Id)
+ if found == nil {
+ t.Fatal("ItemById should return an item")
+ }
+ if found.FullContent == "" {
+ t.Error("ItemById should include full_content")
+ }
+}
+
+func TestFilterMultipleFeedIds(t *testing.T) {
+ setupTestDB(t)
+
+ res1, _ := models.DB.Exec("INSERT INTO feed(url, title) VALUES(?, ?)", "https://feed1.com", "Feed 1")
+ feedId1, _ := res1.LastInsertId()
+ res2, _ := models.DB.Exec("INSERT INTO feed(url, title) VALUES(?, ?)", "https://feed2.com", "Feed 2")
+ feedId2, _ := res2.LastInsertId()
+ res3, _ := models.DB.Exec("INSERT INTO feed(url, title) VALUES(?, ?)", "https://feed3.com", "Feed 3")
+ feedId3, _ := res3.LastInsertId()
+
+ (&Item{Title: "F1 Item", Url: "https://feed1.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId1}).Create()
+ (&Item{Title: "F2 Item", Url: "https://feed2.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId2}).Create()
+ (&Item{Title: "F3 Item", Url: "https://feed3.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId3}).Create()
+
+ // Filter by feed IDs 1 and 2
+ items, err := Filter(0, []int64{feedId1, feedId2}, "", false, false, 0, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) != 2 {
+ t.Errorf("Expected 2 items for two feed IDs, got %d", len(items))
+ }
+}
+
func TestRewriteImagesNoSrc(t *testing.T) {
input := `<html><body><img alt="no src"/></body></html>`
result := rewriteImages(input)
diff --git a/web/login_test.go b/web/login_test.go
index f4931b2..9d78396 100644
--- a/web/login_test.go
+++ b/web/login_test.go
@@ -85,3 +85,89 @@ func TestCSRFLoginWithFormToken(t *testing.T) {
t.Errorf("Expected POST /other with valid CSRF header to succeed, got 403")
}
}
+
+func TestCSRFExcludedPaths(t *testing.T) {
+ originalPw := config.Config.DigestPassword
+ defer func() { config.Config.DigestPassword = originalPw }()
+ config.Config.DigestPassword = "secret"
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/login", apiLoginHandler)
+ mux.HandleFunc("/api/logout", apiLogoutHandler)
+ handler := CSRFMiddleware(&config.Config, mux)
+
+ // POST /api/login without CSRF token should succeed (excluded path)
+ req := httptest.NewRequest("POST", "/api/login", strings.NewReader("password=secret"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code == http.StatusForbidden {
+ t.Error("POST /api/login should be excluded from CSRF protection")
+ }
+
+ // POST /api/logout without CSRF token should succeed (excluded path)
+ req = httptest.NewRequest("POST", "/api/logout", nil)
+ rr = httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code == http.StatusForbidden {
+ t.Error("POST /api/logout should be excluded from CSRF protection")
+ }
+}
+
+func TestCSRFPutAndDeleteMethods(t *testing.T) {
+ cfg := &config.Settings{SecureCookies: false}
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ handler := CSRFMiddleware(cfg, inner)
+
+ // GET to get a token
+ getReq := httptest.NewRequest("GET", "/", nil)
+ getRR := httptest.NewRecorder()
+ handler.ServeHTTP(getRR, getReq)
+
+ var csrfToken string
+ for _, c := range getRR.Result().Cookies() {
+ if c.Name == "csrf_token" {
+ csrfToken = c.Value
+ }
+ }
+
+ // PUT without token should fail
+ req := httptest.NewRequest("PUT", "/item/1", nil)
+ req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusForbidden {
+ t.Errorf("PUT without CSRF token should return 403, got %d", rr.Code)
+ }
+
+ // DELETE without token should fail
+ req = httptest.NewRequest("DELETE", "/feed/1", nil)
+ req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
+ rr = httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusForbidden {
+ t.Errorf("DELETE without CSRF token should return 403, got %d", rr.Code)
+ }
+
+ // PUT with valid token should succeed
+ req = httptest.NewRequest("PUT", "/item/1", nil)
+ req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
+ req.Header.Set("X-CSRF-Token", csrfToken)
+ rr = httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("PUT with valid CSRF token should succeed, got %d", rr.Code)
+ }
+
+ // DELETE with valid token should succeed
+ req = httptest.NewRequest("DELETE", "/feed/1", nil)
+ req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
+ req.Header.Set("X-CSRF-Token", csrfToken)
+ rr = httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("DELETE with valid CSRF token should succeed, got %d", rr.Code)
+ }
+}
diff --git a/web/routing_test.go b/web/routing_test.go
index 972b208..a7c5c37 100644
--- a/web/routing_test.go
+++ b/web/routing_test.go
@@ -36,6 +36,13 @@ func TestRouting(t *testing.T) {
containsBody: "<!doctype html>",
},
{
+ name: "/v3/ serves v3 UI",
+ path: "/v3/",
+ method: "GET",
+ expectedStatus: http.StatusOK,
+ containsBody: "<!doctype html>",
+ },
+ {
name: "/v1/ redirects unauthenticated",
path: "/v1/",
method: "GET",