summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-10 02:02:40 -0800
committerFuwn <[email protected]>2026-02-10 02:02:40 -0800
commit1d9861f0ebfa50d2604e746c161ba8c6dee4d117 (patch)
treea180ada719a6e7dc3ebfad291c80501d928d3b0a /services
parentfix: harden CI and close remaining test/security gaps (diff)
downloadasa.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.go249
-rw-r--r--services/worker/internal/writer/writer_test.go67
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)
+}