diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 01c1875..232e611 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -16,8 +16,8 @@ permissions: security-events: write jobs: - build: - name: Build promgithub + unit-and-build: + name: build and test runs-on: ubuntu-24.04 steps: @@ -53,8 +53,8 @@ jobs: - name: Build run: make build - - name: Test - run: make test + - name: Unit Tests + run: make unit-test - name: Coverage run: make coverage @@ -64,3 +64,29 @@ jobs: - name: Container Security Scan run: make container-security + + integration-tests: + name: Integration tests + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup environment + id: environment + run: | + GOVERSION=$(make go-version) + echo "GOVERSION=${GOVERSION}" >> $GITHUB_ENV + + - name: Set up Golang + uses: actions/setup-go@v5 + id: go + with: + go-version: ${{ env.GOVERSION }} + + - name: Install Tools and Dependencies + run: make deps + + - name: Integration Tests + run: make integration-test diff --git a/Makefile b/Makefile index f478436..ebe6cb2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build container container-security cross-platform debug release test test-all go-version coverage fmt lint deps security clean dev-setup +.PHONY: build container container-security cross-platform debug release test unit-test integration-test test-all go-version coverage fmt lint deps security clean dev-setup include version @@ -31,11 +31,18 @@ debug: LDFLAGS := $(LDFLAGS_DBG) debug: TARGET := $(TARGET)-debug debug: build -test: PROMGITHUB_WEBHOOK_SECRET := test-secret -test: ## Run unit tests +test: unit-test integration-test ## Run the full Go test suite + +unit-test: PROMGITHUB_WEBHOOK_SECRET := test-secret +unit-test: ## Run unit tests @echo "${COLOR_GREEN}Running Unit Tests..${COLOR_RESET}" @go test -v $(SRC) +integration-test: PROMGITHUB_WEBHOOK_SECRET := test-secret +integration-test: ## Run integration tests + @echo "${COLOR_GREEN}Running Integration Tests..${COLOR_RESET}" + @go test -tags=integration -v $(SRC) + coverage: ## Run unit tests with coverage @echo "${COLOR_GREEN}Running Coverage Checks..${COLOR_RESET}" @go test -race -coverprofile=coverage.out -covermode=atomic $(SRC) diff --git a/src/async_test.go b/src/async_test.go index 67e0799..fec0843 100644 --- a/src/async_test.go +++ b/src/async_test.go @@ -1,3 +1,5 @@ +//go:build !integration + package main import ( diff --git a/src/github_test.go b/src/github_test.go index dd4ce95..3d1fa2a 100644 --- a/src/github_test.go +++ b/src/github_test.go @@ -1,3 +1,5 @@ +//go:build !integration + package main import ( diff --git a/src/integration_test.go b/src/integration_test.go new file mode 100644 index 0000000..46a02c4 --- /dev/null +++ b/src/integration_test.go @@ -0,0 +1,272 @@ +//go:build integration + +package main + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "go.uber.org/zap" +) + +func TestIntegrationWebhookMetrics(t *testing.T) { + testCases := []struct { + name string + eventType string + fixture string + expectedStatus int + expectedMetric string + }{ + { + name: "workflow run updates workflow metrics", + eventType: "workflow_run", + fixture: "workflow_run.json", + expectedStatus: http.StatusAccepted, + expectedMetric: `promgithub_workflow_status{branch="main",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 1`, + }, + { + name: "workflow job updates job metrics", + eventType: "workflow_job", + fixture: "workflow_job.json", + expectedStatus: http.StatusAccepted, + expectedMetric: `promgithub_job_status{branch="main",job_conclusion="success",job_status="completed",repository="user/repo",workflow_name="CI"} 1`, + }, + { + name: "push updates commit metrics", + eventType: "push", + fixture: "push.json", + expectedStatus: http.StatusAccepted, + expectedMetric: `promgithub_commit_pushed{repository="user/repo"} 1`, + }, + { + name: "pull request updates pull request metrics", + eventType: "pull_request", + fixture: "pull_request.json", + expectedStatus: http.StatusAccepted, + expectedMetric: `promgithub_pull_request{base_branch="main",pull_request_status="opened",repository="user/repo"} 1`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := newIntegrationTestServer(t) + defer server.Close() + + body := mustReadFixture(t, tc.fixture) + resp := sendWebhookRequest(t, server.URL, tc.eventType, body, "delivery-1") + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != tc.expectedStatus { + t.Fatalf("expected status %d, got %d", tc.expectedStatus, resp.StatusCode) + } + + metrics := waitForMetricsSubstring(t, server.URL, tc.expectedMetric) + if !strings.Contains(metrics, tc.expectedMetric) { + t.Fatalf("expected metrics to contain %q, got:\n%s", tc.expectedMetric, metrics) + } + }) + } +} + +func TestIntegrationWebhookInvalidSignature(t *testing.T) { + server := newIntegrationTestServer(t) + defer server.Close() + + body := mustReadFixture(t, "workflow_run.json") + req, err := http.NewRequest(http.MethodPost, server.URL+"/webhook", bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("X-Hub-Signature-256", "sha256=invalid") + req.Header.Set("X-GitHub-Event", "workflow_run") + req.Header.Set("X-GitHub-Delivery", "delivery-invalid") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode) + } + + metrics := mustFetchMetrics(t, server.URL) + if strings.Contains(metrics, `promgithub_workflow_status{branch="main",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 1`) { + t.Fatalf("workflow metrics changed after invalid signature:\n%s", metrics) + } +} + +func TestIntegrationWebhookUnsupportedEvent(t *testing.T) { + server := newIntegrationTestServer(t) + defer server.Close() + + body := mustReadFixture(t, "workflow_run.json") + resp := sendWebhookRequest(t, server.URL, "unknown_event", body, "delivery-unsupported") + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("expected status %d, got %d", http.StatusAccepted, resp.StatusCode) + } + + metrics := mustFetchMetrics(t, server.URL) + if strings.Contains(metrics, `promgithub_workflow_status{branch="main",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 1`) { + t.Fatalf("unsupported event unexpectedly updated workflow metrics:\n%s", metrics) + } +} + +func TestIntegrationHealthAndMetricsEndpoints(t *testing.T) { + server := newIntegrationTestServer(t) + defer server.Close() + + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatalf("failed to get health endpoint: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected health status %d, got %d", http.StatusOK, resp.StatusCode) + } + + metricsResp, err := http.Get(server.URL + "/metrics") + if err != nil { + t.Fatalf("failed to get metrics endpoint: %v", err) + } + defer func() { _ = metricsResp.Body.Close() }() + + if metricsResp.StatusCode != http.StatusOK { + t.Fatalf("expected metrics status %d, got %d", http.StatusOK, metricsResp.StatusCode) + } +} + +func newIntegrationTestServer(t *testing.T) *httptest.Server { + t.Helper() + resetIntegrationTestMetrics() + + githubWebhookSecret = []byte("integration-test-secret") + stateStore = newInMemoryStateStore() + eventProcessor = newAsyncEventProcessor(asyncProcessorConfig{WorkerCount: 1, QueueSize: 8}, zap.NewNop()) + eventProcessor.Start() + t.Cleanup(func() { + eventProcessor.Stop() + eventProcessor = nil + stateStore = nil + }) + + router := setupRouter(zap.NewNop(), defaultServiceMetrics, prometheus.DefaultGatherer) + return httptest.NewServer(router) +} + +func resetIntegrationTestMetrics() { + workflowStatusCounter.Reset() + workflowDurationHistogram.Reset() + workflowQueuedGauge.Reset() + workflowInProgressGauge.Reset() + workflowCompletedGauge.Reset() + jobStatusCounter.Reset() + jobDurationHistogram.Reset() + jobQueuedGauge.Reset() + jobInProgressGauge.Reset() + jobCompletedGauge.Reset() + commitPushedCounter.Reset() + pullRequestCounter.Reset() + asyncProcessedEventsCounter.Reset() + asyncEventsDroppedCounter.Reset() + asyncProcessingFailuresCounter.Reset() + asyncProcessingDurationHistogram.Reset() + defaultServiceMetrics.apiCallsCounter.Reset() + defaultServiceMetrics.requestDurationHistogram.Reset() + asyncQueueDepthGauge.Set(0) + asyncQueueCapacityGauge.Set(0) + asyncWorkerCountGauge.Set(0) +} + +func mustReadFixture(t *testing.T, name string) []byte { + t.Helper() + allowed := map[string]string{ + "workflow_run.json": "../test_data/workflow_run.json", + "workflow_job.json": "../test_data/workflow_job.json", + "push.json": "../test_data/push.json", + "pull_request.json": "../test_data/pull_request.json", + } + path, ok := allowed[name] + if !ok { + t.Fatalf("unknown fixture %q", name) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read fixture %s: %v", path, err) + } + return body +} + +func sendWebhookRequest(t *testing.T, serverURL, eventType string, body []byte, deliveryID string) *http.Response { + t.Helper() + signature := webhookSignature(body, githubWebhookSecret) + req, err := http.NewRequest(http.MethodPost, serverURL+"/webhook", bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("X-Hub-Signature-256", signature) + req.Header.Set("X-GitHub-Event", eventType) + req.Header.Set("X-GitHub-Delivery", deliveryID) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to send request: %v", err) + } + return resp +} + +func webhookSignature(body, secret []byte) string { + h := hmac.New(sha256.New, secret) + _, _ = h.Write(body) + return fmt.Sprintf("sha256=%s", hex.EncodeToString(h.Sum(nil))) +} + +func mustFetchMetrics(t *testing.T, serverURL string) string { + t.Helper() + + resp, err := http.Get(serverURL + "/metrics") + if err != nil { + t.Fatalf("failed to fetch metrics: %v", err) + } + + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + t.Fatalf("failed to read metrics response: %v", readErr) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected metrics status %d, got %d", http.StatusOK, resp.StatusCode) + } + + return string(body) +} + +func waitForMetricsSubstring(t *testing.T, serverURL, needle string) string { + t.Helper() + + var lastBody string + for i := 0; i < 50; i++ { + lastBody = mustFetchMetrics(t, serverURL) + if strings.Contains(lastBody, needle) { + return lastBody + } + time.Sleep(20 * time.Millisecond) + } + + return lastBody +} diff --git a/src/main_test.go b/src/main_test.go index 08d61b1..73d6552 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -1,3 +1,5 @@ +//go:build !integration + package main import ( diff --git a/src/metrics_test.go b/src/metrics_test.go index e04b1e4..b72c4af 100644 --- a/src/metrics_test.go +++ b/src/metrics_test.go @@ -1,3 +1,5 @@ +//go:build !integration + package main import ( diff --git a/src/redis_test.go b/src/redis_test.go index e81b7c4..41fdea9 100644 --- a/src/redis_test.go +++ b/src/redis_test.go @@ -1,58 +1,13 @@ +//go:build !integration + package main import ( - "context" "os" "testing" "time" ) -type inMemoryStateStore struct { - deliveries map[string]struct{} - workflow map[int]RunState - jobs map[int]RunState -} - -func newInMemoryStateStore() *inMemoryStateStore { - return &inMemoryStateStore{ - deliveries: map[string]struct{}{}, - workflow: map[int]RunState{}, - jobs: map[int]RunState{}, - } -} - -func (s *inMemoryStateStore) MarkDeliveryProcessed(_ context.Context, deliveryID string) (bool, error) { - if _, ok := s.deliveries[deliveryID]; ok { - return false, nil - } - s.deliveries[deliveryID] = struct{}{} - return true, nil -} - -func (s *inMemoryStateStore) GetWorkflowRun(_ context.Context, runID int) (RunState, bool, error) { - state, ok := s.workflow[runID] - return state, ok, nil -} - -func (s *inMemoryStateStore) UpdateWorkflowRun(_ context.Context, runID int, state RunState) error { - s.workflow[runID] = state - return nil -} - -func (s *inMemoryStateStore) GetWorkflowJob(_ context.Context, jobID int) (RunState, bool, error) { - state, ok := s.jobs[jobID] - return state, ok, nil -} - -func (s *inMemoryStateStore) UpdateWorkflowJob(_ context.Context, jobID int, state RunState) error { - s.jobs[jobID] = state - return nil -} - -func (s *inMemoryStateStore) Close() error { - return nil -} - func TestLoadRedisConfigFromEnv(t *testing.T) { for _, key := range []string{ "PROMGITHUB_REDIS_ADDR", diff --git a/src/test_support_test.go b/src/test_support_test.go new file mode 100644 index 0000000..301a3a6 --- /dev/null +++ b/src/test_support_test.go @@ -0,0 +1,49 @@ +package main + +import "context" + +type inMemoryStateStore struct { + deliveries map[string]struct{} + workflow map[int]RunState + jobs map[int]RunState +} + +func newInMemoryStateStore() *inMemoryStateStore { + return &inMemoryStateStore{ + deliveries: map[string]struct{}{}, + workflow: map[int]RunState{}, + jobs: map[int]RunState{}, + } +} + +func (s *inMemoryStateStore) MarkDeliveryProcessed(_ context.Context, deliveryID string) (bool, error) { + if _, ok := s.deliveries[deliveryID]; ok { + return false, nil + } + s.deliveries[deliveryID] = struct{}{} + return true, nil +} + +func (s *inMemoryStateStore) GetWorkflowRun(_ context.Context, runID int) (RunState, bool, error) { + state, ok := s.workflow[runID] + return state, ok, nil +} + +func (s *inMemoryStateStore) UpdateWorkflowRun(_ context.Context, runID int, state RunState) error { + s.workflow[runID] = state + return nil +} + +func (s *inMemoryStateStore) GetWorkflowJob(_ context.Context, jobID int) (RunState, bool, error) { + state, ok := s.jobs[jobID] + return state, ok, nil +} + +func (s *inMemoryStateStore) UpdateWorkflowJob(_ context.Context, jobID int, state RunState) error { + s.jobs[jobID] = state + return nil +} + +func (s *inMemoryStateStore) Close() error { + return nil +}