From 269e44da41f9feed32214bbab6fc16ec88fffd85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 06:18:28 +0000 Subject: Increase test coverage across lowest-coverage packages Major coverage improvements: - safehttp: 46.7% -> 93.3% (SafeDialer, redirect checking, SSRF protection) - api: 81.8% -> 96.4% (HandleImport 0% -> 100%, stream errors, content types) - importer: 85.3% -> 94.7% (ImportFeeds dispatcher, OPML nesting, edge cases) - cmd/neko: 77.1% -> 85.4% (purge, secure-cookies, minutes, allow-local flags) New tests added: - Security regression tests (CSRF token uniqueness, mismatch rejection, auth cookie HttpOnly, security headers, API auth requirements) - Stress tests for concurrent mixed operations and rapid state toggling - SSRF protection tests for SafeDialer hostname resolution and redirect paths https://claude.ai/code/session_01XUBh32rHpbYue1JYXSH64Q --- api/api_stress_test.go | 134 +++++++++++++++++++++++++++++ api/api_test.go | 229 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+) (limited to 'api') diff --git a/api/api_stress_test.go b/api/api_stress_test.go index a846f75..4fcbf5e 100644 --- a/api/api_stress_test.go +++ b/api/api_stress_test.go @@ -194,6 +194,140 @@ func TestStress_LargeDataset(t *testing.T) { } } +func TestStress_ConcurrentMixedOperations(t *testing.T) { + if testing.Short() { + t.Skip("skipping stress test in short mode") + } + + setupTestDB(t) + + // Create multiple feeds with items across categories + categories := []string{"tech", "news", "science", "art"} + for i, cat := range categories { + f := &feed.Feed{ + Url: fmt.Sprintf("http://example.com/mixed/%d", i), + Title: fmt.Sprintf("Mixed Feed %d", i), + Category: cat, + } + f.Create() + for j := 0; j < 25; j++ { + it := &item.Item{ + Title: fmt.Sprintf("Mixed Item %d-%d", i, j), + Url: fmt.Sprintf("http://example.com/mixed/%d/%d", i, j), + Description: fmt.Sprintf("

Mixed content %d-%d

