diff --git a/cmd/github.go b/cmd/github.go new file mode 100644 index 0000000..6bfae64 --- /dev/null +++ b/cmd/github.go @@ -0,0 +1,22 @@ +package cmd + +import "github.com/spf13/cobra" + +// githubCmd is the parent for GitHub-related subcommands. +var githubCmd = &cobra.Command{ + Use: "github", + Short: "GitHub App integration", + Long: `Manage the Vers GitHub App integration. + +Install the GitHub App on your organization and mint short-lived +installation access tokens for CI, agents, and scripts. + +Examples: + vers github install --org myorg + vers github mint-token + vers github mint-token --repo myorg/my-repo --format json`, +} + +func init() { + rootCmd.AddCommand(githubCmd) +} diff --git a/cmd/github_install.go b/cmd/github_install.go new file mode 100644 index 0000000..0651ae0 --- /dev/null +++ b/cmd/github_install.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/hdresearch/vers-cli/internal/auth" + pres "github.com/hdresearch/vers-cli/internal/presenters" + "github.com/spf13/cobra" +) + +var ( + ghInstallOrg string + ghInstallNoOpen bool + ghInstallFormat string + ghInstallTimeout int +) + +// installURLResponse is the response from the install-url endpoint on vers-landing. +// TODO: verify exact response shape against vers-landing once endpoint is confirmed. +type installURLResponse struct { + URL string `json:"url"` +} + +// githubStatusResponse is the response from the github-status endpoint on vers-landing. +// TODO: verify exact response shape against vers-landing once endpoint is confirmed. +type githubStatusResponse struct { + Installed bool `json:"installed"` + InstallationID int64 `json:"installation_id,omitempty"` + Org string `json:"org,omitempty"` +} + +// landingGetJSON performs a GET against the vers-landing app with Bearer auth +// and decodes the JSON response. Returns the HTTP status code and any decode error. +func landingGetJSON(ctx context.Context, landing *url.URL, path string, out interface{}) (int, error) { + apiKey, err := auth.GetAPIKey() + if err != nil || apiKey == "" { + return 0, fmt.Errorf("authentication required: run vers login first") + } + + endpoint := strings.TrimRight(landing.String(), "/") + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return resp.StatusCode, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + if out != nil && len(body) > 0 { + if err := json.Unmarshal(body, out); err != nil { + return resp.StatusCode, fmt.Errorf("decode response: %w", err) + } + } + return resp.StatusCode, nil +} + +var githubInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install the Vers GitHub App on an organization", + Long: `Get the Vers GitHub App installation URL for your organization and +optionally open it in a browser. Then poll until the installation +webhook fires and the app is confirmed installed. + +The command prints the install URL to stdout. If a TTY is detected +(and --no-open is not set), it also attempts to open the URL in +your default browser. + +Exit codes: + 0 GitHub App installed successfully + 1 Timeout waiting for installation + 2 Network or API error + 3 Authentication error + +Examples: + vers github install + vers github install --org myorg + vers github install --no-open + vers github install --timeout 600 --format json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + timeout := time.Duration(ghInstallTimeout) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Resolve the vers-landing base URL (VERS_LANDING_URL or default). + // The GitHub App install flow lives on vers-landing, NOT on api.vers.sh. + landingURL, err := auth.GetVersLandingURL() + if err != nil { + return fmt.Errorf("failed to resolve landing URL: %w", err) + } + + // Step 1: Get the install URL from vers-landing. + urlPath := "/api/github/install-url" + if ghInstallOrg != "" { + urlPath += "?org=" + url.QueryEscape(ghInstallOrg) + } + + var urlResp installURLResponse + _, err = landingGetJSON(ctx, landingURL, urlPath, &urlResp) + + var installURL string + if err != nil || urlResp.URL == "" { + // Fallback: construct a reasonable dashboard URL on landing. + base := strings.TrimRight(landingURL.String(), "/") + if ghInstallOrg != "" { + installURL = fmt.Sprintf("%s/dashboard/github/install?org=%s", base, url.QueryEscape(ghInstallOrg)) + } else { + installURL = fmt.Sprintf("%s/dashboard/github/install", base) + } + if application.Verbose && err != nil { + fmt.Fprintf(application.IO.Err, "Warning: install-url endpoint unavailable (%v), using fallback URL\n", err) + } + } else { + installURL = urlResp.URL + } + + // Step 2: Print the URL (always). + fmt.Fprintln(application.IO.Out, installURL) + + // Step 3: Open browser if not suppressed. + if !ghInstallNoOpen { + if err := openBrowser(installURL); err != nil && application.Verbose { + fmt.Fprintf(application.IO.Err, "Warning: could not open browser: %v\n", err) + } + } + + // Step 4: Poll for installation status. + fmt.Fprintf(application.IO.Err, "Waiting for GitHub App installation (timeout %s)...\n", timeout) + + statusPath := "/api/github/status" + if ghInstallOrg != "" { + statusPath += "?org=" + url.QueryEscape(ghInstallOrg) + } + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + result := githubStatusResponse{Installed: false} + if ghInstallFormat == "json" { + pres.PrintJSON(result) + } else { + fmt.Fprintln(application.IO.Err, "✗ Timed out waiting for GitHub App installation.") + } + cmd.SilenceUsage = true + return fmt.Errorf("timed out waiting for installation") + case <-ticker.C: + var status githubStatusResponse + if _, err := landingGetJSON(ctx, landingURL, statusPath, &status); err != nil { + if application.Verbose { + fmt.Fprintf(application.IO.Err, " poll error: %v\n", err) + } + continue + } + if status.Installed { + if ghInstallFormat == "json" { + pres.PrintJSON(status) + } else { + fmt.Fprintf(application.IO.Err, "✓ GitHub App installed successfully") + if status.InstallationID != 0 { + fmt.Fprintf(application.IO.Err, " (installation %d)", status.InstallationID) + } + fmt.Fprintln(application.IO.Err) + } + return nil + } + } + } + }, +} + +// openBrowser attempts to open the given URL in the default browser. +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform %s", runtime.GOOS) + } + return cmd.Start() +} + +// installResultJSON is a helper for JSON output of install results. +type installResultJSON struct { + Installed bool `json:"installed"` + InstallationID int64 `json:"installation_id,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// marshalInstallResult produces JSON output for the install command. +func marshalInstallResult(r installResultJSON) string { + b, _ := json.Marshal(r) + return string(b) +} + +func init() { + githubCmd.AddCommand(githubInstallCmd) + + githubInstallCmd.Flags().StringVar(&ghInstallOrg, "org", "", "GitHub organization name") + githubInstallCmd.Flags().BoolVar(&ghInstallNoOpen, "no-open", false, "Do not attempt to open the browser") + githubInstallCmd.Flags().StringVar(&ghInstallFormat, "format", "", "Output format (json)") + githubInstallCmd.Flags().IntVar(&ghInstallTimeout, "timeout", 300, "Seconds to wait for installation") +} diff --git a/cmd/github_install_test.go b/cmd/github_install_test.go new file mode 100644 index 0000000..9bb59e1 --- /dev/null +++ b/cmd/github_install_test.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" +) + +// setAPIKeyEnv sets VERS_API_KEY for the duration of a subtest and restores +// the prior value on cleanup. Empty string means "no API key available" — +// in that case we also isolate HOME to a fresh tempdir so auth.GetAPIKey() +// cannot fall back to the developer's real ~/.versrc. +func setAPIKeyEnv(t *testing.T, value string) { + t.Helper() + priorKey, hadKey := os.LookupEnv("VERS_API_KEY") + if err := os.Setenv("VERS_API_KEY", value); err != nil { + t.Fatalf("setenv VERS_API_KEY: %v", err) + } + + var priorHome string + var hadHome bool + if value == "" { + // Isolate HOME so LoadConfig() can't find ~/.versrc on the dev box. + priorHome, hadHome = os.LookupEnv("HOME") + tempHome := t.TempDir() + if err := os.Setenv("HOME", tempHome); err != nil { + t.Fatalf("setenv HOME: %v", err) + } + } + + t.Cleanup(func() { + if hadKey { + os.Setenv("VERS_API_KEY", priorKey) + } else { + os.Unsetenv("VERS_API_KEY") + } + if value == "" { + if hadHome { + os.Setenv("HOME", priorHome) + } else { + os.Unsetenv("HOME") + } + } + }) +} + +// mustParseURL is a small helper for test setup. +func mustParseURL(t *testing.T, s string) *url.URL { + t.Helper() + u, err := url.Parse(s) + if err != nil { + t.Fatalf("parse URL %q: %v", s, err) + } + return u +} + +// TestLandingGetJSON_Success verifies happy-path behavior: correct path, GET +// method, Bearer auth header, Accept header, and JSON decoding. +func TestLandingGetJSON_Success(t *testing.T) { + setAPIKeyEnv(t, "test-api-key-abc") + + var gotPath, gotMethod, gotAuth, gotAccept string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + gotAuth = r.Header.Get("Authorization") + gotAccept = r.Header.Get("Accept") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"url": "https://github.com/apps/x/installations/new?state=nonce-1"}`)) + })) + defer server.Close() + + var resp installURLResponse + code, err := landingGetJSON(context.Background(), mustParseURL(t, server.URL), "/api/github/install-url", &resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != http.StatusOK { + t.Errorf("expected status 200, got %d", code) + } + if gotPath != "/api/github/install-url" { + t.Errorf("expected path /api/github/install-url, got %s", gotPath) + } + if gotMethod != http.MethodGet { + t.Errorf("expected GET, got %s", gotMethod) + } + if gotAuth != "Bearer test-api-key-abc" { + t.Errorf("expected Bearer auth header, got %q", gotAuth) + } + if gotAccept != "application/json" { + t.Errorf("expected Accept: application/json, got %q", gotAccept) + } + if !strings.Contains(resp.URL, "state=nonce-1") { + t.Errorf("expected decoded url to contain state=nonce-1, got %q", resp.URL) + } +} + +// TestLandingGetJSON_NoAPIKey verifies we error out early with a clear +// message when no API key is available (no stranded HTTP request). +func TestLandingGetJSON_NoAPIKey(t *testing.T) { + setAPIKeyEnv(t, "") + + requestFired := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestFired = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + _, err := landingGetJSON(context.Background(), mustParseURL(t, server.URL), "/whatever", nil) + if err == nil { + t.Fatal("expected error when VERS_API_KEY is unset, got nil") + } + if !strings.Contains(err.Error(), "authentication required") { + t.Errorf("expected 'authentication required' in error, got %q", err.Error()) + } + if requestFired { + t.Error("expected no HTTP request to fire when API key missing") + } +} + +// TestLandingGetJSON_ServerError verifies non-2xx statuses surface as errors +// with the status code and response body exposed to the caller. +func TestLandingGetJSON_ServerError(t *testing.T) { + setAPIKeyEnv(t, "k") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin only"}`)) + })) + defer server.Close() + + code, err := landingGetJSON(context.Background(), mustParseURL(t, server.URL), "/api/github/install-url", nil) + if err == nil { + t.Fatal("expected error for 403, got nil") + } + if code != http.StatusForbidden { + t.Errorf("expected returned code 403, got %d", code) + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("expected error to mention status 403, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "admin only") { + t.Errorf("expected error to include response body, got %q", err.Error()) + } +} + +// TestLandingGetJSON_NumericInstallationID verifies that when the server +// sends installation_id as a JSON number (the corrected wire format after +// the vers-landing bigint→number coercion fix), the Go CLI decodes it +// cleanly into int64 without error. +func TestLandingGetJSON_NumericInstallationID(t *testing.T) { + setAPIKeyEnv(t, "k") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "installed": true, + "installation_id": int64(125660596), + "org_id": "abc", + "org_name": "acme", + }) + })) + defer server.Close() + + var status githubStatusResponse + _, err := landingGetJSON(context.Background(), mustParseURL(t, server.URL), "/api/github/status", &status) + if err != nil { + t.Fatalf("unexpected decode error: %v", err) + } + if !status.Installed { + t.Errorf("expected Installed=true, got %v", status.Installed) + } + if status.InstallationID != 125660596 { + t.Errorf("expected InstallationID=125660596, got %d", status.InstallationID) + } + if status.Org != "" { + // githubStatusResponse.Org only unmarshals if the server uses that key; + // a legitimate response may use org_name instead, so this is soft. + } +} + +// TestLandingGetJSON_ContextCancel verifies cancellation propagates into +// the request (no hangs). +func TestLandingGetJSON_ContextCancel(t *testing.T) { + setAPIKeyEnv(t, "k") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Block long enough that the caller's cancelled context should win. + <-r.Context().Done() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := landingGetJSON(ctx, mustParseURL(t, server.URL), "/slow", nil) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } +} diff --git a/cmd/github_mint_token.go b/cmd/github_mint_token.go new file mode 100644 index 0000000..6466af6 --- /dev/null +++ b/cmd/github_mint_token.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/hdresearch/vers-cli/internal/auth" + pres "github.com/hdresearch/vers-cli/internal/presenters" + "github.com/spf13/cobra" +) + +var ( + ghMintOrg string + ghMintRepo string + ghMintFormat string + ghMintRepositories []string + ghMintPermissions []string +) + +// mintTokenRequest is the POST body for the vers-landing installation-token endpoint. +type mintTokenRequest struct { + Repositories []string `json:"repositories,omitempty"` + Permissions map[string]string `json:"permissions,omitempty"` +} + +// mintTokenResponse is the response from the vers-landing installation-token endpoint. +type mintTokenResponse struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + Permissions json.RawMessage `json:"permissions,omitempty"` + Repositories json.RawMessage `json:"repositories,omitempty"` + RepositorySelection string `json:"repository_selection,omitempty"` + InstallationID int64 `json:"installation_id,omitempty"` + OrgID string `json:"org_id,omitempty"` +} + +var githubMintTokenCmd = &cobra.Command{ + Use: "mint-token", + Short: "Mint a short-lived GitHub installation access token", + Long: `Mint a short-lived GitHub App installation access token via the +Vers platform. The token is printed to stdout for piping into other +commands (e.g. git clone, gh CLI, curl). + +The token is scoped to the GitHub App installation on your +organization. You can optionally restrict it to specific +repositories or permissions. + +Tokens typically expire in ~1 hour (GitHub-side policy). Mint a +fresh token per task rather than caching. + +Exit codes: + 0 Token minted successfully + 1 No GitHub App installation found (install first with "vers github install") + 2 API or network error + +Examples: + vers github mint-token + vers github mint-token --format json + vers github mint-token --repo my-repo + vers github mint-token --repo my-repo --repo another-repo + vers github mint-token --permission contents=read --permission pull_requests=write + TOKEN=$(vers github mint-token) + git clone https://x-access-token:${TOKEN}@github.com/myorg/myrepo.git`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + // Build request body. + reqBody := mintTokenRequest{} + + // --repo flags: extract repo name (strip org/ prefix if present). + if ghMintRepo != "" { + // Legacy single --repo flag support. + ghMintRepositories = append(ghMintRepositories, ghMintRepo) + } + for _, r := range ghMintRepositories { + // Strip "org/" prefix if present; the API wants bare repo names. + if idx := strings.Index(r, "/"); idx >= 0 { + r = r[idx+1:] + } + reqBody.Repositories = append(reqBody.Repositories, r) + } + + // --permission flags: "key=value" pairs. + if len(ghMintPermissions) > 0 { + reqBody.Permissions = make(map[string]string, len(ghMintPermissions)) + for _, p := range ghMintPermissions { + parts := strings.SplitN(p, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid --permission %q: expected key=value (e.g. contents=read)", p) + } + reqBody.Permissions[parts[0]] = parts[1] + } + } + + resp, err := doMintToken(apiCtx, reqBody) + if err != nil { + cmd.SilenceUsage = true + return err + } + + format := pres.ParseFormat(false, ghMintFormat) + switch format { + case pres.FormatJSON: + pres.PrintJSON(resp) + default: + // Raw token to stdout for piping. + fmt.Fprint(application.IO.Out, resp.Token) + // Add newline only if stdout is a TTY. + if f, ok := application.IO.Out.(*os.File); ok { + if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 { + fmt.Fprintln(application.IO.Out) + } + } + } + return nil + }, +} + +// doMintToken calls the vers-landing installation-token endpoint directly. +// This endpoint lives on the landing/dashboard app (vers.sh by default), +// NOT on api.vers.sh. The landing base URL is resolved via +// auth.GetVersLandingURL() (VERS_LANDING_URL env override, otherwise +// DEFAULT_VERS_LANDING_URL_STR). +func doMintToken(ctx context.Context, req mintTokenRequest) (*mintTokenResponse, error) { + // Get the API key (same key works for both api.vers.sh and vers.sh). + apiKey, err := auth.GetAPIKey() + if err != nil || apiKey == "" { + return nil, fmt.Errorf("authentication required: run vers login first") + } + + // Resolve the vers-landing base URL (VERS_LANDING_URL or default). + landingURL, err := auth.GetVersLandingURL() + if err != nil { + return nil, fmt.Errorf("failed to resolve landing URL: %w", err) + } + + endpoint := strings.TrimRight(landingURL.String(), "/") + "/api/github/installation-token" + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("API request failed: %w", err) + } + defer httpResp.Body.Close() + + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + switch { + case httpResp.StatusCode == 401 || httpResp.StatusCode == 403: + return nil, fmt.Errorf("authentication failed (HTTP %d): check your API key", httpResp.StatusCode) + case httpResp.StatusCode == 404: + return nil, fmt.Errorf("no GitHub App installation found for your organization: run vers github install first") + case httpResp.StatusCode == 503: + return nil, fmt.Errorf("GitHub App not configured on the Vers server (HTTP 503)") + case httpResp.StatusCode >= 400: + return nil, fmt.Errorf("API error (HTTP %d): %s", httpResp.StatusCode, string(respBody)) + } + + var tokenResp mintTokenResponse + if err := json.Unmarshal(respBody, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if tokenResp.Token == "" { + return nil, fmt.Errorf("API returned empty token") + } + + return &tokenResp, nil +} + +func init() { + githubCmd.AddCommand(githubMintTokenCmd) + + githubMintTokenCmd.Flags().StringVar(&ghMintOrg, "org", "", "Organization name (usually auto-detected from API key)") + githubMintTokenCmd.Flags().StringVar(&ghMintRepo, "repo", "", "Restrict token to a specific repository (owner/repo or repo)") + githubMintTokenCmd.Flags().StringArrayVar(&ghMintRepositories, "repositories", nil, "Restrict token to specific repositories (bare names)") + githubMintTokenCmd.Flags().StringArrayVar(&ghMintPermissions, "permission", nil, "Permission in key=value format (e.g. contents=read)") + githubMintTokenCmd.Flags().StringVar(&ghMintFormat, "format", "", "Output format (json)") +} diff --git a/cmd/github_mint_token_test.go b/cmd/github_mint_token_test.go new file mode 100644 index 0000000..50abfe8 --- /dev/null +++ b/cmd/github_mint_token_test.go @@ -0,0 +1,207 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// setLandingURLEnv points the doMintToken resolver at our test server. +func setLandingURLEnv(t *testing.T, value string) { + t.Helper() + prior, had := os.LookupEnv("VERS_LANDING_URL") + if err := os.Setenv("VERS_LANDING_URL", value); err != nil { + t.Fatalf("setenv VERS_LANDING_URL: %v", err) + } + t.Cleanup(func() { + if had { + os.Setenv("VERS_LANDING_URL", prior) + } else { + os.Unsetenv("VERS_LANDING_URL") + } + }) +} + +// TestDoMintToken_Success verifies POST path, Bearer auth, Content-Type, +// request body shape (repositories + permissions), and decoded response. +func TestDoMintToken_Success(t *testing.T) { + setAPIKeyEnv(t, "test-key") + + var gotPath, gotMethod, gotAuth, gotCT string + var gotBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + gotAuth = r.Header.Get("Authorization") + gotCT = r.Header.Get("Content-Type") + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "token": "ghs_abc123", + "expires_at": "2026-04-20T22:15:46Z", + "installation_id": 99999, + "repository_selection": "selected", + "permissions": {"contents": "read", "pull_requests": "write"} + }`)) + })) + defer server.Close() + setLandingURLEnv(t, server.URL) + + resp, err := doMintToken(context.Background(), mintTokenRequest{ + Repositories: []string{"my-repo", "other-repo"}, + Permissions: map[string]string{"contents": "read"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Token != "ghs_abc123" { + t.Errorf("expected token ghs_abc123, got %s", resp.Token) + } + if resp.InstallationID != 99999 { + t.Errorf("expected installation_id 99999, got %d", resp.InstallationID) + } + + // Wire-format assertions + if gotPath != "/api/github/installation-token" { + t.Errorf("expected path /api/github/installation-token, got %s", gotPath) + } + if gotMethod != http.MethodPost { + t.Errorf("expected POST, got %s", gotMethod) + } + if gotAuth != "Bearer test-key" { + t.Errorf("expected Bearer auth, got %q", gotAuth) + } + if !strings.Contains(gotCT, "application/json") { + t.Errorf("expected Content-Type application/json, got %q", gotCT) + } + + // Body assertions + repos, _ := gotBody["repositories"].([]interface{}) + if len(repos) != 2 || repos[0] != "my-repo" || repos[1] != "other-repo" { + t.Errorf("expected repositories=[my-repo other-repo], got %v", repos) + } + perms, _ := gotBody["permissions"].(map[string]interface{}) + if perms["contents"] != "read" { + t.Errorf("expected permissions.contents=read, got %v", perms["contents"]) + } +} + +// TestDoMintToken_NoAPIKey verifies we error out early without firing a +// request when no API key is configured. +func TestDoMintToken_NoAPIKey(t *testing.T) { + setAPIKeyEnv(t, "") + + requestFired := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestFired = true + })) + defer server.Close() + setLandingURLEnv(t, server.URL) + + _, err := doMintToken(context.Background(), mintTokenRequest{}) + if err == nil { + t.Fatal("expected error without API key") + } + if !strings.Contains(err.Error(), "authentication required") { + t.Errorf("expected 'authentication required' in error, got %q", err.Error()) + } + if requestFired { + t.Error("expected no HTTP request to fire when API key missing") + } +} + +// TestDoMintToken_ErrorMapping verifies the status-code → friendly-message +// mapping (401/403 → auth failed, 404 → install-first nudge, 503 → app not +// configured, other 4xx/5xx → generic with code + body). +func TestDoMintToken_ErrorMapping(t *testing.T) { + setAPIKeyEnv(t, "k") + + tests := []struct { + name string + status int + body string + mustContain string + }{ + {"401 auth", http.StatusUnauthorized, `{"error":"bad key"}`, "authentication failed"}, + {"403 auth", http.StatusForbidden, `{"error":"admin only"}`, "authentication failed"}, + {"404 install-first", http.StatusNotFound, `{"error":"no install"}`, "vers github install"}, + {"503 app unconfigured", http.StatusServiceUnavailable, `{"error":"no app"}`, "not configured"}, + {"422 generic", http.StatusUnprocessableEntity, `{"error":"bad repo"}`, "HTTP 422"}, + {"500 generic", http.StatusInternalServerError, `{"error":"boom"}`, "HTTP 500"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + w.Write([]byte(tc.body)) + })) + defer server.Close() + setLandingURLEnv(t, server.URL) + + _, err := doMintToken(context.Background(), mintTokenRequest{}) + if err == nil { + t.Fatalf("expected error for status %d, got nil", tc.status) + } + if !strings.Contains(err.Error(), tc.mustContain) { + t.Errorf("expected error to contain %q, got %q", tc.mustContain, err.Error()) + } + }) + } +} + +// TestDoMintToken_EmptyToken verifies that a 200 response with no token +// still produces an error (not a silent success with an empty string). +func TestDoMintToken_EmptyToken(t *testing.T) { + setAPIKeyEnv(t, "k") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"token": "", "installation_id": 1}`)) + })) + defer server.Close() + setLandingURLEnv(t, server.URL) + + _, err := doMintToken(context.Background(), mintTokenRequest{}) + if err == nil { + t.Fatal("expected error when API returns empty token, got nil") + } + if !strings.Contains(err.Error(), "empty token") { + t.Errorf("expected 'empty token' in error, got %q", err.Error()) + } +} + +// TestDoMintToken_UsesLandingURL verifies that VERS_LANDING_URL actually +// drives endpoint selection (regression guard against re-introducing the +// old host-munging heuristic). +func TestDoMintToken_UsesLandingURL(t *testing.T) { + setAPIKeyEnv(t, "k") + + var hitHost string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitHost = r.Host + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"token":"ghs_x","installation_id":1}`)) + })) + defer server.Close() + setLandingURLEnv(t, server.URL) + + _, err := doMintToken(context.Background(), mintTokenRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Sanity: we hit the test server, not vers.sh. + if strings.Contains(hitHost, "vers.sh") { + t.Errorf("expected test server host, got %s (env var not honored)", hitHost) + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 08268be..651dd16 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -12,6 +12,7 @@ import ( ) const DEFAULT_VERS_URL_STR = "https://api.vers.sh" +const DEFAULT_VERS_LANDING_URL_STR = "https://vers.sh" // Config represents the structure of the .versrc file type Config struct { @@ -192,4 +193,26 @@ func GetVMDomain() string { return "vm.vers.sh" } +// GetVersLandingURL returns the base URL for the vers-landing web app +// (dashboard, GitHub App install, signin). Defaults to +// DEFAULT_VERS_LANDING_URL_STR; override with VERS_LANDING_URL. +// +// The landing app is a separate deployment from the api (VERS_URL). +// Callers should not derive one from the other by host-munging. +func GetVersLandingURL() (*url.URL, error) { + raw := strings.TrimSpace(os.Getenv("VERS_LANDING_URL")) + if raw == "" { + raw = DEFAULT_VERS_LANDING_URL_STR + } + + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid VERS_LANDING_URL %q: %w", raw, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("invalid VERS_LANDING_URL %s; URL must include scheme http:// or https://", u) + } + return u, nil +} + // Backward-compatibility checks removed; the CLI only targets DEFAULT_VERS_URL (or VERS_URL override). diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 82b7d67..accf937 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -33,3 +33,62 @@ func TestGetVMDomain(t *testing.T) { }) } } + +func TestGetVersLandingURL(t *testing.T) { + tests := []struct { + name string + envValue string // "" means unset + expectURL string // empty if we expect an error + expectErr bool + }{ + {"default when env unset", "", DEFAULT_VERS_LANDING_URL_STR, false}, + {"default when env empty string", "", DEFAULT_VERS_LANDING_URL_STR, false}, + {"default when env whitespace", " ", DEFAULT_VERS_LANDING_URL_STR, false}, + {"staging override", "https://staging.vers.sh", "https://staging.vers.sh", false}, + {"local dev override", "http://localhost:3001", "http://localhost:3001", false}, + {"missing scheme errors", "vers.sh", "", true}, + {"unsupported scheme errors", "ftp://vers.sh", "", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.envValue != "" { + os.Setenv("VERS_LANDING_URL", tc.envValue) + defer os.Unsetenv("VERS_LANDING_URL") + } else { + os.Unsetenv("VERS_LANDING_URL") + } + + got, err := GetVersLandingURL() + if tc.expectErr { + if err == nil { + t.Fatalf("expected error for env=%q, got URL %v", tc.envValue, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.String() != tc.expectURL { + t.Errorf("GetVersLandingURL() = %q, want %q", got.String(), tc.expectURL) + } + }) + } +} + +// TestGetVersLandingURL_IndependentOfVersURL verifies that the landing URL +// is NOT derived from VERS_URL (that was the old heuristic-host-munging +// behavior that we replaced with an explicit env var). +func TestGetVersLandingURL_IndependentOfVersURL(t *testing.T) { + os.Setenv("VERS_URL", "https://api.staging.vers.sh") + defer os.Unsetenv("VERS_URL") + os.Unsetenv("VERS_LANDING_URL") + + got, err := GetVersLandingURL() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.String() != DEFAULT_VERS_LANDING_URL_STR { + t.Errorf("expected default landing URL regardless of VERS_URL, got %q", got.String()) + } +}