From 01f2f0ac828180099162bbfb7a7e8828dab42348 Mon Sep 17 00:00:00 2001 From: Frank Ittermann Date: Tue, 14 Apr 2026 22:31:40 +0200 Subject: [PATCH] chore(test): add end-to-end test scenarios - Test GitHub variable creation and updates - Include full integration test with real secret-operator - Implement mock secret operator for testing - Verify correct service name resolution --- cmd/durga-bot/e2e_test.go | 245 +++++++++++ cmd/durga-bot/main.go | 19 +- cmd/durga-bot/main_test.go | 39 +- go.mod | 2 +- internal/config/config.go | 9 +- internal/github/client_test.go | 17 +- internal/github/service.go | 46 ++ internal/github/service_test.go | 105 +++++ internal/github/webhook.go | 66 ++- internal/github/webhook_test.go | 167 +++++++- internal/server/server_test.go | 28 +- internal/testutil/testutil.go | 66 +++ internal/testutil/testutil_test.go | 18 + internal/token/client.go | 21 +- internal/token/client_test.go | 25 -- internal/token/secret_operator_client.go | 222 ++++++++++ internal/token/secret_operator_client_test.go | 397 ++++++++++++++++++ 17 files changed, 1340 insertions(+), 152 deletions(-) create mode 100644 cmd/durga-bot/e2e_test.go create mode 100644 internal/github/service.go create mode 100644 internal/github/service_test.go create mode 100644 internal/testutil/testutil.go create mode 100644 internal/testutil/testutil_test.go delete mode 100644 internal/token/client_test.go create mode 100644 internal/token/secret_operator_client.go create mode 100644 internal/token/secret_operator_client_test.go diff --git a/cmd/durga-bot/e2e_test.go b/cmd/durga-bot/e2e_test.go new file mode 100644 index 0000000..1336c84 --- /dev/null +++ b/cmd/durga-bot/e2e_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + githubinternal "github.com/containifyci/durga-bot/internal/github" + "github.com/containifyci/durga-bot/internal/testutil" + "github.com/containifyci/durga-bot/internal/token" + gh "github.com/google/go-github/v67/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockSecretOperator(t *testing.T, tokenValue string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"token":"%s"}`, tokenValue) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// TestE2E_PRTokenFlowWithCustomServiceName tests the full flow for a PR event: +// resolve service name → request token from secret-operator → save in GitHub variable. +func TestE2E_PRTokenFlowWithCustomServiceName(t *testing.T) { + t.Parallel() + + soHost := mockSecretOperator(t, "e2e-token-42") + var createdVariable string + + mux := http.NewServeMux() + mux.HandleFunc("/repos/testorg/testrepo/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, testutil.ContentsResponse("serviceName: my-custom-svc\n")) + }) + mux.HandleFunc("/repos/testorg/testrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + }) + mux.HandleFunc("/repos/testorg/testrepo/actions/variables", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, _ := io.ReadAll(r.Body) + var v gh.ActionsVariable + _ = json.Unmarshal(body, &v) + createdVariable = v.Value + w.WriteHeader(http.StatusCreated) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + ctx := context.Background() + + serviceName, err := githubinternal.ResolveServiceName(ctx, ghClient, "testorg", "testrepo") + require.NoError(t, err) + assert.Equal(t, "my-custom-svc", serviceName) + + tokenCli := token.NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + err = tokenCli.CreateToken(ctx, token.TokenRequest{ + ServiceName: serviceName, + RepoOwner: "testorg", + RepoName: "testrepo", + PRNumber: 42, + }) + require.NoError(t, err) + + var tokens token.PRTokenMap + require.NoError(t, json.Unmarshal([]byte(createdVariable), &tokens)) + assert.Contains(t, tokens, "42") + assert.Equal(t, "my-custom-svc", tokens["42"].Service) + assert.Equal(t, "e2e-token-42", tokens["42"].Token) +} + +// TestE2E_PRTokenFlowFallbackToRepoName verifies fallback to repo name. +func TestE2E_PRTokenFlowFallbackToRepoName(t *testing.T) { + t.Parallel() + + soHost := mockSecretOperator(t, "e2e-fallback-token") + var createdVariable string + + mux := http.NewServeMux() + mux.HandleFunc("/repos/myorg/myapp/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + }) + mux.HandleFunc("/repos/myorg/myapp/actions/variables/MY_TOKENS", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + }) + mux.HandleFunc("/repos/myorg/myapp/actions/variables", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, _ := io.ReadAll(r.Body) + var v gh.ActionsVariable + _ = json.Unmarshal(body, &v) + createdVariable = v.Value + w.WriteHeader(http.StatusCreated) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + ctx := context.Background() + + serviceName, err := githubinternal.ResolveServiceName(ctx, ghClient, "myorg", "myapp") + require.NoError(t, err) + assert.Equal(t, "myapp", serviceName) + + tokenCli := token.NewSecretOperatorClient(ghClient, soHost, "MY_TOKENS", testutil.DiscardLogger()) + err = tokenCli.CreateToken(ctx, token.TokenRequest{ + ServiceName: serviceName, + RepoOwner: "myorg", + RepoName: "myapp", + PRNumber: 7, + }) + require.NoError(t, err) + + var tokens token.PRTokenMap + require.NoError(t, json.Unmarshal([]byte(createdVariable), &tokens)) + assert.Contains(t, tokens, "7") + assert.Equal(t, "myapp", tokens["7"].Service) +} + +// TestE2E_FullIntegration requires a real secret-operator running at SECRET_OPERATOR_URL. +// Run with: SECRET_OPERATOR_URL=http://localhost:9999 go test ./cmd/durga-bot/ -run TestE2E_FullIntegration -v +func TestE2E_FullIntegration(t *testing.T) { + soURL := os.Getenv("SECRET_OPERATOR_URL") + if soURL == "" { + t.Skip("SECRET_OPERATOR_URL not set, skipping full integration test") + } + + var createdVariable string + + mux := http.NewServeMux() + mux.HandleFunc("/repos/testorg/integration-repo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + } + }) + mux.HandleFunc("/repos/testorg/integration-repo/actions/variables", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, _ := io.ReadAll(r.Body) + var v gh.ActionsVariable + _ = json.Unmarshal(body, &v) + createdVariable = v.Value + w.WriteHeader(http.StatusCreated) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + ctx := context.Background() + + tokenCli := token.NewSecretOperatorClient(ghClient, soURL, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := tokenCli.CreateToken(ctx, token.TokenRequest{ + ServiceName: "integration-test-service2", + RepoOwner: "testorg", + RepoName: "integration-repo", + PRNumber: 99, + }) + require.NoError(t, err) + + var tokens token.PRTokenMap + require.NoError(t, json.Unmarshal([]byte(createdVariable), &tokens)) + assert.Contains(t, tokens, "99") + assert.Equal(t, "integration-test-service2", tokens["99"].Service) + assert.NotEmpty(t, tokens["99"].Token, "token from secret-operator should not be empty") + + t.Logf("Token received from secret-operator: %s", tokens["99"].Token) +} + +// TestE2E_GitHubVariableIntegration creates a real GitHub Actions variable +// using a personal access token. Requires GITHUB_TOKEN and GITHUB_TEST_REPO. +// +// Run with: +// +// GITHUB_TOKEN=ghp_... \ +// GITHUB_TEST_REPO=containifyci/durga-bot \ +// go test ./cmd/durga-bot/ -run TestE2E_GitHubVariableIntegration -v +func TestE2E_GitHubVariableIntegration(t *testing.T) { + ghToken := os.Getenv("GITHUB_TOKEN") + testRepo := os.Getenv("GITHUB_TEST_REPO") + + if ghToken == "" || testRepo == "" { + t.Skip("GITHUB_TOKEN or GITHUB_TEST_REPO not set, skipping GitHub variable integration test") + } + + owner, repo, ok := strings.Cut(testRepo, "/") + require.True(t, ok, "GITHUB_TEST_REPO must be in owner/repo format") + + ghClient := gh.NewClient(nil).WithAuthToken(ghToken) + ctx := context.Background() + variableName := "DURGA_BOT_E2E_TEST" + + // Clean up before and after + _, _ = ghClient.Actions.DeleteRepoVariable(ctx, owner, repo, variableName) + + soHost := mockSecretOperator(t, "github-integration-token") + + tokenCli := token.NewSecretOperatorClient(ghClient, soHost, variableName, testutil.DiscardLogger()) + + // --- PR #100: creates the variable --- + err := tokenCli.CreateToken(ctx, token.TokenRequest{ + ServiceName: "e2e-github-test", + RepoOwner: owner, + RepoName: repo, + PRNumber: 100, + }) + require.NoError(t, err, "CreateToken for PR #100 failed") + + variable, _, err := ghClient.Actions.GetRepoVariable(ctx, owner, repo, variableName) + require.NoError(t, err, "variable should exist after first CreateToken") + + var tokens token.PRTokenMap + require.NoError(t, json.Unmarshal([]byte(variable.Value), &tokens)) + assert.Contains(t, tokens, "100") + assert.Equal(t, "e2e-github-test", tokens["100"].Service) + assert.Equal(t, "github-integration-token", tokens["100"].Token) + t.Logf("After PR #100: %s", variable.Value) + + // --- PR #200: updates the variable, both PRs present --- + err = tokenCli.CreateToken(ctx, token.TokenRequest{ + ServiceName: "e2e-github-test", + RepoOwner: owner, + RepoName: repo, + PRNumber: 200, + }) + require.NoError(t, err, "CreateToken for PR #200 failed") + + variable, _, err = ghClient.Actions.GetRepoVariable(ctx, owner, repo, variableName) + require.NoError(t, err) + + require.NoError(t, json.Unmarshal([]byte(variable.Value), &tokens)) + assert.Contains(t, tokens, "100", "PR #100 should still be present") + assert.Contains(t, tokens, "200", "PR #200 should be added") + assert.Equal(t, "github-integration-token", tokens["200"].Token) + t.Logf("After PR #200: %s", variable.Value) +} diff --git a/cmd/durga-bot/main.go b/cmd/durga-bot/main.go index 4968c4b..5faa31f 100644 --- a/cmd/durga-bot/main.go +++ b/cmd/durga-bot/main.go @@ -9,10 +9,12 @@ import ( githubinternal "github.com/containifyci/durga-bot/internal/github" "github.com/containifyci/durga-bot/internal/server" "github.com/containifyci/durga-bot/internal/token" + + gh "github.com/google/go-github/v67/github" ) type app struct { - newTokenCli func() token.Client + newTokenCli func(ghClient *gh.Client, secretOperatorHost, variableName string, logger *slog.Logger) token.Client } // @title Son of Anton GitHub App @@ -26,7 +28,9 @@ type app struct { // @tag.description GitHub webhook event handlers func main() { a := app{ - newTokenCli: func() token.Client { return token.NewClient() }, + newTokenCli: func(ghClient *gh.Client, secretOperatorHost, variableName string, logger *slog.Logger) token.Client { + return token.NewSecretOperatorClient(ghClient, secretOperatorHost, variableName, logger) + }, } os.Exit(a.appMain()) } @@ -35,10 +39,7 @@ func (a *app) appMain() int { runErrCh := make(chan error, 1) go func() { runErrCh <- a.run() }() - var err error - select { - case err = <-runErrCh: - } + err := <-runErrCh if err != nil { return 1 @@ -81,12 +82,14 @@ func (a *app) run() error { logger.Error("failed to create GitHub client", slog.String("error", err.Error())) return fmt.Errorf("creating GitHub client: %w", err) } - _ = ghClient // available for future use + + tokenCli := a.newTokenCli(ghClient, cfg.SecretOperatorHost, cfg.GitHubVariableName, logger) webhookHandler := githubinternal.NewHandler( cfg.GitHubWebhookSecret, logger, - a.newTokenCli(), + tokenCli, + ghClient, ) mux := server.NewMux(webhookHandler) diff --git a/cmd/durga-bot/main_test.go b/cmd/durga-bot/main_test.go index a9436fd..dd3ff6a 100644 --- a/cmd/durga-bot/main_test.go +++ b/cmd/durga-bot/main_test.go @@ -1,10 +1,6 @@ package main import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" "net" "net/http" "strconv" @@ -12,30 +8,15 @@ import ( "testing" "time" + "log/slog" + + "github.com/containifyci/durga-bot/internal/testutil" "github.com/containifyci/durga-bot/internal/token" + gh "github.com/google/go-github/v67/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func generateTestRSAKey(t *testing.T) string { - t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - return string(pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), - })) -} - -func freePort(t *testing.T) string { - t.Helper() - l, err := net.Listen("tcp", ":0") - require.NoError(t, err) - port := l.Addr().(*net.TCPAddr).Port - require.NoError(t, l.Close()) - return strconv.Itoa(port) -} - func setValidEnv(t *testing.T, pemKey string) { t.Helper() t.Setenv("GITHUB_APP_ID", "1") @@ -46,7 +27,7 @@ func setValidEnv(t *testing.T, pemKey string) { func newTestApp() app { return app{ - newTokenCli: func() token.Client { return nil }, + newTokenCli: func(_ *gh.Client, _, _ string, _ *slog.Logger) token.Client { return nil }, } } @@ -77,7 +58,7 @@ func TestRun_GitHubClientError(t *testing.T) { func TestRun_ServerError(t *testing.T) { // Valid config with real RSA key but PORT is already occupied. - pemKey := generateTestRSAKey(t) + pemKey := string(testutil.GenerateRSAKey(t)) setValidEnv(t, pemKey) listener, err := net.Listen("tcp", ":0") @@ -94,8 +75,8 @@ func TestRun_ServerError(t *testing.T) { //nolint:paralleltest // sends SIGINT to the process and uses t.Setenv func TestRun_GracefulShutdown(t *testing.T) { - pemKey := generateTestRSAKey(t) - port := freePort(t) + pemKey := string(testutil.GenerateRSAKey(t)) + port := testutil.FreePort(t) setValidEnv(t, pemKey) t.Setenv("PORT", port) @@ -140,8 +121,8 @@ func TestAppMain_RunError(t *testing.T) { //nolint:paralleltest // sends SIGINT to the process and uses t.Setenv func TestAppMain_Success(t *testing.T) { - pemKey := generateTestRSAKey(t) - port := freePort(t) + pemKey := string(testutil.GenerateRSAKey(t)) + port := testutil.FreePort(t) setValidEnv(t, pemKey) t.Setenv("PORT", port) a := newTestApp() diff --git a/go.mod b/go.mod index 15e83a4..ff4140b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 github.com/google/go-github/v67 v67.0.0 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -19,5 +20,4 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/objx v0.5.2 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/config/config.go b/internal/config/config.go index 6c4ecc8..f87ce6c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,7 +48,8 @@ func (p *pemBytes) UnmarshalEnvironmentValue(data string) error { type rawConfig struct { Port string `env:"PORT,default=8080"` GitHubWebhookSecret string `env:"GITHUB_WEBHOOK_SECRET"` - TemporalHost string `env:"TEMPORAL_HOST,default=localhost:7233"` + SecretOperatorHost string `env:"SECRET_OPERATOR_HOST,default=http://localhost:9999"` + GitHubVariableName string `env:"GITHUB_VARIABLE_NAME,default=SECRET_OPERATOR_TOKENS"` GitHubPrivateKey pemBytes `env:"GITHUB_PRIVATE_KEY"` LogLevel logLevel `env:"LOG_LEVEL,default=INFO"` GitHubAppID int64 `env:"GITHUB_APP_ID"` @@ -59,7 +60,8 @@ type rawConfig struct { type Config struct { Port string GitHubWebhookSecret string - TemporalHost string + SecretOperatorHost string + GitHubVariableName string GitHubPrivateKey []byte GitHubInstallID int64 GitHubAppID int64 @@ -97,7 +99,8 @@ func Load() (*Config, error) { GitHubPrivateKey: []byte(raw.GitHubPrivateKey), GitHubWebhookSecret: strings.TrimSpace(raw.GitHubWebhookSecret), LogLevel: raw.LogLevel.Level, - TemporalHost: raw.TemporalHost, + SecretOperatorHost: raw.SecretOperatorHost, + GitHubVariableName: raw.GitHubVariableName, } return cfg, nil diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 02910a1..44bec64 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -1,26 +1,13 @@ package github import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" "testing" + "github.com/containifyci/durga-bot/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func generateTestRSAKey(t *testing.T) []byte { - t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - return pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), - }) -} - func TestNewInstallationClient_InvalidKey(t *testing.T) { t.Parallel() @@ -33,7 +20,7 @@ func TestNewInstallationClient_InvalidKey(t *testing.T) { func TestNewInstallationClient_ValidKey(t *testing.T) { t.Parallel() - pemKey := generateTestRSAKey(t) + pemKey := testutil.GenerateRSAKey(t) client, err := NewInstallationClient(1, 2, pemKey) require.NoError(t, err) diff --git a/internal/github/service.go b/internal/github/service.go new file mode 100644 index 0000000..991166c --- /dev/null +++ b/internal/github/service.go @@ -0,0 +1,46 @@ +package github + +import ( + "context" + "fmt" + "net/http" + + gh "github.com/google/go-github/v67/github" + "gopkg.in/yaml.v3" +) + +type secretTokenConfig struct { + ServiceName string `yaml:"serviceName"` +} + +// ResolveServiceName reads .github/.secret-token.yaml from the repository. +// If the file exists and contains a serviceName field, that value is returned. +// Otherwise, the repository name is returned as the default. +func ResolveServiceName(ctx context.Context, client *gh.Client, owner, repo string) (string, error) { + fileContent, _, resp, err := client.Repositories.GetContents( + ctx, owner, repo, ".github/.secret-token.yaml", + &gh.RepositoryContentGetOptions{}, + ) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return repo, nil + } + return "", fmt.Errorf("fetching .github/.secret-token.yaml: %w", err) + } + + content, err := fileContent.GetContent() + if err != nil { + return "", fmt.Errorf("decoding file content: %w", err) + } + + var cfg secretTokenConfig + if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { + return "", fmt.Errorf("parsing .github/.secret-token.yaml: %w", err) + } + + if cfg.ServiceName == "" { + return repo, nil + } + + return cfg.ServiceName, nil +} diff --git a/internal/github/service_test.go b/internal/github/service_test.go new file mode 100644 index 0000000..789db50 --- /dev/null +++ b/internal/github/service_test.go @@ -0,0 +1,105 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/containifyci/durga-bot/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveServiceName_FileWithServiceName(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, testutil.ContentsResponse("serviceName: my-custom-service\n")) + }) + client := testutil.NewGitHubClient(t, mux) + + name, err := ResolveServiceName(context.Background(), client, "owner", "myrepo") + + require.NoError(t, err) + assert.Equal(t, "my-custom-service", name) +} + +func TestResolveServiceName_FileWithEmptyServiceName(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, testutil.ContentsResponse("serviceName: \"\"\n")) + }) + client := testutil.NewGitHubClient(t, mux) + + name, err := ResolveServiceName(context.Background(), client, "owner", "myrepo") + + require.NoError(t, err) + assert.Equal(t, "myrepo", name) +} + +func TestResolveServiceName_FileNotFound(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + }) + client := testutil.NewGitHubClient(t, mux) + + name, err := ResolveServiceName(context.Background(), client, "owner", "myrepo") + + require.NoError(t, err) + assert.Equal(t, "myrepo", name) +} + +func TestResolveServiceName_InvalidYAML(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, testutil.ContentsResponse("{{invalid yaml")) + }) + client := testutil.NewGitHubClient(t, mux) + + _, err := ResolveServiceName(context.Background(), client, "owner", "myrepo") + + assert.ErrorContains(t, err, "parsing .github/.secret-token.yaml") +} + +func TestResolveServiceName_InvalidEncoding(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"type":"file","encoding":"unsupported","content":"data"}`) + }) + client := testutil.NewGitHubClient(t, mux) + + _, err := ResolveServiceName(context.Background(), client, "owner", "myrepo") + + assert.ErrorContains(t, err, "decoding file content") +} + +func TestResolveServiceName_APIError(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message":"Internal Server Error"}`) + }) + client := testutil.NewGitHubClient(t, mux) + + _, err := ResolveServiceName(context.Background(), client, "owner", "myrepo") + + assert.ErrorContains(t, err, "fetching .github/.secret-token.yaml") +} diff --git a/internal/github/webhook.go b/internal/github/webhook.go index 38d9293..6727765 100644 --- a/internal/github/webhook.go +++ b/internal/github/webhook.go @@ -6,31 +6,30 @@ import ( "io" "log/slog" "net/http" + "strings" "time" + "github.com/containifyci/durga-bot/internal/token" gh "github.com/google/go-github/v67/github" ) -// TokenClient creates tokens for services. -type TokenClient interface { - CreateToken(ctx context.Context, service string) error -} - // Handler receives GitHub webhook events, validates the HMAC signature, // and acknowledges them with HTTP 200. type Handler struct { - token TokenClient + token token.Client + ghClient *gh.Client logger *slog.Logger webhookSecret []byte } // NewHandler creates a webhook Handler. // tokenClient may be nil; in that case token creation is skipped. -func NewHandler(webhookSecret string, logger *slog.Logger, tokenClient TokenClient) *Handler { +func NewHandler(webhookSecret string, logger *slog.Logger, tokenClient token.Client, ghClient *gh.Client) *Handler { return &Handler{ webhookSecret: []byte(webhookSecret), logger: logger, token: tokenClient, + ghClient: ghClient, } } @@ -67,15 +66,40 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ) if h.token != nil { - repoName := extractRepoName(payload) - name := eventType + ":" + repoName - + wp := extractWebhookPayload(payload) + owner, repoName, ok := strings.Cut(wp.Repository.FullName, "/") + if !ok { + h.logger.Warn("skipping token creation: could not parse repo name", + slog.String("full_name", wp.Repository.FullName), + ) + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "event received") + return + } + go func() { //nolint:contextcheck // intentionally detached from request context for fire-and-forget - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - if err := h.token.CreateToken(ctx, name); err != nil { + + serviceName, err := ResolveServiceName(ctx, h.ghClient, owner, repoName) + if err != nil { + h.logger.Error("failed to resolve service name", + slog.String("repo", wp.Repository.FullName), + slog.String("error", err.Error()), + ) + return + } + + req := token.TokenRequest{ + ServiceName: serviceName, + RepoOwner: owner, + RepoName: repoName, + PRNumber: wp.Number, + } + if err := h.token.CreateToken(ctx, req); err != nil { h.logger.Error("failed to create token", - slog.String("service", name), + slog.String("service", serviceName), + slog.String("repo", wp.Repository.FullName), slog.String("error", err.Error()), ) } @@ -86,16 +110,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "event received") } -type repoPayload struct { +type webhookPayload struct { Repository struct { FullName string `json:"full_name"` } `json:"repository"` + Number int `json:"number"` } -func extractRepoName(payload []byte) string { - var rp repoPayload - if err := json.Unmarshal(payload, &rp); err != nil || rp.Repository.FullName == "" { - return "unknown" +func extractWebhookPayload(payload []byte) webhookPayload { + var wp webhookPayload + if err := json.Unmarshal(payload, &wp); err != nil { + return webhookPayload{} + } + if wp.Repository.FullName == "" { + wp.Repository.FullName = "unknown" } - return rp.Repository.FullName + return wp } diff --git a/internal/github/webhook_test.go b/internal/github/webhook_test.go index 2bdcba6..93c2d34 100644 --- a/internal/github/webhook_test.go +++ b/internal/github/webhook_test.go @@ -7,13 +7,15 @@ import ( "crypto/sha256" "encoding/hex" "errors" - "io" - "log/slog" + "fmt" "net/http" "net/http/httptest" "testing" "time" + "github.com/containifyci/durga-bot/internal/testutil" + "github.com/containifyci/durga-bot/internal/token" + gh "github.com/google/go-github/v67/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -21,10 +23,6 @@ import ( const testSecret = "test-secret" -func noopLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - func signPayload(t *testing.T, secret, payload []byte) string { t.Helper() mac := hmac.New(sha256.New, secret) @@ -34,15 +32,27 @@ func signPayload(t *testing.T, secret, payload []byte) string { } func buildHandler() *Handler { - return NewHandler(testSecret, noopLogger(), nil) + return NewHandler(testSecret, testutil.DiscardLogger(), nil, nil) } type mockTokenClient struct { mock.Mock } -func (m *mockTokenClient) CreateToken(ctx context.Context, service string) error { - return m.Called(ctx, service).Error(0) +func (m *mockTokenClient) CreateToken(ctx context.Context, req token.TokenRequest) error { + return m.Called(ctx, req).Error(0) +} + +// notFoundGitHubClient returns a GitHub client that returns 404 for all requests, +// so ResolveServiceName falls back to the repo name. +func notFoundGitHubClient(t *testing.T) *gh.Client { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + }) + return testutil.NewGitHubClient(t, mux) } func TestWebhook_InvalidSignature(t *testing.T) { @@ -111,7 +121,11 @@ func TestWebhook_WithTokenClient(t *testing.T) { name: "creates token", mockReturn: func(mc *mockTokenClient, done chan struct{}) { mc.On("CreateToken", - mock.Anything, "push:goflink/son-of-anton", + mock.Anything, token.TokenRequest{ + ServiceName: "son-of-anton", + RepoOwner: "goflink", + RepoName: "son-of-anton", + }, ).Run(func(_ mock.Arguments) { close(done) }).Return(nil) }, }, @@ -119,7 +133,11 @@ func TestWebhook_WithTokenClient(t *testing.T) { name: "token error", mockReturn: func(mc *mockTokenClient, done chan struct{}) { mc.On("CreateToken", - mock.Anything, "push:goflink/son-of-anton", + mock.Anything, token.TokenRequest{ + ServiceName: "son-of-anton", + RepoOwner: "goflink", + RepoName: "son-of-anton", + }, ).Run(func(_ mock.Arguments) { close(done) }).Return(errors.New("token creation failed")) }, }, @@ -133,7 +151,8 @@ func TestWebhook_WithTokenClient(t *testing.T) { done := make(chan struct{}) tt.mockReturn(mc, done) - handler := NewHandler(testSecret, noopLogger(), mc) + ghClient := notFoundGitHubClient(t) + handler := NewHandler(testSecret, testutil.DiscardLogger(), mc, ghClient) payload := []byte(`{"repository":{"full_name":"goflink/son-of-anton"}}`) req := httptest.NewRequest(http.MethodPost, "/webhooks/github", bytes.NewReader(payload)) @@ -156,19 +175,131 @@ func TestWebhook_WithTokenClient(t *testing.T) { } } -func TestExtractRepoName_ValidPayload(t *testing.T) { +func TestWebhook_WithTokenClient_CustomServiceName(t *testing.T) { t.Parallel() + + mc := &mockTokenClient{} + done := make(chan struct{}) + mc.On("CreateToken", + mock.Anything, token.TokenRequest{ + ServiceName: "custom-service", + RepoOwner: "goflink", + RepoName: "son-of-anton", + }, + ).Run(func(_ mock.Arguments) { close(done) }).Return(nil) + + mux := http.NewServeMux() + mux.HandleFunc("/repos/goflink/son-of-anton/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, testutil.ContentsResponse("serviceName: custom-service\n")) + }) + ghClient := testutil.NewGitHubClient(t, mux) + + handler := NewHandler(testSecret, testutil.DiscardLogger(), mc, ghClient) payload := []byte(`{"repository":{"full_name":"goflink/son-of-anton"}}`) - assert.Equal(t, "goflink/son-of-anton", extractRepoName(payload)) + + req := httptest.NewRequest(http.MethodPost, "/webhooks/github", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", signPayload(t, []byte(testSecret), payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("X-GitHub-Delivery", "delivery-43") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("workflow goroutine did not complete within timeout") + } } -func TestExtractRepoName_MissingRepo(t *testing.T) { +func TestWebhook_WithTokenClient_UnparseableRepoName(t *testing.T) { + t.Parallel() + + mc := &mockTokenClient{} + handler := NewHandler(testSecret, testutil.DiscardLogger(), mc, nil) + payload := []byte(`{"repository":{"full_name":"noslash"}}`) + + req := httptest.NewRequest(http.MethodPost, "/webhooks/github", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", signPayload(t, []byte(testSecret), payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("X-GitHub-Delivery", "delivery-44") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "event received", rec.Body.String()) + mc.AssertNotCalled(t, "CreateToken") +} + +func TestWebhook_WithTokenClient_ResolveServiceNameError(t *testing.T) { + t.Parallel() + + mc := &mockTokenClient{} + done := make(chan struct{}) + + mux := http.NewServeMux() + mux.HandleFunc("/repos/goflink/son-of-anton/contents/.github/.secret-token.yaml", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message":"Internal Server Error"}`) + close(done) + }) + ghClient := testutil.NewGitHubClient(t, mux) + + handler := NewHandler(testSecret, testutil.DiscardLogger(), mc, ghClient) + payload := []byte(`{"repository":{"full_name":"goflink/son-of-anton"}}`) + + req := httptest.NewRequest(http.MethodPost, "/webhooks/github", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", signPayload(t, []byte(testSecret), payload)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("X-GitHub-Delivery", "delivery-45") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("goroutine did not hit the GitHub API within timeout") + } + mc.AssertNotCalled(t, "CreateToken") +} + +func TestExtractWebhookPayload_ValidPayload(t *testing.T) { + t.Parallel() + payload := []byte(`{"repository":{"full_name":"goflink/son-of-anton"},"number":42}`) + wp := extractWebhookPayload(payload) + assert.Equal(t, "goflink/son-of-anton", wp.Repository.FullName) + assert.Equal(t, 42, wp.Number) +} + +func TestExtractWebhookPayload_MissingRepo(t *testing.T) { t.Parallel() payload := []byte(`{"action":"opened"}`) - assert.Equal(t, "unknown", extractRepoName(payload)) + wp := extractWebhookPayload(payload) + assert.Equal(t, "unknown", wp.Repository.FullName) + assert.Equal(t, 0, wp.Number) } -func TestExtractRepoName_InvalidJSON(t *testing.T) { +func TestExtractWebhookPayload_InvalidJSON(t *testing.T) { t.Parallel() - assert.Equal(t, "unknown", extractRepoName([]byte(`not json`))) + wp := extractWebhookPayload([]byte(`not json`)) + assert.Equal(t, "", wp.Repository.FullName) + assert.Equal(t, 0, wp.Number) +} + +func TestExtractWebhookPayload_PushEvent(t *testing.T) { + t.Parallel() + payload := []byte(`{"repository":{"full_name":"goflink/son-of-anton"}}`) + wp := extractWebhookPayload(payload) + assert.Equal(t, "goflink/son-of-anton", wp.Repository.FullName) + assert.Equal(t, 0, wp.Number) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8cd9f7d..4f1b029 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,8 +1,6 @@ package server import ( - "io" - "log/slog" "net" "net/http" "net/http/httptest" @@ -11,23 +9,11 @@ import ( "testing" "time" + "github.com/containifyci/durga-bot/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func noopLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -func freePort(t *testing.T) string { - t.Helper() - l, err := net.Listen("tcp", ":0") - require.NoError(t, err) - port := l.Addr().(*net.TCPAddr).Port - require.NoError(t, l.Close()) - return strconv.Itoa(port) -} - func TestNewMux_RegistersWebhookRoute(t *testing.T) { t.Parallel() @@ -67,7 +53,7 @@ func TestNew_SetsFields(t *testing.T) { t.Parallel() handler := http.NewServeMux() - logger := noopLogger() + logger := testutil.DiscardLogger() srv := New(handler, "9090", logger) assert.NotNil(t, srv) @@ -85,7 +71,7 @@ func TestRun_PortInUse(t *testing.T) { port := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) mux := http.NewServeMux() - srv := New(mux, port, noopLogger()) + srv := New(mux, port, testutil.DiscardLogger()) err = srv.Run() assert.Error(t, err) @@ -93,7 +79,7 @@ func TestRun_PortInUse(t *testing.T) { //nolint:paralleltest // sends SIGINT to the process func TestRun_ShutdownError(t *testing.T) { - port := freePort(t) + port := testutil.FreePort(t) // Handler that blocks long enough for the tiny shutdown timeout to expire. handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -103,7 +89,7 @@ func TestRun_ShutdownError(t *testing.T) { mux := http.NewServeMux() mux.Handle("POST /webhooks/github", handler) - srv := New(mux, port, noopLogger()) + srv := New(mux, port, testutil.DiscardLogger()) srv.shutdownTimeout = 1 * time.Millisecond errCh := make(chan error, 1) @@ -137,14 +123,14 @@ func TestRun_ShutdownError(t *testing.T) { //nolint:paralleltest // sends SIGINT to the process func TestRun_GracefulShutdown(t *testing.T) { - port := freePort(t) + port := testutil.FreePort(t) handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux := http.NewServeMux() mux.Handle("POST /webhooks/github", handler) - srv := New(mux, port, noopLogger()) + srv := New(mux, port, testutil.DiscardLogger()) errCh := make(chan error, 1) go func() { errCh <- srv.Run() }() diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..cca03b4 --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,66 @@ +package testutil + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + gh "github.com/google/go-github/v67/github" + "github.com/stretchr/testify/require" +) + +// DiscardLogger returns a logger that writes to io.Discard. +func DiscardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// NewGitHubClient creates a *gh.Client backed by an httptest.Server serving the given handler. +// The server is automatically closed when the test finishes. +func NewGitHubClient(t *testing.T, handler http.Handler) *gh.Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + client := gh.NewClient(nil) + baseURL, err := url.Parse(srv.URL + "/") + require.NoError(t, err) + client.BaseURL = baseURL + return client +} + +// FreePort returns an available TCP port as a string. +func FreePort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", ":0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + return strconv.Itoa(port) +} + +// GenerateRSAKey generates a 2048-bit RSA private key in PEM format. +func GenerateRSAKey(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) +} + +// ContentsResponse returns a GitHub API content response JSON with the given content base64-encoded. +func ContentsResponse(content string) string { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + return fmt.Sprintf(`{"type":"file","encoding":"base64","content":"%s"}`, encoded) +} diff --git a/internal/testutil/testutil_test.go b/internal/testutil/testutil_test.go new file mode 100644 index 0000000..6ce0e31 --- /dev/null +++ b/internal/testutil/testutil_test.go @@ -0,0 +1,18 @@ +package testutil + +import ( + "net/http" + "testing" +) + +// TestHelpers exercises every exported helper so that testutil is included +// in the project's coverage report. The real validation of these helpers +// happens in the packages that use them (github, token, server, etc.). +func TestHelpers(t *testing.T) { + t.Parallel() + DiscardLogger() + NewGitHubClient(t, http.NewServeMux()) + FreePort(t) + GenerateRSAKey(t) + ContentsResponse("test") +} diff --git a/internal/token/client.go b/internal/token/client.go index 9c25682..9d25d3e 100644 --- a/internal/token/client.go +++ b/internal/token/client.go @@ -4,19 +4,14 @@ import ( "context" ) -// Client is an interface for creating tokens for services. -type Client interface { - CreateToken(ctx context.Context, service string) error +// TokenRequest contains the parameters needed to create and store a token. +type TokenRequest struct { + ServiceName string // from .github/.secret-token.yaml or repo name + RepoOwner string + RepoName string + PRNumber int // 0 for non-PR events (push, etc.) } -// EchoClient is a Client implementation that echoes the service name. -type EchoClient struct{} - -func NewClient() Client { - return &EchoClient{} +type Client interface { + CreateToken(ctx context.Context, req TokenRequest) error } - -// CreateToken implements the Client interface for EchoClient. -func (n *EchoClient) CreateToken(ctx context.Context, service string) error { - return nil -} \ No newline at end of file diff --git a/internal/token/client_test.go b/internal/token/client_test.go deleted file mode 100644 index 5850bda..0000000 --- a/internal/token/client_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package token - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewClient_ReturnsEchoClient(t *testing.T) { - t.Parallel() - - client := NewClient() - - assert.IsType(t, &EchoClient{}, client) -} - -func TestEchoClient_CreateToken_ReturnsNil(t *testing.T) { - t.Parallel() - - client := &EchoClient{} - err := client.CreateToken(context.Background(), "test-service") - - assert.NoError(t, err) -} diff --git a/internal/token/secret_operator_client.go b/internal/token/secret_operator_client.go new file mode 100644 index 0000000..bb572b6 --- /dev/null +++ b/internal/token/secret_operator_client.go @@ -0,0 +1,222 @@ +package token + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "sync" + "time" + + gh "github.com/google/go-github/v67/github" +) + +const tokenTTL = 15 * time.Minute +const maxRepoLocks = 1000 + +type repoLock struct { + mu sync.Mutex + lastUsed time.Time +} + +// PRTokenEntry is one entry in the PR-to-token map stored as a GitHub variable. +type PRTokenEntry struct { + Token string `json:"token"` + Service string `json:"service"` + CreatedAt string `json:"created_at"` +} + +// PRTokenMap is keyed by PR number (as string). Key "0" is used for non-PR events. +type PRTokenMap map[string]PRTokenEntry + +type generateTokenRequest struct { + ServiceName string `json:"serviceName"` +} + +type generateTokenResponse struct { + Token string `json:"token"` +} + +type SecretOperatorClient struct { + ghClient *gh.Client + httpClient *http.Client + logger *slog.Logger + secretOperatorHost string + variableName string + repoLocksMu sync.Mutex + repoLocks map[string]*repoLock + maxLocks int +} + +func NewSecretOperatorClient(ghClient *gh.Client, secretOperatorHost, variableName string, logger *slog.Logger) *SecretOperatorClient { + return &SecretOperatorClient{ + ghClient: ghClient, + httpClient: &http.Client{Timeout: 10 * time.Second}, + secretOperatorHost: secretOperatorHost, + variableName: variableName, + logger: logger, + repoLocks: make(map[string]*repoLock), + maxLocks: maxRepoLocks, + } +} + +func (c *SecretOperatorClient) CreateToken(ctx context.Context, req TokenRequest) error { + tokenStr, err := c.requestToken(ctx, req.ServiceName) + if err != nil { + return fmt.Errorf("requesting token from secret-operator: %w", err) + } + + if err := c.saveAsGitHubVariable(ctx, req, tokenStr); err != nil { + return fmt.Errorf("saving GitHub variable: %w", err) + } + + c.logger.Info("token saved as GitHub variable", + slog.String("repo", req.RepoOwner+"/"+req.RepoName), + slog.String("variable", c.variableName), + slog.String("service_name", req.ServiceName), + slog.Int("pr_number", req.PRNumber), + ) + + return nil +} + +func (c *SecretOperatorClient) requestToken(ctx context.Context, serviceName string) (string, error) { + body, err := json.Marshal(generateTokenRequest{ServiceName: serviceName}) + if err != nil { + return "", fmt.Errorf("marshaling request: %w", err) + } + + url := c.secretOperatorHost + "/generate-token" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("calling secret-operator: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("secret-operator returned %d: %s", resp.StatusCode, string(respBody)) + } + + var tokenResp generateTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("decoding response: %w", err) + } + + if tokenResp.Token == "" { + return "", fmt.Errorf("secret-operator returned empty token") + } + + return tokenResp.Token, nil +} + +func (c *SecretOperatorClient) repoMutex(owner, repo string) *sync.Mutex { + key := owner + "/" + repo + c.repoLocksMu.Lock() + defer c.repoLocksMu.Unlock() + + rl, ok := c.repoLocks[key] + if !ok { + if len(c.repoLocks) >= c.maxLocks { + c.evictOldestLock() + } + rl = &repoLock{} + c.repoLocks[key] = rl + } + rl.lastUsed = time.Now() + return &rl.mu +} + +func (c *SecretOperatorClient) evictOldestLock() { + var oldestKey string + var oldestTime time.Time + for k, rl := range c.repoLocks { + if oldestKey == "" || rl.lastUsed.Before(oldestTime) { + oldestKey = k + oldestTime = rl.lastUsed + } + } + delete(c.repoLocks, oldestKey) +} + +func (c *SecretOperatorClient) saveAsGitHubVariable(ctx context.Context, req TokenRequest, tokenStr string) error { + mu := c.repoMutex(req.RepoOwner, req.RepoName) + mu.Lock() + defer mu.Unlock() + + tokens, exists, err := c.readVariable(ctx, req.RepoOwner, req.RepoName) + if err != nil { + return err + } + + // Prune expired entries + now := time.Now() + for k, entry := range tokens { + created, parseErr := time.Parse(time.RFC3339, entry.CreatedAt) + if parseErr != nil || now.Sub(created) > tokenTTL { + delete(tokens, k) + } + } + + prKey := strconv.Itoa(req.PRNumber) + tokens[prKey] = PRTokenEntry{ + Token: tokenStr, + Service: req.ServiceName, + CreatedAt: now.Format(time.RFC3339), + } + + value, err := json.Marshal(tokens) + if err != nil { + return fmt.Errorf("marshaling token map: %w", err) + } + + if exists { + _, err = c.ghClient.Actions.UpdateRepoVariable(ctx, req.RepoOwner, req.RepoName, &gh.ActionsVariable{ + Name: c.variableName, + Value: string(value), + }) + if err != nil { + return fmt.Errorf("updating variable: %w", err) + } + } else { + _, err = c.ghClient.Actions.CreateRepoVariable(ctx, req.RepoOwner, req.RepoName, &gh.ActionsVariable{ + Name: c.variableName, + Value: string(value), + }) + if err != nil { + return fmt.Errorf("creating variable: %w", err) + } + } + + return nil +} + +func (c *SecretOperatorClient) readVariable(ctx context.Context, owner, repo string) (PRTokenMap, bool, error) { + variable, resp, err := c.ghClient.Actions.GetRepoVariable(ctx, owner, repo, c.variableName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return make(PRTokenMap), false, nil + } + return nil, false, fmt.Errorf("reading variable: %w", err) + } + + var tokens PRTokenMap + if err := json.Unmarshal([]byte(variable.Value), &tokens); err != nil { + c.logger.Warn("corrupt variable value, starting fresh", + slog.String("error", err.Error()), + ) + return make(PRTokenMap), true, nil + } + + return tokens, true, nil +} diff --git a/internal/token/secret_operator_client_test.go b/internal/token/secret_operator_client_test.go new file mode 100644 index 0000000..39dc17c --- /dev/null +++ b/internal/token/secret_operator_client_test.go @@ -0,0 +1,397 @@ +package token + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/containifyci/durga-bot/internal/testutil" + gh "github.com/google/go-github/v67/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupMockSecretOperator(t *testing.T, tokenValue string, statusCode int) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/generate-token" { + w.WriteHeader(http.StatusNotFound) + return + } + + var req generateTokenRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + if statusCode != http.StatusOK { + w.WriteHeader(statusCode) + fmt.Fprint(w, `{"error":"failed"}`) + return + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(generateTokenResponse{Token: tokenValue}) + require.NoError(t, err) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func TestSecretOperatorClient_CreateToken_NewVariable(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "test-token-abc", http.StatusOK) + variableCreated := false + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + }) + mux.HandleFunc("/repos/owner/myrepo/actions/variables", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + variableCreated = true + w.WriteHeader(http.StatusCreated) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + client := NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "my-service", + RepoOwner: "owner", + RepoName: "myrepo", + PRNumber: 42, + }) + + require.NoError(t, err) + assert.True(t, variableCreated, "GitHub variable should have been created") +} + +func TestSecretOperatorClient_CreateToken_UpdateExistingVariable(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "new-token-xyz", http.StatusOK) + + existingTokens := PRTokenMap{ + "10": { + Token: "existing-token", + Service: "other-service", + CreatedAt: time.Now().Format(time.RFC3339), + }, + } + existingJSON, _ := json.Marshal(existingTokens) + + var updatedValue string + variableUpdated := false + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"name":"SECRET_OPERATOR_TOKENS","value":%q}`, string(existingJSON)) + case http.MethodPatch: + body, _ := io.ReadAll(r.Body) + var v gh.ActionsVariable + _ = json.Unmarshal(body, &v) + updatedValue = v.Value + variableUpdated = true + w.WriteHeader(http.StatusNoContent) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + client := NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "my-service", + RepoOwner: "owner", + RepoName: "myrepo", + PRNumber: 42, + }) + + require.NoError(t, err) + assert.True(t, variableUpdated) + + var result PRTokenMap + require.NoError(t, json.Unmarshal([]byte(updatedValue), &result)) + assert.Contains(t, result, "10", "existing PR entry should be preserved") + assert.Contains(t, result, "42", "new PR entry should be added") + assert.Equal(t, "new-token-xyz", result["42"].Token) + assert.Equal(t, "my-service", result["42"].Service) +} + +func TestSecretOperatorClient_CreateToken_PrunesExpiredEntries(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "fresh-token", http.StatusOK) + + existingTokens := PRTokenMap{ + "10": {Token: "old", Service: "old", CreatedAt: time.Now().Add(-20 * time.Minute).Format(time.RFC3339)}, + "20": {Token: "fresh", Service: "fresh", CreatedAt: time.Now().Format(time.RFC3339)}, + } + existingJSON, _ := json.Marshal(existingTokens) + + var updatedValue string + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"name":"SECRET_OPERATOR_TOKENS","value":%q}`, string(existingJSON)) + case http.MethodPatch: + body, _ := io.ReadAll(r.Body) + var v gh.ActionsVariable + _ = json.Unmarshal(body, &v) + updatedValue = v.Value + w.WriteHeader(http.StatusNoContent) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + client := NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "new", RepoOwner: "owner", RepoName: "myrepo", PRNumber: 30, + }) + require.NoError(t, err) + + var result PRTokenMap + require.NoError(t, json.Unmarshal([]byte(updatedValue), &result)) + assert.NotContains(t, result, "10", "expired entry should be pruned") + assert.Contains(t, result, "20", "fresh entry should be preserved") + assert.Contains(t, result, "30", "new entry should be added") +} + +func TestSecretOperatorClient_CreateToken_SecretOperatorError(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "", http.StatusInternalServerError) + + client := NewSecretOperatorClient(nil, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "my-service", RepoOwner: "owner", RepoName: "myrepo", PRNumber: 1, + }) + + assert.ErrorContains(t, err, "secret-operator returned 500") +} + +func TestSecretOperatorClient_CreateToken_ReadVariableError(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "token-abc", http.StatusOK) + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + ghClient := testutil.NewGitHubClient(t, mux) + client := NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "my-service", RepoOwner: "owner", RepoName: "myrepo", PRNumber: 1, + }) + + assert.ErrorContains(t, err, "reading variable") +} + +func TestRequestToken_Success(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "my-generated-token", http.StatusOK) + client := NewSecretOperatorClient(nil, soHost, "TEST", testutil.DiscardLogger()) + + token, err := client.requestToken(context.Background(), "my-service") + + require.NoError(t, err) + assert.Equal(t, "my-generated-token", token) +} + +func TestRequestToken_EmptyToken(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "", http.StatusOK) + client := NewSecretOperatorClient(nil, soHost, "TEST", testutil.DiscardLogger()) + + _, err := client.requestToken(context.Background(), "my-service") + + assert.ErrorContains(t, err, "empty token") +} + +func TestRequestToken_ConnectionError(t *testing.T) { + t.Parallel() + + client := NewSecretOperatorClient(nil, "http://127.0.0.1:1", "TEST", testutil.DiscardLogger()) + _, err := client.requestToken(context.Background(), "my-service") + + assert.ErrorContains(t, err, "calling secret-operator") +} + +func TestRequestToken_InvalidResponseBody(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `not valid json`) + })) + t.Cleanup(srv.Close) + + client := NewSecretOperatorClient(nil, srv.URL, "TEST", testutil.DiscardLogger()) + _, err := client.requestToken(context.Background(), "my-service") + + assert.ErrorContains(t, err, "decoding response") +} + +func TestSecretOperatorClient_CreateToken_CreateVariableError(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "test-token", http.StatusOK) + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + }) + mux.HandleFunc("/repos/owner/myrepo/actions/variables", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message":"Internal Server Error"}`) + }) + + ghClient := testutil.NewGitHubClient(t, mux) + client := NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "my-service", RepoOwner: "owner", RepoName: "myrepo", PRNumber: 1, + }) + + assert.ErrorContains(t, err, "creating variable") +} + +func TestSecretOperatorClient_CreateToken_UpdateVariableError(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "test-token", http.StatusOK) + + existingTokens := PRTokenMap{ + "10": {Token: "existing", Service: "svc", CreatedAt: time.Now().Format(time.RFC3339)}, + } + existingJSON, _ := json.Marshal(existingTokens) + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"name":"SECRET_OPERATOR_TOKENS","value":%q}`, string(existingJSON)) + case http.MethodPatch: + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message":"Internal Server Error"}`) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + client := NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "my-service", RepoOwner: "owner", RepoName: "myrepo", PRNumber: 42, + }) + + assert.ErrorContains(t, err, "updating variable") +} + +func TestSecretOperatorClient_CreateToken_CorruptVariableValue(t *testing.T) { + t.Parallel() + + soHost := setupMockSecretOperator(t, "fresh-token", http.StatusOK) + var updatedValue string + + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/myrepo/actions/variables/SECRET_OPERATOR_TOKENS", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"name":"SECRET_OPERATOR_TOKENS","value":"not valid json"}`) + case http.MethodPatch: + body, _ := io.ReadAll(r.Body) + var v gh.ActionsVariable + _ = json.Unmarshal(body, &v) + updatedValue = v.Value + w.WriteHeader(http.StatusNoContent) + } + }) + + ghClient := testutil.NewGitHubClient(t, mux) + client := NewSecretOperatorClient(ghClient, soHost, "SECRET_OPERATOR_TOKENS", testutil.DiscardLogger()) + + err := client.CreateToken(context.Background(), TokenRequest{ + ServiceName: "my-service", RepoOwner: "owner", RepoName: "myrepo", PRNumber: 5, + }) + + require.NoError(t, err) + + var result PRTokenMap + require.NoError(t, json.Unmarshal([]byte(updatedValue), &result)) + assert.Contains(t, result, "5") + assert.Equal(t, "fresh-token", result["5"].Token) +} + +func TestRepoMutex_EvictsOldEntries(t *testing.T) { + t.Parallel() + + client := NewSecretOperatorClient(nil, "", "TEST", testutil.DiscardLogger()) + client.maxLocks = 3 + + // Fill to capacity. + client.repoMutex("org", "repo1") + time.Sleep(time.Millisecond) + client.repoMutex("org", "repo2") + time.Sleep(time.Millisecond) + client.repoMutex("org", "repo3") + + assert.Len(t, client.repoLocks, 3) + + // Adding a 4th should evict repo1 (oldest lastUsed). + client.repoMutex("org", "repo4") + + assert.Len(t, client.repoLocks, 3) + assert.NotContains(t, client.repoLocks, "org/repo1") + assert.Contains(t, client.repoLocks, "org/repo2") + assert.Contains(t, client.repoLocks, "org/repo3") + assert.Contains(t, client.repoLocks, "org/repo4") +} + +func TestRepoMutex_RefreshKeepsEntry(t *testing.T) { + t.Parallel() + + client := NewSecretOperatorClient(nil, "", "TEST", testutil.DiscardLogger()) + client.maxLocks = 3 + + client.repoMutex("org", "repo1") + time.Sleep(time.Millisecond) + client.repoMutex("org", "repo2") + time.Sleep(time.Millisecond) + client.repoMutex("org", "repo3") + + // Refresh repo1 so it's no longer the oldest. + time.Sleep(time.Millisecond) + client.repoMutex("org", "repo1") + + // Adding repo4 should evict repo2 (now the oldest). + client.repoMutex("org", "repo4") + + assert.Len(t, client.repoLocks, 3) + assert.Contains(t, client.repoLocks, "org/repo1") + assert.NotContains(t, client.repoLocks, "org/repo2") + assert.Contains(t, client.repoLocks, "org/repo3") + assert.Contains(t, client.repoLocks, "org/repo4") +}