diff options
| author | Fuwn <[email protected]> | 2026-02-10 01:59:01 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-10 01:59:01 -0800 |
| commit | 871985bc9eb42c6a088563e7c34db181f603f407 (patch) | |
| tree | 31299597a9f246d332b3bf6d5e2bed177648b577 /services/worker/internal/fetcher | |
| parent | feat: reorder feature grid by attention-grabbing impact (diff) | |
| download | asa.news-871985bc9eb42c6a088563e7c34db181f603f407.tar.xz asa.news-871985bc9eb42c6a088563e7c34db181f603f407.zip | |
fix: harden CI and close remaining test/security gaps
- Make webhook URL tests deterministic with injectable DNS resolver
- Wire tier parity checker into CI and root scripts
- Add rate_limits cleanup cron job (hourly, >1hr retention)
- Change rate limiter to fail closed on RPC error
- Add Go worker tests: parser, SSRF protection, error classification,
authentication, and worker pool (48 test functions)
Diffstat (limited to 'services/worker/internal/fetcher')
| -rw-r--r-- | services/worker/internal/fetcher/authentication_test.go | 90 | ||||
| -rw-r--r-- | services/worker/internal/fetcher/errors_test.go | 169 | ||||
| -rw-r--r-- | services/worker/internal/fetcher/ssrf_protection_test.go | 114 |
3 files changed, 373 insertions, 0 deletions
diff --git a/services/worker/internal/fetcher/authentication_test.go b/services/worker/internal/fetcher/authentication_test.go new file mode 100644 index 0000000..bc840b9 --- /dev/null +++ b/services/worker/internal/fetcher/authentication_test.go @@ -0,0 +1,90 @@ +package fetcher + +import ( + "encoding/base64" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestApplyBearerAuthentication(test *testing.T) { + request, _ := http.NewRequest(http.MethodGet, "https://example.com/feed", nil) + authenticationError := ApplyAuthentication(request, AuthenticationConfiguration{ + AuthenticationType: "bearer", + AuthenticationValue: "my-secret-token", + }) + + require.NoError(test, authenticationError) + assert.Equal(test, "Bearer my-secret-token", request.Header.Get("Authorization")) +} + +func TestApplyBasicAuthentication(test *testing.T) { + request, _ := http.NewRequest(http.MethodGet, "https://example.com/feed", nil) + authenticationError := ApplyAuthentication(request, AuthenticationConfiguration{ + AuthenticationType: "basic", + AuthenticationValue: "user:pass", + }) + + require.NoError(test, authenticationError) + + expectedEncoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) + + assert.Equal(test, "Basic "+expectedEncoded, request.Header.Get("Authorization")) +} + +func TestApplyQueryParamAuthentication(test *testing.T) { + request, _ := http.NewRequest(http.MethodGet, "https://example.com/feed?existing=value", nil) + authenticationError := ApplyAuthentication(request, AuthenticationConfiguration{ + AuthenticationType: "query_param", + AuthenticationValue: "api_key=abc123", + }) + + require.NoError(test, authenticationError) + assert.Equal(test, "abc123", request.URL.Query().Get("api_key")) + assert.Equal(test, "value", request.URL.Query().Get("existing")) +} + +func TestApplyEmptyAuthenticationType(test *testing.T) { + request, _ := http.NewRequest(http.MethodGet, "https://example.com/feed", nil) + authenticationError := ApplyAuthentication(request, AuthenticationConfiguration{ + AuthenticationType: "", + AuthenticationValue: "", + }) + + require.NoError(test, authenticationError) + assert.Empty(test, request.Header.Get("Authorization")) +} + +func TestApplyUnsupportedAuthenticationType(test *testing.T) { + request, _ := http.NewRequest(http.MethodGet, "https://example.com/feed", nil) + authenticationError := ApplyAuthentication(request, AuthenticationConfiguration{ + AuthenticationType: "oauth2", + AuthenticationValue: "token", + }) + + assert.Error(test, authenticationError) + assert.Contains(test, authenticationError.Error(), "unsupported") +} + +func TestApplyQueryParamInvalidFormat(test *testing.T) { + request, _ := http.NewRequest(http.MethodGet, "https://example.com/feed", nil) + authenticationError := ApplyAuthentication(request, AuthenticationConfiguration{ + AuthenticationType: "query_param", + AuthenticationValue: "no-equals-sign", + }) + + assert.Error(test, authenticationError) + assert.Contains(test, authenticationError.Error(), "param_name=value") +} + +func TestApplyQueryParamWithEqualsInValue(test *testing.T) { + request, _ := http.NewRequest(http.MethodGet, "https://example.com/feed", nil) + authenticationError := ApplyAuthentication(request, AuthenticationConfiguration{ + AuthenticationType: "query_param", + AuthenticationValue: "token=abc=def", + }) + + require.NoError(test, authenticationError) + assert.Equal(test, "abc=def", request.URL.Query().Get("token")) +} diff --git a/services/worker/internal/fetcher/errors_test.go b/services/worker/internal/fetcher/errors_test.go new file mode 100644 index 0000000..e81251b --- /dev/null +++ b/services/worker/internal/fetcher/errors_test.go @@ -0,0 +1,169 @@ +package fetcher + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "net" + "net/url" + "testing" +) + +func TestClassifyHTTPStatus401(test *testing.T) { + fetchError := ClassifyError(nil, 401) + + assert.Equal(test, 401, fetchError.StatusCode) + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "authentication") +} + +func TestClassifyHTTPStatus403(test *testing.T) { + fetchError := ClassifyError(nil, 403) + + assert.Equal(test, 403, fetchError.StatusCode) + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "forbidden") +} + +func TestClassifyHTTPStatus404(test *testing.T) { + fetchError := ClassifyError(nil, 404) + + assert.Equal(test, 404, fetchError.StatusCode) + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "not found") +} + +func TestClassifyHTTPStatus410(test *testing.T) { + fetchError := ClassifyError(nil, 410) + + assert.Equal(test, 410, fetchError.StatusCode) + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "permanently removed") +} + +func TestClassifyHTTPStatus429(test *testing.T) { + fetchError := ClassifyError(nil, 429) + + assert.Equal(test, 429, fetchError.StatusCode) + assert.True(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "rate limited") +} + +func TestClassifyHTTPServerErrors(test *testing.T) { + serverErrorCodes := []int{500, 502, 503, 504} + + for _, statusCode := range serverErrorCodes { + test.Run(fmt.Sprintf("status_%d", statusCode), func(test *testing.T) { + fetchError := ClassifyError(nil, statusCode) + + assert.Equal(test, statusCode, fetchError.StatusCode) + assert.True(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "server error") + }) + } +} + +func TestClassifyHTTPUnknownClientError(test *testing.T) { + fetchError := ClassifyError(nil, 418) + + assert.Equal(test, 418, fetchError.StatusCode) + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "418") +} + +func TestClassifyDNSNotFoundError(test *testing.T) { + dnsError := &net.DNSError{ + Name: "nonexistent.example.com", + IsNotFound: true, + } + fetchError := ClassifyError(dnsError, 0) + + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "DNS") +} + +func TestClassifyDNSTemporaryError(test *testing.T) { + dnsError := &net.DNSError{ + Name: "flaky.example.com", + IsNotFound: false, + } + fetchError := ClassifyError(dnsError, 0) + + assert.True(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "DNS") +} + +func TestClassifyTimeoutError(test *testing.T) { + timeoutError := &url.Error{ + Op: "Get", + URL: "https://slow.example.com", + Err: &timeoutErr{}, + } + fetchError := ClassifyError(timeoutError, 0) + + assert.True(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "timed out") +} + +type timeoutErr struct{} + +func (timeoutError *timeoutErr) Error() string { return "connection timed out" } +func (timeoutError *timeoutErr) Timeout() bool { return true } +func (timeoutError *timeoutErr) Temporary() bool { return true } + +func TestClassifyNetworkOpError(test *testing.T) { + opError := &net.OpError{ + Op: "dial", + Net: "tcp", + Err: fmt.Errorf("connection refused"), + } + fetchError := ClassifyError(opError, 0) + + assert.True(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "network") +} + +func TestClassifyTLSError(test *testing.T) { + tlsError := fmt.Errorf("tls: handshake failure") + fetchError := ClassifyError(tlsError, 0) + + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "TLS") +} + +func TestClassifyCertificateError(test *testing.T) { + certError := fmt.Errorf("x509: certificate has expired") + fetchError := ClassifyError(certError, 0) + + assert.False(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "certificate") +} + +func TestClassifyNilError(test *testing.T) { + fetchError := ClassifyError(nil, 0) + + assert.True(test, fetchError.Retryable) + assert.Contains(test, fetchError.UserMessage, "unknown") +} + +func TestFetchErrorInterface(test *testing.T) { + underlyingError := fmt.Errorf("some network issue") + fetchError := &FetchError{ + StatusCode: 0, + UserMessage: "test error", + Retryable: true, + UnderlyingError: underlyingError, + } + + assert.Contains(test, fetchError.Error(), "test error") + assert.Contains(test, fetchError.Error(), "some network issue") + assert.Equal(test, underlyingError, fetchError.Unwrap()) +} + +func TestFetchErrorWithoutUnderlying(test *testing.T) { + fetchError := &FetchError{ + UserMessage: "standalone error", + } + + assert.Equal(test, "standalone error", fetchError.Error()) + assert.Nil(test, fetchError.Unwrap()) +} diff --git a/services/worker/internal/fetcher/ssrf_protection_test.go b/services/worker/internal/fetcher/ssrf_protection_test.go new file mode 100644 index 0000000..3e78380 --- /dev/null +++ b/services/worker/internal/fetcher/ssrf_protection_test.go @@ -0,0 +1,114 @@ +package fetcher + +import ( + "github.com/stretchr/testify/assert" + "net" + "testing" +) + +func TestIsReservedAddress(test *testing.T) { + reservedAddresses := []struct { + name string + address string + }{ + {"loopback ipv4", "127.0.0.1"}, + {"loopback ipv4 alternate", "127.0.0.2"}, + {"private 10.x", "10.0.0.1"}, + {"private 10.x deep", "10.255.255.255"}, + {"private 172.16.x", "172.16.0.1"}, + {"private 172.31.x", "172.31.255.255"}, + {"private 192.168.x", "192.168.1.1"}, + {"link-local", "169.254.1.1"}, + {"null address", "0.0.0.1"}, + {"ipv6 loopback", "::1"}, + {"ipv6 unique local fc", "fc00::1"}, + {"ipv6 unique local fd", "fd00::1"}, + {"ipv6 link-local", "fe80::1"}, + } + + for _, testCase := range reservedAddresses { + test.Run(testCase.name, func(test *testing.T) { + parsedIP := net.ParseIP(testCase.address) + + assert.True(test, isReservedAddress(parsedIP), "expected %s to be reserved", testCase.address) + }) + } +} + +func TestIsNotReservedAddress(test *testing.T) { + publicAddresses := []struct { + name string + address string + }{ + {"google dns", "8.8.8.8"}, + {"cloudflare dns", "1.1.1.1"}, + {"random public", "93.184.216.34"}, + {"public 172.32", "172.32.0.1"}, + {"public ipv6", "2001:db8::1"}, + } + + for _, testCase := range publicAddresses { + test.Run(testCase.name, func(test *testing.T) { + parsedIP := net.ParseIP(testCase.address) + + assert.False(test, isReservedAddress(parsedIP), "expected %s to not be reserved", testCase.address) + }) + } +} + +func TestValidateFeedURLRejectsUnsupportedSchemes(test *testing.T) { + unsupportedURLs := []string{ + "ftp://example.com/feed", + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,hello", + } + + for _, feedURL := range unsupportedURLs { + test.Run(feedURL, func(test *testing.T) { + assert.Error(test, ValidateFeedURL(feedURL)) + }) + } +} + +func TestValidateFeedURLRejectsEmptyHostname(test *testing.T) { + assert.Error(test, ValidateFeedURL("http:///path")) +} + +func TestValidateFeedURLRejectsPrivateIPs(test *testing.T) { + privateURLs := []string{ + "http://127.0.0.1/feed", + "https://10.0.0.1/feed", + "http://192.168.1.1/feed", + "http://172.16.0.1/feed", + "http://[::1]/feed", + } + + for _, feedURL := range privateURLs { + test.Run(feedURL, func(test *testing.T) { + assert.Error(test, ValidateFeedURL(feedURL)) + }) + } +} + +func TestValidateFeedURLAcceptsPublicIPs(test *testing.T) { + publicURLs := []string{ + "http://93.184.216.34/feed", + "https://8.8.8.8/feed", + } + + for _, feedURL := range publicURLs { + test.Run(feedURL, func(test *testing.T) { + assert.NoError(test, ValidateFeedURL(feedURL)) + }) + } +} + +func TestValidateFeedURLAcceptsHTTPAndHTTPS(test *testing.T) { + assert.NoError(test, ValidateFeedURL("http://93.184.216.34/feed")) + assert.NoError(test, ValidateFeedURL("https://93.184.216.34/feed")) +} + +func TestValidateFeedURLRejectsInvalidURL(test *testing.T) { + assert.Error(test, ValidateFeedURL("://broken")) +} |