diff options
| author | Fuwn <[email protected]> | 2026-02-10 02:02:40 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-10 02:02:40 -0800 |
| commit | 1d9861f0ebfa50d2604e746c161ba8c6dee4d117 (patch) | |
| tree | a180ada719a6e7dc3ebfad291c80501d928d3b0a /services | |
| parent | fix: harden CI and close remaining test/security gaps (diff) | |
| download | asa.news-1d9861f0ebfa50d2604e746c161ba8c6dee4d117.tar.xz asa.news-1d9861f0ebfa50d2604e746c161ba8c6dee4d117.zip | |
feat: add Go worker tests and include in CI
- Add webhook tests: HMAC signing, headers, status handling, timeout
- Add writer tests: credential stripping, truncation, edge cases
- Add Go test step to CI workflow (actions/setup-go + go test)
Diffstat (limited to 'services')
| -rw-r--r-- | services/worker/internal/webhook/webhook_test.go | 249 | ||||
| -rw-r--r-- | services/worker/internal/writer/writer_test.go | 67 |
2 files changed, 316 insertions, 0 deletions
diff --git a/services/worker/internal/webhook/webhook_test.go b/services/worker/internal/webhook/webhook_test.go new file mode 100644 index 0000000..378fe26 --- /dev/null +++ b/services/worker/internal/webhook/webhook_test.go @@ -0,0 +1,249 @@ +package webhook + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestSendWebhookRequestSetsHeaders(test *testing.T) { + var capturedRequest *http.Request + var capturedBody []byte + + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + capturedRequest = request + capturedBody, _ = io.ReadAll(request.Body) + + responseWriter.WriteHeader(http.StatusOK) + })) + + defer testServer.Close() + + dispatcher := &Dispatcher{ + httpClient: testServer.Client(), + } + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: nil, + } + payload := []byte(`{"event":"entries.created"}`) + deliveryError := dispatcher.sendWebhookRequest(context.Background(), subscriber, payload) + + require.NoError(test, deliveryError) + assert.Equal(test, "application/json", capturedRequest.Header.Get("Content-Type")) + assert.Equal(test, "asa.news Webhook/1.0", capturedRequest.Header.Get("User-Agent")) + assert.Empty(test, capturedRequest.Header.Get("X-Asa-Signature-256")) + assert.Equal(test, `{"event":"entries.created"}`, string(capturedBody)) +} + +func TestSendWebhookRequestHMACSignature(test *testing.T) { + var capturedSignatureHeader string + + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + capturedSignatureHeader = request.Header.Get("X-Asa-Signature-256") + + responseWriter.WriteHeader(http.StatusOK) + })) + + defer testServer.Close() + + dispatcher := &Dispatcher{ + httpClient: testServer.Client(), + } + webhookSecret := "my-super-secret-key" + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: &webhookSecret, + } + payload := []byte(`{"event":"entries.created","entries":[]}`) + deliveryError := dispatcher.sendWebhookRequest(context.Background(), subscriber, payload) + + require.NoError(test, deliveryError) + + expectedMAC := hmac.New(sha256.New, []byte(webhookSecret)) + + expectedMAC.Write(payload) + + expectedSignature := "sha256=" + hex.EncodeToString(expectedMAC.Sum(nil)) + + assert.Equal(test, expectedSignature, capturedSignatureHeader) +} + +func TestSendWebhookRequestEmptySecretNoSignature(test *testing.T) { + var capturedSignatureHeader string + + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + capturedSignatureHeader = request.Header.Get("X-Asa-Signature-256") + + responseWriter.WriteHeader(http.StatusOK) + })) + + defer testServer.Close() + + dispatcher := &Dispatcher{ + httpClient: testServer.Client(), + } + emptySecret := "" + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: &emptySecret, + } + deliveryError := dispatcher.sendWebhookRequest(context.Background(), subscriber, []byte(`{}`)) + + require.NoError(test, deliveryError) + assert.Empty(test, capturedSignatureHeader) +} + +func TestSendWebhookRequestReturnsErrorOnServerError(test *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + responseWriter.WriteHeader(http.StatusInternalServerError) + })) + + defer testServer.Close() + + dispatcher := &Dispatcher{ + httpClient: testServer.Client(), + } + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: nil, + } + deliveryError := dispatcher.sendWebhookRequest(context.Background(), subscriber, []byte(`{}`)) + + assert.Error(test, deliveryError) + assert.Contains(test, deliveryError.Error(), "500") +} + +func TestSendWebhookRequestAccepts2xxStatuses(test *testing.T) { + successCodes := []int{200, 201, 202, 204} + + for _, statusCode := range successCodes { + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + responseWriter.WriteHeader(statusCode) + })) + dispatcher := &Dispatcher{ + httpClient: testServer.Client(), + } + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: nil, + } + deliveryError := dispatcher.sendWebhookRequest(context.Background(), subscriber, []byte(`{}`)) + + assert.NoError(test, deliveryError, "expected status %d to succeed", statusCode) + testServer.Close() + } +} + +func TestSendWebhookRequestReturnsErrorOn4xx(test *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + responseWriter.WriteHeader(http.StatusBadRequest) + })) + + defer testServer.Close() + + dispatcher := &Dispatcher{ + httpClient: testServer.Client(), + } + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: nil, + } + deliveryError := dispatcher.sendWebhookRequest(context.Background(), subscriber, []byte(`{}`)) + + assert.Error(test, deliveryError) + assert.Contains(test, deliveryError.Error(), "400") +} + +func TestWebhookPayloadSerialization(test *testing.T) { + title := "Test Entry" + entryURL := "https://example.com/1" + payload := WebhookPayload{ + Event: "entries.created", + Timestamp: "2024-01-01T12:00:00Z", + Entries: []EntryPayload{ + { + EntryIdentifier: "entry-1", + FeedIdentifier: "feed-1", + GUID: "guid-1", + URL: &entryURL, + Title: &title, + Author: nil, + Summary: nil, + PublishedAt: nil, + EnclosureURL: nil, + EnclosureType: nil, + }, + }, + } + + assert.Equal(test, "entries.created", payload.Event) + assert.Len(test, payload.Entries, 1) + assert.Equal(test, "entry-1", payload.Entries[0].EntryIdentifier) + assert.Equal(test, "Test Entry", *payload.Entries[0].Title) + assert.Nil(test, payload.Entries[0].Author) +} + +func TestSendWebhookRequestTimeout(test *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + time.Sleep(2 * time.Second) + responseWriter.WriteHeader(http.StatusOK) + })) + + defer testServer.Close() + + dispatcher := &Dispatcher{ + httpClient: &http.Client{ + Timeout: 50 * time.Millisecond, + }, + } + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: nil, + } + deliveryError := dispatcher.sendWebhookRequest(context.Background(), subscriber, []byte(`{}`)) + + assert.Error(test, deliveryError) +} + +func TestSendWebhookRequestRetryCount(test *testing.T) { + var requestCount atomic.Int32 + + testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + requestCount.Add(1) + responseWriter.WriteHeader(http.StatusInternalServerError) + })) + + defer testServer.Close() + + dispatcher := &Dispatcher{ + httpClient: testServer.Client(), + } + subscriber := webhookSubscriber{ + UserIdentifier: "user-1", + WebhookURL: testServer.URL, + WebhookSecret: nil, + } + + for range 4 { + dispatcher.sendWebhookRequest(context.Background(), subscriber, []byte(`{}`)) + } + + assert.Equal(test, int32(4), requestCount.Load()) +} diff --git a/services/worker/internal/writer/writer_test.go b/services/worker/internal/writer/writer_test.go new file mode 100644 index 0000000..37aac83 --- /dev/null +++ b/services/worker/internal/writer/writer_test.go @@ -0,0 +1,67 @@ +package writer + +import ( + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestSanitizeErrorMessageStripsCredentials(test *testing.T) { + errorMessage := "failed to fetch https://user:[email protected]/feed.xml: connection refused" + sanitized := sanitizeErrorMessage(errorMessage) + + assert.NotContains(test, sanitized, "user:password") + assert.Contains(test, sanitized, "example.com") + assert.Contains(test, sanitized, "connection refused") +} + +func TestSanitizeErrorMessageTruncatesLongMessages(test *testing.T) { + longMessage := strings.Repeat("a", 1000) + sanitized := sanitizeErrorMessage(longMessage) + + assert.Len(test, sanitized, 500) +} + +func TestSanitizeErrorMessagePreservesShortMessages(test *testing.T) { + shortMessage := "simple error" + sanitized := sanitizeErrorMessage(shortMessage) + + assert.Equal(test, "simple error", sanitized) +} + +func TestSanitizeErrorMessageNoURLs(test *testing.T) { + errorMessage := "connection timed out after 30 seconds" + sanitized := sanitizeErrorMessage(errorMessage) + + assert.Equal(test, "connection timed out after 30 seconds", sanitized) +} + +func TestSanitizeErrorMessageURLWithoutCredentials(test *testing.T) { + errorMessage := "failed to fetch https://example.com/feed.xml: 404" + sanitized := sanitizeErrorMessage(errorMessage) + + assert.Equal(test, errorMessage, sanitized) +} + +func TestSanitizeErrorMessageMultipleURLsWithCredentials(test *testing.T) { + errorMessage := "redirect from https://admin:[email protected] to https://user:[email protected]" + sanitized := sanitizeErrorMessage(errorMessage) + + assert.NotContains(test, sanitized, "admin:secret") + assert.NotContains(test, sanitized, "user:pass") + assert.Contains(test, sanitized, "old.example.com") + assert.Contains(test, sanitized, "new.example.com") +} + +func TestSanitizeErrorMessageExactly500Characters(test *testing.T) { + exactMessage := strings.Repeat("b", 500) + sanitized := sanitizeErrorMessage(exactMessage) + + assert.Len(test, sanitized, 500) +} + +func TestSanitizeErrorMessageEmptyString(test *testing.T) { + sanitized := sanitizeErrorMessage("") + + assert.Equal(test, "", sanitized) +} |