forgejo-runner-optimiser/internal/integration/integration_test.go
Martin McCaffery d0dd209bc9
All checks were successful
ci / build (push) Successful in 28s
Add token-based authentication for receiver
2026-02-11 15:18:03 +01:00

386 lines
12 KiB
Go

// ABOUTME: Integration tests for collector and receiver interaction.
// ABOUTME: Tests that the push client can successfully send metrics to the receiver.
package integration
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-resource-collector/internal/receiver"
"edp.buildth.ing/DevFW-CICD/forgejo-runner-resource-collector/internal/summary"
)
const (
testReadToken = "integration-test-token"
testHMACKey = "integration-hmac-key"
)
// setupTestReceiver creates a test receiver with SQLite storage, auth, and HTTP server
func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := receiver.NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
server := httptest.NewServer(mux)
cleanup := func() {
server.Close()
_ = store.Close()
}
return store, server, cleanup
}
// generatePushToken generates a scoped push token for an execution context
func generatePushToken(exec summary.ExecutionContext) string {
return receiver.GenerateScopedToken(testHMACKey, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
}
func TestPushClientToReceiver(t *testing.T) {
store, server, cleanup := setupTestReceiver(t)
defer cleanup()
// Test execution context
testCtx := summary.ExecutionContext{
Organization: "integration-org",
Repository: "integration-repo",
Workflow: "test.yml",
Job: "integration-test",
RunID: "run-integration-123",
}
// Create a test summary
testSummary := &summary.RunSummary{
StartTime: time.Now().Add(-time.Minute),
EndTime: time.Now(),
DurationSeconds: 60.0,
SampleCount: 10,
CPUTotal: summary.StatSummary{Peak: 85.5, Avg: 42.3, P95: 78.0},
MemUsedBytes: summary.StatSummary{Peak: 4294967296, Avg: 2147483648, P95: 3865470566},
MemUsedPercent: summary.StatSummary{Peak: 50.0, Avg: 25.0, P95: 45.0},
TopCPUProcesses: []summary.ProcessPeak{
{PID: 1234, Name: "test-process", PeakCPU: 45.0, PeakMem: 1073741824},
},
TopMemProcesses: []summary.ProcessPeak{
{PID: 1234, Name: "test-process", PeakCPU: 45.0, PeakMem: 1073741824},
},
}
// Build payload matching what push client sends
payload := struct {
Execution summary.ExecutionContext `json:"execution"`
Summary summary.RunSummary `json:"run_summary"`
}{
Execution: testCtx,
Summary: *testSummary,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
// Send via HTTP client with scoped push token
pushToken := generatePushToken(testCtx)
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/metrics", bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+pushToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do() error = %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusCreated)
}
// Verify metrics were stored
metrics, err := store.GetMetricsByWorkflowJob("integration-org", "integration-repo", "test.yml", "integration-test")
if err != nil {
t.Fatalf("GetMetricsByWorkflowJob() error = %v", err)
}
if len(metrics) != 1 {
t.Fatalf("got %d metrics, want 1", len(metrics))
}
m := metrics[0]
if m.Organization != testCtx.Organization {
t.Errorf("Organization = %q, want %q", m.Organization, testCtx.Organization)
}
if m.Repository != testCtx.Repository {
t.Errorf("Repository = %q, want %q", m.Repository, testCtx.Repository)
}
if m.Workflow != testCtx.Workflow {
t.Errorf("Workflow = %q, want %q", m.Workflow, testCtx.Workflow)
}
if m.Job != testCtx.Job {
t.Errorf("Job = %q, want %q", m.Job, testCtx.Job)
}
if m.RunID != testCtx.RunID {
t.Errorf("RunID = %q, want %q", m.RunID, testCtx.RunID)
}
// Verify payload was stored correctly
var storedSummary summary.RunSummary
if err := json.Unmarshal([]byte(m.Payload), &storedSummary); err != nil {
t.Fatalf("Unmarshal payload error = %v", err)
}
if storedSummary.SampleCount != testSummary.SampleCount {
t.Errorf("SampleCount = %d, want %d", storedSummary.SampleCount, testSummary.SampleCount)
}
if storedSummary.CPUTotal.Peak != testSummary.CPUTotal.Peak {
t.Errorf("CPUTotal.Peak = %f, want %f", storedSummary.CPUTotal.Peak, testSummary.CPUTotal.Peak)
}
}
func TestPushClientIntegration(t *testing.T) {
store, server, cleanup := setupTestReceiver(t)
defer cleanup()
// Set environment variables for the push client
t.Setenv("GITHUB_REPOSITORY_OWNER", "push-client-org")
t.Setenv("GITHUB_REPOSITORY", "push-client-repo")
t.Setenv("GITHUB_WORKFLOW", "push-test.yml")
t.Setenv("GITHUB_JOB", "push-job")
t.Setenv("GITHUB_RUN_ID", "push-run-456")
// Generate scoped push token
pushToken := receiver.GenerateScopedToken(testHMACKey, "push-client-org", "push-client-repo", "push-test.yml", "push-job")
// Create push client with token - it reads execution context from env vars
pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", pushToken)
// Verify execution context was read from env
ctx := pushClient.ExecutionContext()
if ctx.Organization != "push-client-org" {
t.Errorf("Organization = %q, want %q", ctx.Organization, "push-client-org")
}
// Create and push a summary
testSummary := &summary.RunSummary{
StartTime: time.Now().Add(-30 * time.Second),
EndTime: time.Now(),
DurationSeconds: 30.0,
SampleCount: 6,
CPUTotal: summary.StatSummary{Peak: 50.0, Avg: 25.0, P95: 45.0},
MemUsedBytes: summary.StatSummary{Peak: 1000000, Avg: 500000, P95: 900000},
MemUsedPercent: summary.StatSummary{Peak: 10.0, Avg: 5.0, P95: 9.0},
}
// Push the summary
err := pushClient.Push(context.Background(), testSummary)
if err != nil {
t.Fatalf("Push() error = %v", err)
}
// Verify it was stored
metrics, err := store.GetMetricsByWorkflowJob("push-client-org", "push-client-repo", "push-test.yml", "push-job")
if err != nil {
t.Fatalf("GetMetricsByWorkflowJob() error = %v", err)
}
if len(metrics) != 1 {
t.Fatalf("got %d metrics, want 1", len(metrics))
}
if metrics[0].RunID != "push-run-456" {
t.Errorf("RunID = %q, want %q", metrics[0].RunID, "push-run-456")
}
}
func TestMultiplePushes(t *testing.T) {
store, server, cleanup := setupTestReceiver(t)
defer cleanup()
// Simulate multiple workflow runs pushing metrics via direct HTTP POST
runs := []summary.ExecutionContext{
{Organization: "org-a", Repository: "repo-1", Workflow: "ci.yml", Job: "build", RunID: "run-1"},
{Organization: "org-a", Repository: "repo-1", Workflow: "ci.yml", Job: "build", RunID: "run-2"},
{Organization: "org-a", Repository: "repo-1", Workflow: "ci.yml", Job: "test", RunID: "run-1"},
{Organization: "org-a", Repository: "repo-2", Workflow: "ci.yml", Job: "build", RunID: "run-1"},
}
for _, execCtx := range runs {
payload := struct {
Execution summary.ExecutionContext `json:"execution"`
Summary summary.RunSummary `json:"run_summary"`
}{
Execution: execCtx,
Summary: summary.RunSummary{
SampleCount: 5,
CPUTotal: summary.StatSummary{Peak: 50.0},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
pushToken := generatePushToken(execCtx)
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/metrics", bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest() error = %v for run %+v", err, execCtx)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+pushToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do() error = %v for run %+v", err, execCtx)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want %d for run %+v", resp.StatusCode, http.StatusCreated, execCtx)
}
}
// Verify filtering works correctly
metrics, err := store.GetMetricsByWorkflowJob("org-a", "repo-1", "ci.yml", "build")
if err != nil {
t.Fatalf("GetMetricsByWorkflowJob() error = %v", err)
}
if len(metrics) != 2 {
t.Errorf("got %d metrics for org-a/repo-1/ci.yml/build, want 2", len(metrics))
}
metrics, err = store.GetMetricsByWorkflowJob("org-a", "repo-1", "ci.yml", "test")
if err != nil {
t.Fatalf("GetMetricsByWorkflowJob() error = %v", err)
}
if len(metrics) != 1 {
t.Errorf("got %d metrics for org-a/repo-1/ci.yml/test, want 1", len(metrics))
}
}
func TestPushClientWithTokenIntegration(t *testing.T) {
readToken := "integration-read-secret"
hmacKey := "integration-hmac-secret"
store, server, cleanup := setupTestReceiverWithToken(t, readToken, hmacKey)
defer cleanup()
// Generate a scoped token via the API
tokenReqBody, _ := json.Marshal(map[string]string{
"organization": "token-org",
"repository": "token-repo",
"workflow": "ci.yml",
"job": "build",
})
tokenReq, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/token", bytes.NewReader(tokenReqBody))
tokenReq.Header.Set("Authorization", "Bearer "+readToken)
tokenReq.Header.Set("Content-Type", "application/json")
tokenResp, err := http.DefaultClient.Do(tokenReq)
if err != nil {
t.Fatalf("token request error: %v", err)
}
defer func() { _ = tokenResp.Body.Close() }()
if tokenResp.StatusCode != http.StatusOK {
t.Fatalf("token request status = %d, want %d", tokenResp.StatusCode, http.StatusOK)
}
var tokenBody struct {
Token string `json:"token"`
}
if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil {
t.Fatalf("decode token response: %v", err)
}
// Use the scoped token to push metrics
t.Setenv("GITHUB_REPOSITORY_OWNER", "token-org")
t.Setenv("GITHUB_REPOSITORY", "token-repo")
t.Setenv("GITHUB_WORKFLOW", "ci.yml")
t.Setenv("GITHUB_JOB", "build")
t.Setenv("GITHUB_RUN_ID", "token-run-1")
pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", tokenBody.Token)
testSummary := &summary.RunSummary{
StartTime: time.Now().Add(-10 * time.Second),
EndTime: time.Now(),
DurationSeconds: 10.0,
SampleCount: 2,
}
if err := pushClient.Push(context.Background(), testSummary); err != nil {
t.Fatalf("Push() error = %v", err)
}
// Verify stored
metrics, err := store.GetMetricsByWorkflowJob("token-org", "token-repo", "ci.yml", "build")
if err != nil {
t.Fatalf("GetMetricsByWorkflowJob() error = %v", err)
}
if len(metrics) != 1 {
t.Fatalf("got %d metrics, want 1", len(metrics))
}
if metrics[0].RunID != "token-run-1" {
t.Errorf("RunID = %q, want %q", metrics[0].RunID, "token-run-1")
}
}
func TestPushClientWithWrongTokenIntegration(t *testing.T) {
readToken := "integration-read-secret"
hmacKey := "integration-hmac-secret"
_, server, cleanup := setupTestReceiverWithToken(t, readToken, hmacKey)
defer cleanup()
t.Setenv("GITHUB_REPOSITORY_OWNER", "token-org")
t.Setenv("GITHUB_REPOSITORY", "token-repo")
t.Setenv("GITHUB_WORKFLOW", "ci.yml")
t.Setenv("GITHUB_JOB", "build")
t.Setenv("GITHUB_RUN_ID", "token-run-2")
pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", "wrong-token")
err := pushClient.Push(context.Background(), &summary.RunSummary{SampleCount: 1})
if err == nil {
t.Error("Push() with wrong token should fail")
}
}
func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*receiver.Store, *httptest.Server, func()) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := receiver.NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
server := httptest.NewServer(mux)
cleanup := func() {
server.Close()
_ = store.Close()
}
return store, server, cleanup
}