summaryrefslogtreecommitdiff
path: root/services/worker/internal/fetcher
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-10 01:59:01 -0800
committerFuwn <[email protected]>2026-02-10 01:59:01 -0800
commit871985bc9eb42c6a088563e7c34db181f603f407 (patch)
tree31299597a9f246d332b3bf6d5e2bed177648b577 /services/worker/internal/fetcher
parentfeat: reorder feature grid by attention-grabbing impact (diff)
downloadasa.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.go90
-rw-r--r--services/worker/internal/fetcher/errors_test.go169
-rw-r--r--services/worker/internal/fetcher/ssrf_protection_test.go114
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"))
+}