package receiver import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "testing" "edp.buildth.ing/DevFW-CICD/forgejo-runner-optimiser/internal/summary" ) func TestHandler_ReceiveMetrics(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() exec := ExecutionContext{ Organization: "test-org", Repository: "test-repo", Workflow: "ci.yml", Job: "build", RunID: "run-123", } pushToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job) payload := MetricsPayload{ Execution: exec, Summary: summary.RunSummary{ DurationSeconds: 60.0, SampleCount: 12, }, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+pushToken) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated) } var resp map[string]any if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp["status"] != "created" { t.Errorf("response status = %v, want %q", resp["status"], "created") } if resp["id"] == nil || resp["id"].(float64) == 0 { t.Error("response id is missing or zero") } } func TestHandler_ReceiveMetrics_InvalidJSON(t *testing.T) { h, cleanup := newTestHandler(t) defer cleanup() req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader([]byte("not json"))) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestHandler_ReceiveMetrics_MissingRunID(t *testing.T) { h, cleanup := newTestHandler(t) defer cleanup() payload := MetricsPayload{ Execution: ExecutionContext{ Organization: "test-org", Repository: "test-repo", // RunID is missing }, Summary: summary.RunSummary{}, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body)) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestHandler_GetByWorkflowJob(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() // Save metrics for different workflow/job combinations payloads := []*MetricsPayload{ {Execution: ExecutionContext{Organization: "org-x", Repository: "repo-y", Workflow: "ci.yml", Job: "build", RunID: "r1"}}, {Execution: ExecutionContext{Organization: "org-x", Repository: "repo-y", Workflow: "ci.yml", Job: "build", RunID: "r2"}}, {Execution: ExecutionContext{Organization: "org-x", Repository: "repo-y", Workflow: "ci.yml", Job: "test", RunID: "r3"}}, } for _, p := range payloads { if _, err := h.store.SaveMetric(p); err != nil { t.Fatalf("SaveMetric() error = %v", err) } } req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org-x/repo-y/ci.yml/build", nil) req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } var metrics []MetricResponse if err := json.NewDecoder(rec.Body).Decode(&metrics); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(metrics) != 2 { t.Errorf("got %d metrics, want 2", len(metrics)) } } func TestHandler_GetByWorkflowJob_NotFound(t *testing.T) { const readToken = "test-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/workflow/job", nil) req.Header.Set("Authorization", "Bearer "+readToken) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } var metrics []MetricResponse if err := json.NewDecoder(rec.Body).Decode(&metrics); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(metrics) != 0 { t.Errorf("got %d metrics, want 0", len(metrics)) } } func TestHandler_GetByWorkflowJob_WithToken(t *testing.T) { h, cleanup := newTestHandlerWithToken(t, "secret-token") defer cleanup() // Save a metric payload := &MetricsPayload{ Execution: ExecutionContext{Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", RunID: "r1"}, } if _, err := h.store.SaveMetric(payload); err != nil { t.Fatalf("SaveMetric() error = %v", err) } mux := http.NewServeMux() h.RegisterRoutes(mux) tests := []struct { name string authHeader string wantCode int }{ {"no auth header", "", http.StatusUnauthorized}, {"wrong format", "Basic dXNlcjpwYXNz", http.StatusUnauthorized}, {"wrong token", "Bearer wrong-token", http.StatusUnauthorized}, {"valid token", "Bearer secret-token", http.StatusOK}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/ci.yml/build", nil) if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) } rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != tt.wantCode { t.Errorf("status = %d, want %d", rec.Code, tt.wantCode) } }) } } func TestHandler_Health(t *testing.T) { h, cleanup := newTestHandler(t) defer cleanup() req := httptest.NewRequest(http.MethodGet, "/health", nil) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) } var resp map[string]string if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp["status"] != "ok" { t.Errorf("status = %q, want %q", resp["status"], "ok") } } func TestHandler_GenerateToken(t *testing.T) { h, cleanup := newTestHandlerWithToken(t, "secret-token") defer cleanup() body, _ := json.Marshal(TokenRequest{ Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer secret-token") req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } var resp TokenResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Token == "" { t.Error("expected non-empty token") } if len(resp.Token) != 64 { t.Errorf("token length = %d, want 64", len(resp.Token)) } } func TestHandler_GenerateToken_NoAuth(t *testing.T) { h, cleanup := newTestHandlerWithToken(t, "secret-token") defer cleanup() body, _ := json.Marshal(TokenRequest{ Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body)) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } func TestHandler_GenerateToken_MissingFields(t *testing.T) { h, cleanup := newTestHandlerWithToken(t, "secret-token") defer cleanup() // Missing job field body, _ := json.Marshal(TokenRequest{ Organization: "org", Repository: "repo", Workflow: "ci.yml", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer secret-token") req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestHandler_GenerateToken_NoReadToken(t *testing.T) { h, cleanup := newTestHandler(t) // no readToken configured defer cleanup() body, _ := json.Marshal(TokenRequest{ Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) { readToken := "secret-token" h, cleanup := newTestHandlerWithToken(t, readToken) defer cleanup() mux := http.NewServeMux() h.RegisterRoutes(mux) exec := ExecutionContext{ Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", RunID: "run-1", } validToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job) wrongScopeToken := GenerateScopedToken(readToken, "other-org", "repo", "ci.yml", "build") tests := []struct { name string authHeader string wantCode int }{ {"no auth", "", http.StatusUnauthorized}, {"wrong token", "Bearer wrong-token", http.StatusUnauthorized}, {"wrong scope", "Bearer " + wrongScopeToken, http.StatusUnauthorized}, {"valid token", "Bearer " + validToken, http.StatusCreated}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { payload := MetricsPayload{ Execution: exec, Summary: summary.RunSummary{SampleCount: 1}, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) } rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != tt.wantCode { t.Errorf("status = %d, want %d", rec.Code, tt.wantCode) } }) } } func TestHandler_ReceiveMetrics_RejectsWhenNoReadToken(t *testing.T) { h, cleanup := newTestHandlerWithToken(t, "") // no readToken configured defer cleanup() payload := MetricsPayload{ Execution: ExecutionContext{ Organization: "org", Repository: "repo", Workflow: "ci.yml", Job: "build", RunID: "run-1", }, Summary: summary.RunSummary{SampleCount: 1}, } body, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } func TestHandler_GetByWorkflowJob_RejectsWhenNoReadToken(t *testing.T) { h, cleanup := newTestHandlerWithToken(t, "") // no readToken configured defer cleanup() req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/ci.yml/build", nil) rec := httptest.NewRecorder() mux := http.NewServeMux() h.RegisterRoutes(mux) mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } func newTestHandler(t *testing.T) (*Handler, func()) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") store, err := NewStore(dbPath) if err != nil { t.Fatalf("NewStore() error = %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) handler := NewHandler(store, logger, "", "") // no auth — endpoints will reject return handler, func() { _ = store.Close() } } func newTestHandlerWithToken(t *testing.T, readToken string) (*Handler, func()) { t.Helper() return newTestHandlerWithKeys(t, readToken, readToken) } func newTestHandlerWithKeys(t *testing.T, readToken, hmacKey string) (*Handler, func()) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") store, err := NewStore(dbPath) if err != nil { t.Fatalf("NewStore() error = %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) handler := NewHandler(store, logger, readToken, hmacKey) return handler, func() { _ = store.Close() } }