diff --git a/README.md b/README.md index 02841c6..50cc46c 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,29 @@ go install github.com/andreagrandi/mb-cli/cmd/mb@latest ## Configuration -Set two environment variables: +Set `MB_HOST` and **one** of the two authentication variables: + +### Option 1: API key (recommended for long-lived access) ```bash export MB_HOST=https://your-metabase-instance.com export MB_API_KEY=your-api-key ``` -Both are required. `MB_HOST` is the base URL of your Metabase instance. `MB_API_KEY` is a [Metabase API key](https://www.metabase.com/docs/latest/people-and-groups/api-keys). +`MB_API_KEY` is a [Metabase API key](https://www.metabase.com/docs/latest/people-and-groups/api-keys). Requires admin access to generate. + +### Option 2: Session token (when you don't have API key access) + +```bash +export MB_HOST=https://your-metabase-instance.com +export MB_SESSION_TOKEN=your-session-token +``` + +To get your session token: open your Metabase instance in Chrome → DevTools → Application → Cookies → copy the `metabase.SESSION` value. Session tokens expire when you log out or after the server's session timeout. + +Setting both `MB_API_KEY` and `MB_SESSION_TOKEN` is an error — use one or the other. -Optional: +### Optional ```bash export MB_REDACT_PII=false # Disable PII redaction (enabled by default) diff --git a/internal/client/client.go b/internal/client/client.go index 5da11b2..8eb08c4 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -23,11 +23,12 @@ type HTTPDoer interface { // Client represents the Metabase API client. type Client struct { - BaseURL string - HTTPClient HTTPDoer - APIKey string - Verbose bool - RedactPII bool + BaseURL string + HTTPClient HTTPDoer + APIKey string + SessionToken string + Verbose bool + RedactPII bool } // NewClient creates a new Metabase API client from the provided config. @@ -37,13 +38,18 @@ func NewClient(cfg *config.Config) *Client { HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, - APIKey: cfg.APIKey, + APIKey: cfg.APIKey, + SessionToken: cfg.SessionToken, } } // Do executes an HTTP request with authentication headers and error handling. func (c *Client) Do(req *http.Request) (*http.Response, error) { - req.Header.Set("x-api-key", c.APIKey) + if c.SessionToken != "" { + req.Header.Set("X-Metabase-Session", c.SessionToken) + } else { + req.Header.Set("x-api-key", c.APIKey) + } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", UserAgent) diff --git a/internal/config/config.go b/internal/config/config.go index b69abfe..99027b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,8 +6,9 @@ import ( ) type Config struct { - Host string - APIKey string + Host string + APIKey string + SessionToken string } func LoadConfig() (*Config, error) { @@ -17,12 +18,19 @@ func LoadConfig() (*Config, error) { } apiKey := os.Getenv("MB_API_KEY") - if apiKey == "" { - return nil, fmt.Errorf("MB_API_KEY environment variable is required") + sessionToken := os.Getenv("MB_SESSION_TOKEN") + + if apiKey == "" && sessionToken == "" { + return nil, fmt.Errorf("either MB_API_KEY or MB_SESSION_TOKEN environment variable is required") + } + + if apiKey != "" && sessionToken != "" { + return nil, fmt.Errorf("MB_API_KEY and MB_SESSION_TOKEN are mutually exclusive, set only one") } return &Config{ - Host: host, - APIKey: apiKey, + Host: host, + APIKey: apiKey, + SessionToken: sessionToken, }, nil } diff --git a/tests/client_test.go b/tests/client_test.go index 9b81a79..594a2d1 100644 --- a/tests/client_test.go +++ b/tests/client_test.go @@ -265,3 +265,112 @@ func TestVerboseMode(t *testing.T) { } defer resp.Body.Close() } + + +func newTestClientWithSessionToken(serverURL string) *client.Client { + cfg := &config.Config{ + Host: serverURL, + SessionToken: "test-session-token", + } + return client.NewClient(cfg) +} + +func TestNewClient_WithSessionToken(t *testing.T) { + cfg := &config.Config{ + Host: "https://metabase.example.com", + SessionToken: "my-session-token", + } + + c := client.NewClient(cfg) + + if c.BaseURL != "https://metabase.example.com" { + t.Errorf("expected base URL 'https://metabase.example.com', got '%s'", c.BaseURL) + } + + if c.SessionToken != "my-session-token" { + t.Errorf("expected session token 'my-session-token', got '%s'", c.SessionToken) + } +} + +func TestDo_SetsSessionTokenHeader(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionToken := r.Header.Get("X-Metabase-Session") + if sessionToken != "test-session-token" { + t.Errorf("expected X-Metabase-Session 'test-session-token', got '%s'", sessionToken) + } + + apiKey := r.Header.Get("x-api-key") + if apiKey != "" { + t.Errorf("expected no x-api-key header when using session token, got '%s'", apiKey) + } + + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + t.Errorf("expected Content-Type 'application/json', got '%s'", contentType) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + c := newTestClientWithSessionToken(server.URL) + + req, err := http.NewRequest("GET", server.URL+"/test", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := c.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + + +func TestGet_WithSessionToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionToken := r.Header.Get("X-Metabase-Session") + if sessionToken != "test-session-token" { + t.Errorf("expected X-Metabase-Session header, got '%s'", sessionToken) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + })) + defer server.Close() + + c := newTestClientWithSessionToken(server.URL) + + resp, err := c.Get("/api/database/", nil) + if err != nil { + t.Fatalf("GET request failed: %v", err) + } + defer resp.Body.Close() +} + +func TestPost_WithSessionToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionToken := r.Header.Get("X-Metabase-Session") + if sessionToken != "test-session-token" { + t.Errorf("expected X-Metabase-Session header, got '%s'", sessionToken) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + c := newTestClientWithSessionToken(server.URL) + + resp, err := c.Post("/api/dataset/", map[string]any{"query": "SELECT 1"}) + if err != nil { + t.Fatalf("POST request failed: %v", err) + } + defer resp.Body.Close() +} diff --git a/tests/config_test.go b/tests/config_test.go index 8bef676..10dce09 100644 --- a/tests/config_test.go +++ b/tests/config_test.go @@ -10,6 +10,7 @@ import ( func TestLoadConfig_Success(t *testing.T) { os.Setenv("MB_HOST", "https://metabase.example.com") os.Setenv("MB_API_KEY", "test-api-key") + os.Unsetenv("MB_SESSION_TOKEN") defer os.Unsetenv("MB_HOST") defer os.Unsetenv("MB_API_KEY") @@ -27,9 +28,54 @@ func TestLoadConfig_Success(t *testing.T) { } } +func TestLoadConfig_SessionTokenOnly(t *testing.T) { + os.Setenv("MB_HOST", "https://metabase.example.com") + os.Unsetenv("MB_API_KEY") + os.Setenv("MB_SESSION_TOKEN", "test-session-token") + defer os.Unsetenv("MB_HOST") + defer os.Unsetenv("MB_SESSION_TOKEN") + + cfg, err := config.LoadConfig() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if cfg.Host != "https://metabase.example.com" { + t.Errorf("expected host 'https://metabase.example.com', got '%s'", cfg.Host) + } + + if cfg.SessionToken != "test-session-token" { + t.Errorf("expected session token 'test-session-token', got '%s'", cfg.SessionToken) + } + + if cfg.APIKey != "" { + t.Errorf("expected empty api key, got '%s'", cfg.APIKey) + } +} + +func TestLoadConfig_BothAuthMethodsErrors(t *testing.T) { + os.Setenv("MB_HOST", "https://metabase.example.com") + os.Setenv("MB_API_KEY", "test-api-key") + os.Setenv("MB_SESSION_TOKEN", "test-session-token") + defer os.Unsetenv("MB_HOST") + defer os.Unsetenv("MB_API_KEY") + defer os.Unsetenv("MB_SESSION_TOKEN") + + _, err := config.LoadConfig() + if err == nil { + t.Fatal("expected error when both MB_API_KEY and MB_SESSION_TOKEN are set") + } + + expected := "MB_API_KEY and MB_SESSION_TOKEN are mutually exclusive, set only one" + if err.Error() != expected { + t.Errorf("expected error '%s', got '%s'", expected, err.Error()) + } +} + func TestLoadConfig_MissingHost(t *testing.T) { os.Unsetenv("MB_HOST") os.Setenv("MB_API_KEY", "test-api-key") + os.Unsetenv("MB_SESSION_TOKEN") defer os.Unsetenv("MB_API_KEY") _, err := config.LoadConfig() @@ -43,17 +89,18 @@ func TestLoadConfig_MissingHost(t *testing.T) { } } -func TestLoadConfig_MissingAPIKey(t *testing.T) { +func TestLoadConfig_NoAuthMethod(t *testing.T) { os.Setenv("MB_HOST", "https://metabase.example.com") os.Unsetenv("MB_API_KEY") + os.Unsetenv("MB_SESSION_TOKEN") defer os.Unsetenv("MB_HOST") _, err := config.LoadConfig() if err == nil { - t.Fatal("expected error when MB_API_KEY is missing") + t.Fatal("expected error when neither MB_API_KEY nor MB_SESSION_TOKEN is set") } - expected := "MB_API_KEY environment variable is required" + expected := "either MB_API_KEY or MB_SESSION_TOKEN environment variable is required" if err.Error() != expected { t.Errorf("expected error '%s', got '%s'", expected, err.Error()) } @@ -62,9 +109,10 @@ func TestLoadConfig_MissingAPIKey(t *testing.T) { func TestLoadConfig_BothMissing(t *testing.T) { os.Unsetenv("MB_HOST") os.Unsetenv("MB_API_KEY") + os.Unsetenv("MB_SESSION_TOKEN") _, err := config.LoadConfig() if err == nil { - t.Fatal("expected error when both env vars are missing") + t.Fatal("expected error when all env vars are missing") } }