diff --git a/cmd/server/tests/processing_issues_test.go b/cmd/server/tests/processing_issues_test.go new file mode 100644 index 0000000..19ecffd --- /dev/null +++ b/cmd/server/tests/processing_issues_test.go @@ -0,0 +1,522 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProcessingIssuesListAuthentication tests authentication and authorization for list endpoint +func TestProcessingIssuesListAuthentication(t *testing.T) { + setup := setupTestServer(t) + + // Create a test library + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Auth Library", "manga") + + t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("GET /api/libraries/:id/issues/list - Non-admin user forbidden", func(t *testing.T) { + token := setup.RegularToken + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusForbidden, rec.Code) + }) + + t.Run("GET /api/libraries/:id/issues/list - Admin user authorized", func(t *testing.T) { + token := setup.Token + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + }) +} + +// TestProcessingIssuesListResponseStructure tests the response structure of list endpoint +func TestProcessingIssuesListResponseStructure(t *testing.T) { + setup := setupTestServer(t) + + // Create a test library + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Structure Library", "comics") + + t.Run("Returns JSON array for empty library", func(t *testing.T) { + token := setup.Token + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.IsType(t, []map[string]interface{}{}, response) + assert.Equal(t, 0, len(response), "Empty library should return empty array") + }) + + t.Run("Has correct content type", func(t *testing.T) { + token := setup.Token + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + }) +} + +// TestProcessingIssuesListInputValidation tests input validation for list endpoint +func TestProcessingIssuesListInputValidation(t *testing.T) { + setup := setupTestServer(t) + + token := setup.Token + + testCases := []struct { + name string + libraryID string + expectedStatus int + description string + }{ + { + name: "Invalid UUID format", + libraryID: "not-a-uuid", + expectedStatus: http.StatusBadRequest, + description: "Should reject malformed UUID", + }, + { + name: "Empty UUID", + libraryID: "", + expectedStatus: http.StatusNotFound, + description: "Should return 404 for empty ID", + }, + { + name: "UUID with extra path traversal", + libraryID: "00000000-0000-0000-0000-000000000000/../../etc", + expectedStatus: http.StatusBadRequest, + description: "Should reject path traversal attempts", + }, + { + name: "Nonexistent library UUID", + libraryID: "00000000-0000-0000-0000-000000000000", + expectedStatus: http.StatusOK, + description: "Should return empty array for valid but nonexistent UUID", + }, + { + name: "Special characters in UUID", + libraryID: "'; DROP TABLE processing_issues; --", + expectedStatus: http.StatusBadRequest, + description: "Should reject SQL injection attempts", + }, + { + name: "Very long UUID", + libraryID: "00000000-0000-0000-0000-000000000000-extra-suffix", + expectedStatus: http.StatusBadRequest, + description: "Should reject overly long IDs", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, tc.expectedStatus, rec.Code, tc.description) + + // For successful requests, verify response structure + if tc.expectedStatus == http.StatusOK { + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.IsType(t, []map[string]interface{}{}, response) + } + }) + } +} + +// TestProcessingIssueStatsAuthentication tests authentication and authorization for stats endpoint +func TestProcessingIssueStatsAuthentication(t *testing.T) { + setup := setupTestServer(t) + + // Create a test library + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Stats Auth Library", "manga") + + t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("GET /api/libraries/:id/issues/stats - Non-admin user forbidden", func(t *testing.T) { + token := setup.RegularToken + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusForbidden, rec.Code) + }) + + t.Run("GET /api/libraries/:id/issues/stats - Admin user authorized", func(t *testing.T) { + token := setup.Token + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + }) +} + +// TestProcessingIssueStatsResponseStructure tests the response structure of stats endpoint +func TestProcessingIssueStatsResponseStructure(t *testing.T) { + setup := setupTestServer(t) + + // Create a test library + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Stats Structure Library", "comics") + + t.Run("Returns correct structure for empty library", func(t *testing.T) { + token := setup.Token + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + // Verify required fields + assert.Contains(t, response, "error_count", "Should have error_count field") + assert.Contains(t, response, "warning_count", "Should have warning_count field") + assert.Contains(t, response, "info_count", "Should have info_count field") + + // Verify all counts are zero for empty library + assert.Equal(t, float64(0), response["error_count"], "error_count should be 0") + assert.Equal(t, float64(0), response["warning_count"], "warning_count should be 0") + assert.Equal(t, float64(0), response["info_count"], "info_count should be 0") + + // Verify field types + assert.IsType(t, float64(0), response["error_count"], "error_count should be number") + assert.IsType(t, float64(0), response["warning_count"], "warning_count should be number") + assert.IsType(t, float64(0), response["info_count"], "info_count should be number") + }) + + t.Run("Has correct content type", func(t *testing.T) { + token := setup.Token + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + }) + + t.Run("Has exactly 3 fields", func(t *testing.T) { + token := setup.Token + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.Len(t, response, 3, "Should have exactly 3 fields") + }) +} + +// TestProcessingIssueStatsInputValidation tests input validation for stats endpoint +func TestProcessingIssueStatsInputValidation(t *testing.T) { + setup := setupTestServer(t) + + token := setup.Token + + testCases := []struct { + name string + libraryID string + expectedStatus int + description string + }{ + { + name: "Invalid UUID format", + libraryID: "not-a-uuid", + expectedStatus: http.StatusBadRequest, + description: "Should reject malformed UUID", + }, + { + name: "Empty UUID", + libraryID: "", + expectedStatus: http.StatusNotFound, + description: "Should return 404 for empty ID", + }, + { + name: "UUID with extra path traversal", + libraryID: "00000000-0000-0000-0000-000000000000/../../etc", + expectedStatus: http.StatusBadRequest, + description: "Should reject path traversal attempts", + }, + { + name: "Nonexistent library UUID", + libraryID: "00000000-0000-0000-0000-000000000000", + expectedStatus: http.StatusOK, + description: "Should return zero counts for valid but nonexistent UUID", + }, + { + name: "Special characters in UUID", + libraryID: "'; DROP TABLE processing_issues; --", + expectedStatus: http.StatusBadRequest, + description: "Should reject SQL injection attempts", + }, + { + name: "Very long UUID", + libraryID: "00000000-0000-0000-0000-000000000000-extra-suffix", + expectedStatus: http.StatusBadRequest, + description: "Should reject overly long IDs", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, tc.expectedStatus, rec.Code, tc.description) + + // For successful requests, verify response structure + if tc.expectedStatus == http.StatusOK { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + // Verify all counts are zero for nonexistent library + assert.Equal(t, float64(0), response["error_count"]) + assert.Equal(t, float64(0), response["warning_count"]) + assert.Equal(t, float64(0), response["info_count"]) + } + }) + } +} + +// TestProcessingIssuesCrossLibraryIsolation tests that different libraries don't interfere +func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) { + setup := setupTestServer(t) + + // Create two different libraries with different types + deviceSetup1 := setupDeviceTest(t) + library1ID := deviceSetup1.CreateLibrary(t, "Isolation Test Library 1", "manga") + + deviceSetup2 := setupDeviceTest(t) + library2ID := deviceSetup2.CreateLibrary(t, "Isolation Test Library 2", "comics") + + token := setup.Token + + // Both libraries should return empty arrays (no issues created) + t.Run("Library 1 returns its own empty list", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+library1ID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.Equal(t, 0, len(response), "Library 1 should have 0 issues") + }) + + t.Run("Library 2 returns its own empty list", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.Equal(t, 0, len(response), "Library 2 should have 0 issues") + }) + + t.Run("Both libraries have independent stats", func(t *testing.T) { + // Get stats for library 1 + req1 := httptest.NewRequest("GET", "/api/libraries/"+library1ID+"/issues/stats", nil) + req1.Header.Set("Authorization", "Bearer "+token) + rec1 := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec1, req1) + + var stats1 map[string]interface{} + json.NewDecoder(rec1.Body).Decode(&stats1) + + // Get stats for library 2 + req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil) + req2.Header.Set("Authorization", "Bearer "+token) + rec2 := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec2, req2) + + var stats2 map[string]interface{} + json.NewDecoder(rec2.Body).Decode(&stats2) + + // Both should have zero counts + assert.Equal(t, float64(0), stats1["error_count"]) + assert.Equal(t, float64(0), stats1["warning_count"]) + assert.Equal(t, float64(0), stats1["info_count"]) + + assert.Equal(t, float64(0), stats2["error_count"]) + assert.Equal(t, float64(0), stats2["warning_count"]) + assert.Equal(t, float64(0), stats2["info_count"]) + }) +} + +// TestProcessingIssuesDifferentLibraryTypes tests endpoints with different library types +func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) { + setup := setupTestServer(t) + + token := setup.Token + + libraryTypes := []struct { + name string + libraryType string + }{ + {"Ebooks library", "ebooks"}, + {"Comics library", "comics"}, + {"Manga library", "manga"}, + {"Audiobooks library", "audiobooks"}, + } + + for _, lt := range libraryTypes { + t.Run(lt.name+" - list endpoint works", func(t *testing.T) { + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType) + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response []map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.IsType(t, []map[string]interface{}{}, response) + }) + + t.Run(lt.name+" - stats endpoint works", func(t *testing.T) { + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType) + + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Contains(t, response, "error_count") + assert.Contains(t, response, "warning_count") + assert.Contains(t, response, "info_count") + }) + } +} + +// TestProcessingIssuesInvalidTokens tests with invalid authentication tokens +func TestProcessingIssuesInvalidTokens(t *testing.T) { + setup := setupTestServer(t) + + // Create a test library + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Token Library", "ebooks") + + t.Run("List endpoint rejects invalid token", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "Bearer invalid-token-12345") + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("Stats endpoint rejects invalid token", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", "Bearer invalid-token-12345") + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("List endpoint rejects missing bearer prefix", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", setup.Token) // Missing "Bearer " prefix + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("Stats endpoint rejects missing bearer prefix", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil) + req.Header.Set("Authorization", setup.Token) // Missing "Bearer " prefix + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("List endpoint rejects empty authorization header", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil) + req.Header.Set("Authorization", "") + rec := httptest.NewRecorder() + + setup.Server.Config.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +}