diff --git a/internal/config/config_load_test.go b/internal/config/config_load_test.go new file mode 100644 index 0000000..15e8d1c --- /dev/null +++ b/internal/config/config_load_test.go @@ -0,0 +1,266 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_ValidFile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(` +services: + - name: api + repo: github.com/org/api +server: + port: 9090 +cache: + ttl: 30m +tokens: + github: file-token +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.Port != 9090 { + t.Errorf("port = %d, want 9090", cfg.Server.Port) + } + if cfg.Cache.TTL != "30m" { + t.Errorf("ttl = %q, want 30m", cfg.Cache.TTL) + } + if len(cfg.Services) != 1 || cfg.Services[0].Name != "api" { + t.Errorf("services = %v, want [{api ...}]", cfg.Services) + } + if cfg.Tokens.GitHub != "file-token" { + t.Errorf("github token = %q, want file-token", cfg.Tokens.GitHub) + } + // Defaults should be applied + if cfg.RateLimit.GitHub != 5 { + t.Errorf("rate_limit.github = %v, want 5", cfg.RateLimit.GitHub) + } + if cfg.Log.Level != "info" { + t.Errorf("log.level = %q, want info", cfg.Log.Level) + } +} + +func TestLoad_MissingFile_ReturnsDefault(t *testing.T) { + cfg, err := Load("/tmp/releasewave-test-nonexistent-12345.yaml") + if err != nil { + t.Fatalf("Load: %v", err) + } + + // Should get default config + if cfg.Server.Port != 7891 { + t.Errorf("port = %d, want default 7891", cfg.Server.Port) + } + if cfg.Cache.TTL != "15m" { + t.Errorf("ttl = %q, want default 15m", cfg.Cache.TTL) + } + if cfg.RateLimit.GitHub != 5 { + t.Errorf("rate_limit.github = %v, want default 5", cfg.RateLimit.GitHub) + } +} + +func TestLoad_InvalidYAML(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "bad.yaml") + if err := os.WriteFile(cfgPath, []byte(`{{{not yaml at all`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(cfgPath) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestLoad_ValidationError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "invalid.yaml") + if err := os.WriteFile(cfgPath, []byte(` +services: + - name: svc + repo: bad-repo +`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(cfgPath) + if err == nil { + t.Fatal("expected validation error for bad repo format") + } +} + +func TestLoad_EnvOverrides(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(` +tokens: + github: from-file + gitlab: from-file +server: + api_key: from-file +`), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("GITHUB_TOKEN", "env-gh-token") + t.Setenv("GITLAB_TOKEN", "env-gl-token") + t.Setenv("RELEASEWAVE_API_KEY", "env-api-key") + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Tokens.GitHub != "env-gh-token" { + t.Errorf("github token = %q, want env-gh-token", cfg.Tokens.GitHub) + } + if cfg.Tokens.GitLab != "env-gl-token" { + t.Errorf("gitlab token = %q, want env-gl-token", cfg.Tokens.GitLab) + } + if cfg.Server.APIKey != "env-api-key" { + t.Errorf("api_key = %q, want env-api-key", cfg.Server.APIKey) + } +} + +func TestLoad_EnvNotSet_KeepsFileValues(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(` +tokens: + github: from-file +`), 0o644); err != nil { + t.Fatal(err) + } + + // Ensure env vars are not set + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GITLAB_TOKEN", "") + t.Setenv("RELEASEWAVE_API_KEY", "") + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Tokens.GitHub != "from-file" { + t.Errorf("github token = %q, want from-file", cfg.Tokens.GitHub) + } +} + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Server.Port != 7891 { + t.Errorf("port = %d, want 7891", cfg.Server.Port) + } + if cfg.Cache.TTL != "15m" { + t.Errorf("ttl = %q, want 15m", cfg.Cache.TTL) + } + if cfg.RateLimit.GitHub != 5 { + t.Errorf("rate_limit.github = %v, want 5", cfg.RateLimit.GitHub) + } + if cfg.RateLimit.GitLab != 3 { + t.Errorf("rate_limit.gitlab = %v, want 3", cfg.RateLimit.GitLab) + } + if cfg.Log.Level != "info" { + t.Errorf("log.level = %q, want info", cfg.Log.Level) + } + if cfg.Log.Format != "text" { + t.Errorf("log.format = %q, want text", cfg.Log.Format) + } +} + +func TestApplyDefaults_DoesNotOverrideSetValues(t *testing.T) { + cfg := &Config{ + Server: ServerConfig{Port: 3000}, + Cache: CacheConfig{TTL: "5m"}, + RateLimit: RateLimitConfig{GitHub: 10, GitLab: 8}, + Log: LogConfig{Level: "debug", Format: "json"}, + } + cfg.applyDefaults() + + if cfg.Server.Port != 3000 { + t.Errorf("port = %d, want 3000 (should not override)", cfg.Server.Port) + } + if cfg.Cache.TTL != "5m" { + t.Errorf("ttl = %q, want 5m (should not override)", cfg.Cache.TTL) + } + if cfg.RateLimit.GitHub != 10 { + t.Errorf("rate_limit.github = %v, want 10 (should not override)", cfg.RateLimit.GitHub) + } + if cfg.RateLimit.GitLab != 8 { + t.Errorf("rate_limit.gitlab = %v, want 8 (should not override)", cfg.RateLimit.GitLab) + } + if cfg.Log.Level != "debug" { + t.Errorf("log.level = %q, want debug (should not override)", cfg.Log.Level) + } + if cfg.Log.Format != "json" { + t.Errorf("log.format = %q, want json (should not override)", cfg.Log.Format) + } +} + +func TestParseRepo_UnknownHost(t *testing.T) { + svc, err := ParseRepo("bitbucket.org/org/repo") + if err != nil { + t.Fatalf("ParseRepo: %v", err) + } + // Unknown host should use the host string as platform + if svc.Platform != "bitbucket.org" { + t.Errorf("platform = %q, want bitbucket.org", svc.Platform) + } + if svc.Owner != "org" { + t.Errorf("owner = %q, want org", svc.Owner) + } + if svc.RepoName != "repo" { + t.Errorf("repo = %q, want repo", svc.RepoName) + } +} + +func TestParseRepo_TwoPartPath(t *testing.T) { + _, err := ParseRepo("github.com/just-one") + if err == nil { + t.Error("expected error for two-part path") + } +} + +func TestDefaultConfigPath(t *testing.T) { + path, err := DefaultConfigPath() + if err != nil { + t.Fatalf("DefaultConfigPath: %v", err) + } + if path == "" { + t.Fatal("expected non-empty path") + } + // Should end with the expected suffix + if filepath.Base(path) != "config.yaml" { + t.Errorf("path = %q, expected to end with config.yaml", path) + } +} + +func TestValidate_MissingRepo(t *testing.T) { + cfg := &Config{ + Services: []ServiceConfig{{Name: "svc", Repo: ""}}, + } + if err := cfg.Validate(); err == nil { + t.Error("expected error for missing repo") + } +} + +func TestValidate_PortBounds(t *testing.T) { + cfg := &Config{Server: ServerConfig{Port: -1}} + if err := cfg.Validate(); err == nil { + t.Error("expected error for negative port") + } + cfg = &Config{Server: ServerConfig{Port: 70000}} + if err := cfg.Validate(); err == nil { + t.Error("expected error for port > 65535") + } +} diff --git a/internal/githubapp/api_test.go b/internal/githubapp/api_test.go new file mode 100644 index 0000000..2a12a98 --- /dev/null +++ b/internal/githubapp/api_test.go @@ -0,0 +1,204 @@ +package githubapp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// testApp creates an App with a real RSA key pointing at a test server. +func testApp(t *testing.T, serverURL string) *App { + t.Helper() + keyPath, _ := writeTestKey(t) + + app, err := New(Config{AppID: 100, PrivateKeyPath: keyPath}) + if err != nil { + t.Fatalf("New: %v", err) + } + app.baseURL = serverURL + return app +} + +func TestListInstallations_Success(t *testing.T) { + installations := []Installation{ + {ID: 1}, + {ID: 2}, + } + installations[0].Account.Login = "org-a" + installations[0].Account.Type = "Organization" + installations[1].Account.Login = "user-b" + installations[1].Account.Type = "User" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/app/installations" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") == "" { + t.Error("missing Authorization header") + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(installations); err != nil { + t.Fatal(err) + } + })) + defer srv.Close() + + app := testApp(t, srv.URL) + got, err := app.ListInstallations(context.Background()) + if err != nil { + t.Fatalf("ListInstallations: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d installations, want 2", len(got)) + } + if got[0].Account.Login != "org-a" { + t.Errorf("got[0].Account.Login = %q, want org-a", got[0].Account.Login) + } + if got[1].Account.Login != "user-b" { + t.Errorf("got[1].Account.Login = %q, want user-b", got[1].Account.Login) + } +} + +func TestListInstallations_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + })) + defer srv.Close() + + app := testApp(t, srv.URL) + _, err := app.ListInstallations(context.Background()) + if err == nil { + t.Fatal("expected error for 403 response") + } +} + +func TestListInstallations_EmptyList(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + app := testApp(t, srv.URL) + got, err := app.ListInstallations(context.Background()) + if err != nil { + t.Fatalf("ListInstallations: %v", err) + } + if len(got) != 0 { + t.Errorf("got %d installations, want 0", len(got)) + } +} + +func TestGetInstallationToken_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/app/installations/42/access_tokens" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("unexpected method: %s", r.Method) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"ghs_test_token_123"}`)) + })) + defer srv.Close() + + app := testApp(t, srv.URL) + token, err := app.GetInstallationToken(context.Background(), 42) + if err != nil { + t.Fatalf("GetInstallationToken: %v", err) + } + if token != "ghs_test_token_123" { + t.Errorf("token = %q, want ghs_test_token_123", token) + } +} + +func TestGetInstallationToken_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + })) + defer srv.Close() + + app := testApp(t, srv.URL) + _, err := app.GetInstallationToken(context.Background(), 999) + if err == nil { + t.Fatal("expected error for 404 response") + } +} + +func TestListRepos_Success(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch r.URL.Path { + case "/app/installations/10/access_tokens": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"ghs_abc"}`)) + case "/installation/repositories": + if r.Header.Get("Authorization") != "token ghs_abc" { + t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"repositories":[{"full_name":"org/repo1"},{"full_name":"org/repo2"}]}`)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + app := testApp(t, srv.URL) + repos, err := app.ListRepos(context.Background(), 10) + if err != nil { + t.Fatalf("ListRepos: %v", err) + } + if len(repos) != 2 { + t.Fatalf("got %d repos, want 2", len(repos)) + } + if repos[0] != "org/repo1" { + t.Errorf("repos[0] = %q, want org/repo1", repos[0]) + } + if repos[1] != "org/repo2" { + t.Errorf("repos[1] = %q, want org/repo2", repos[1]) + } +} + +func TestListRepos_TokenError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Unauthorized"}`)) + })) + defer srv.Close() + + app := testApp(t, srv.URL) + _, err := app.ListRepos(context.Background(), 10) + if err == nil { + t.Fatal("expected error when token request fails") + } +} + +func TestListRepos_EmptyRepos(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/app/installations/10/access_tokens": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"ghs_abc"}`)) + case "/installation/repositories": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"repositories":[]}`)) + } + })) + defer srv.Close() + + app := testApp(t, srv.URL) + repos, err := app.ListRepos(context.Background(), 10) + if err != nil { + t.Fatalf("ListRepos: %v", err) + } + if len(repos) != 0 { + t.Errorf("got %d repos, want 0", len(repos)) + } +} diff --git a/internal/githubapp/app.go b/internal/githubapp/app.go index 49a0f43..d58eab8 100644 --- a/internal/githubapp/app.go +++ b/internal/githubapp/app.go @@ -30,6 +30,7 @@ type App struct { config Config privateKey *rsa.PrivateKey httpClient *http.Client + baseURL string // defaults to "https://api.github.com" } // New creates a GitHub App instance. @@ -57,6 +58,7 @@ func New(cfg Config) (*App, error) { config: cfg, privateKey: key, httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: "https://api.github.com", }, nil } @@ -71,7 +73,7 @@ type Installation struct { // ListInstallations returns all installations of this GitHub App. func (a *App) ListInstallations(ctx context.Context) ([]Installation, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/app/installations", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/app/installations", nil) if err != nil { return nil, err } @@ -103,7 +105,7 @@ func (a *App) ListInstallations(ctx context.Context) ([]Installation, error) { // GetInstallationToken generates an access token for an installation. func (a *App) GetInstallationToken(ctx context.Context, installationID int64) (string, error) { - url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID) + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", a.baseURL, installationID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return "", err @@ -143,7 +145,7 @@ func (a *App) ListRepos(ctx context.Context, installationID int64) ([]string, er return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/installation/repositories", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/installation/repositories", nil) if err != nil { return nil, err }