", i, j), + PublishDate: "2024-01-01 00:00:00", + FeedId: f.Id, + } + _ = it.Create() + } + } + + server := newTestServer() + + const goroutines = 40 + var wg sync.WaitGroup + errors := make(chan error, goroutines*2) + + start := time.Now() + + // Mix of reads, filtered reads, updates, and exports + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + var req *http.Request + switch idx % 4 { + case 0: + // Stream with category filter + req = httptest.NewRequest("GET", "/stream?tag="+categories[idx%len(categories)], nil) + case 1: + // Stream with search + req = httptest.NewRequest("GET", "/stream?q=Mixed", nil) + case 2: + // Feed list + req = httptest.NewRequest("GET", "/feed", nil) + case 3: + // Export + req = httptest.NewRequest("GET", "/export/json", nil) + } + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + errors <- fmt.Errorf("op %d (type %d) got status %d", idx, idx%4, rr.Code) + } + }(i) + } + wg.Wait() + close(errors) + elapsed := time.Since(start) + + for err := range errors { + t.Errorf("concurrent mixed operation error: %v", err) + } + + t.Logf("40 concurrent mixed operations completed in %v", elapsed) + if elapsed > 10*time.Second { + t.Errorf("concurrent mixed operations took too long: %v (threshold: 10s)", elapsed) + } +} + +func TestStress_RapidReadMarkUnmark(t *testing.T) { + if testing.Short() { + t.Skip("skipping stress test in short mode") + } + + setupTestDB(t) + + f := &feed.Feed{Url: "http://example.com/rapid", Title: "Rapid Feed"} + f.Create() + it := &item.Item{ + Title: "Rapid Toggle", + Url: "http://example.com/rapid/1", + FeedId: f.Id, + } + _ = it.Create() + + server := newTestServer() + + // Rapidly toggle read state on the same item + const iterations = 100 + var wg sync.WaitGroup + errors := make(chan error, iterations) + + start := time.Now() + for i := 0; i < iterations; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + body, _ := json.Marshal(item.Item{ + Id: it.Id, + ReadState: idx%2 == 0, + Starred: idx%3 == 0, + }) + req := httptest.NewRequest("PUT", "/item/"+strconv.FormatInt(it.Id, 10), bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + server.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + errors <- fmt.Errorf("rapid update %d got status %d", idx, rr.Code) + } + }(i) + } + wg.Wait() + close(errors) + elapsed := time.Since(start) + + for err := range errors { + t.Errorf("rapid toggle error: %v", err) + } + + t.Logf("100 concurrent read-state toggles completed in %v", elapsed) + if elapsed > 10*time.Second { + t.Errorf("rapid toggles took too long: %v (threshold: 10s)", elapsed) + } +} + func seedStressData(t *testing.T, count int) { t.Helper() f := &feed.Feed{Url: "http://example.com/bench", Title: "Bench Feed", Category: "tech"} diff --git a/api/api_test.go b/api/api_test.go index a2c3415..2c77501 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "encoding/json" + "mime/multipart" "net/http" "net/http/httptest" "path/filepath" @@ -546,3 +547,231 @@ func TestHandleCategorySuccess(t *testing.T) { t.Errorf("Expected %d, got %d", http.StatusOK, rr.Code) } } + +func TestHandleImportOPML(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + opmlContent := ` + + test + + + +` + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", "feeds.opml") + if err != nil { + t.Fatal(err) + } + part.Write([]byte(opmlContent)) + writer.Close() + + req := httptest.NewRequest("POST", "/import", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + server.HandleImport(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var resp map[string]string + json.NewDecoder(rr.Body).Decode(&resp) + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %q", resp["status"]) + } + + // Verify the feed was imported + feeds, _ := feed.All() + if len(feeds) != 1 { + t.Errorf("expected 1 feed after import, got %d", len(feeds)) + } + + time.Sleep(100 * time.Millisecond) // let goroutine settle +} + +func TestHandleImportText(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + textContent := "https://example.com/feed1\nhttps://example.com/feed2\n" + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", "feeds.txt") + if err != nil { + t.Fatal(err) + } + part.Write([]byte(textContent)) + writer.WriteField("format", "text") + writer.Close() + + req := httptest.NewRequest("POST", "/import?format=text", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + server.HandleImport(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + feeds, _ := feed.All() + if len(feeds) != 2 { + t.Errorf("expected 2 feeds after text import, got %d", len(feeds)) + } + + time.Sleep(100 * time.Millisecond) +} + +func TestHandleImportMethodNotAllowed(t *testing.T) { + server := newTestServer() + + req := httptest.NewRequest("GET", "/import", nil) + rr := httptest.NewRecorder() + server.HandleImport(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("expected %d, got %d", http.StatusMethodNotAllowed, rr.Code) + } +} + +func TestHandleImportNoFile(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + req := httptest.NewRequest("POST", "/import", nil) + rr := httptest.NewRecorder() + server.HandleImport(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } +} + +func TestHandleImportUnsupportedFormat(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "feeds.csv") + part.Write([]byte("some data")) + writer.Close() + + req := httptest.NewRequest("POST", "/import?format=csv", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + server.HandleImport(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("expected %d for unsupported format, got %d", http.StatusInternalServerError, rr.Code) + } +} + +func TestHandleImportInvalidOPML(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "bad.opml") + part.Write([]byte("not valid xml at all")) + writer.Close() + + req := httptest.NewRequest("POST", "/import?format=opml", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + server.HandleImport(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("expected %d for invalid OPML, got %d", http.StatusInternalServerError, rr.Code) + } +} + +func TestHandleStreamErrorOnClosedDB(t *testing.T) { + setupTestDB(t) + seedData(t) + server := newTestServer() + + // Close the DB to force an error + models.DB.Close() + + req := httptest.NewRequest("GET", "/stream", nil) + rr := httptest.NewRecorder() + server.HandleStream(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("expected %d for closed DB, got %d", http.StatusInternalServerError, rr.Code) + } +} + +func TestHandleItemInvalidJSON(t *testing.T) { + setupTestDB(t) + seedData(t) + server := newTestServer() + + req := httptest.NewRequest("PUT", "/item/1", strings.NewReader("not json")) + rr := httptest.NewRecorder() + server.HandleItem(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected %d for invalid JSON, got %d", http.StatusBadRequest, rr.Code) + } +} + +func TestHandleExportContentTypes(t *testing.T) { + setupTestDB(t) + seedData(t) + server := newTestServer() + + testCases := []struct { + format string + contentType string + disposition string + }{ + {"text", "text/plain", "neko_export.txt"}, + {"opml", "application/xml", "neko_export.opml"}, + {"json", "application/json", "neko_export.json"}, + {"html", "text/html", "neko_export.html"}, + } + + for _, tc := range testCases { + req := httptest.NewRequest("GET", "/export/"+tc.format, nil) + rr := httptest.NewRecorder() + server.HandleExport(rr, req) + + if ct := rr.Header().Get("Content-Type"); ct != tc.contentType { + t.Errorf("export/%s: expected Content-Type %q, got %q", tc.format, tc.contentType, ct) + } + if cd := rr.Header().Get("Content-Disposition"); !strings.Contains(cd, tc.disposition) { + t.Errorf("export/%s: expected Content-Disposition containing %q, got %q", tc.format, tc.disposition, cd) + } + } +} + +func TestHandleImportJSON(t *testing.T) { + setupTestDB(t) + server := newTestServer() + + jsonContent := `{"title":"Article 1","url":"https://example.com/1","description":"desc","read":false,"starred":false,"date":{"$date":"2024-01-01"},"feed":{"url":"https://example.com/feed","title":"Feed 1"}}` + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "items.json") + part.Write([]byte(jsonContent)) + writer.Close() + + req := httptest.NewRequest("POST", "/import?format=json", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + server.HandleImport(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + time.Sleep(100 * time.Millisecond) +} -- cgit v1.2.3