diff options
| author | Claude <noreply@anthropic.com> | 2026-02-18 06:18:28 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-02-18 06:18:28 +0000 |
| commit | 269e44da41f9feed32214bbab6fc16ec88fffd85 (patch) | |
| tree | 6c6312b8ad3fd9175b2992e3e044fa6257e3ef43 /api | |
| parent | 8eb86cdc49c3c2f69d8a64f855220ebd68be336c (diff) | |
| download | neko-269e44da41f9feed32214bbab6fc16ec88fffd85.tar.gz neko-269e44da41f9feed32214bbab6fc16ec88fffd85.tar.bz2 neko-269e44da41f9feed32214bbab6fc16ec88fffd85.zip | |
Increase test coverage across lowest-coverage packagesclaude/improve-test-coverage-iBkwc
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
Diffstat (limited to 'api')
| -rw-r--r-- | api/api_stress_test.go | 134 | ||||
| -rw-r--r-- | api/api_test.go | 229 |
2 files changed, 363 insertions, 0 deletions
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("<p>Mixed content %d-%d</p>", 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 := `<?xml version="1.0" encoding="UTF-8"?> +<opml version="2.0"> + <head><title>test</title></head> + <body> + <outline type="rss" text="Test Feed" title="Test Feed" xmlUrl="https://example.com/feed" htmlUrl="https://example.com"/> + </body> +</opml>` + + 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) +} |
