diff --git a/chatops-lark/README.md b/chatops-lark/README.md index 6afda7ec..a4ae471c 100644 --- a/chatops-lark/README.md +++ b/chatops-lark/README.md @@ -8,7 +8,6 @@ You can run it by following steps: ```yaml cherry-pick-invite.audit_webhook: cherry-pick-invite.github_token: - bot_name: # for @bot mention in group chat ``` 2. Run the lark bot app: ```bash diff --git a/chatops-lark/cmd/server/main.go b/chatops-lark/cmd/server/main.go index 5e690911..b018c95c 100644 --- a/chatops-lark/cmd/server/main.go +++ b/chatops-lark/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "github.com/rs/zerolog/log" "gopkg.in/yaml.v3" + "github.com/PingCAP-QE/ee-apps/chatops-lark/pkg/botinfo" "github.com/PingCAP-QE/ee-apps/chatops-lark/pkg/events/handler" ) @@ -51,8 +52,25 @@ func main() { } producerCli := lark.NewClient(*appID, *appSecret, producerOpts...) + cfg := loadConfig(*config) + + // Get bot name at startup if not already in config + if _, ok := cfg["bot_name"].(string); !ok && *appID != "" && *appSecret != "" { + botName, err := botinfo.GetBotName(context.Background(), *appID, *appSecret) + if err != nil { + log.Fatal().Err(err).Msg("Failed to get bot name from API. Please verify your App ID and App Secret, and ensure the bot is properly configured in the Lark platform. Alternatively, set a default bot name in the config.") + } else if botName == "" { + log.Fatal().Msg("Retrieved empty bot name from API. Please check your app configuration.") + } else { + log.Info().Str("botName", botName).Msg("Bot name retrieved from API successfully") + // Store the bot name in the config for later use + cfg["bot_name"] = botName + } + } + eventHandler := dispatcher.NewEventDispatcher("", ""). - OnP2MessageReceiveV1(handler.NewRootForMessage(producerCli, loadConfig(*config))) + OnP2MessageReceiveV1(handler.NewRootForMessage(producerCli, cfg)) + consumerOpts := []larkws.ClientOption{larkws.WithEventHandler(eventHandler)} if *debugMode { consumerOpts = append(consumerOpts, diff --git a/chatops-lark/pkg/botinfo/client.go b/chatops-lark/pkg/botinfo/client.go new file mode 100644 index 00000000..07a0ba24 --- /dev/null +++ b/chatops-lark/pkg/botinfo/client.go @@ -0,0 +1,161 @@ +package botinfo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/rs/zerolog/log" +) + +// Lark API endpoints +const ( + tenantAccessTokenURL = "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal" + botInfoURL = "https://open.larksuite.com/open-apis/bot/v3/info" +) + +// HTTPClient interface for easier testing +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// defaultHTTPClient is the default HTTP client +var defaultHTTPClient HTTPClient = &http.Client{} + +// setHTTPClient allows setting a custom HTTP client (used for testing) +func setHTTPClient(client HTTPClient) { + defaultHTTPClient = client +} + +// TenantAccessTokenRequest represents the request body for getting a tenant access token +type TenantAccessTokenRequest struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` +} + +// TenantAccessTokenResponse represents the response from the tenant access token API +type TenantAccessTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + Expire int `json:"expire"` +} + +// BotInfoResponse represents the response from the bot info API +type BotInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Bot struct { + ActivateStatus int `json:"activate_status"` + AppName string `json:"app_name"` + AvatarURL string `json:"avatar_url"` + IPWhiteList []string `json:"ip_white_list"` + OpenID string `json:"open_id"` + } `json:"bot"` +} + +// GetBotName fetches the bot name from Lark API using app credentials +func GetBotName(ctx context.Context, appID, appSecret string) (string, error) { + logger := log.With().Str("component", "botinfo").Logger() + + ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + token, err := getTenantAccessToken(ctxWithTimeout, appID, appSecret) + if err != nil { + logger.Err(err).Msg("Failed to get tenant access token") + return "", fmt.Errorf("failed to get tenant access token: %w", err) + } + + botInfo, err := getBotInfo(ctxWithTimeout, token) + if err != nil { + logger.Err(err).Msg("Failed to get bot info") + return "", fmt.Errorf("failed to get bot info: %w", err) + } + + if botInfo.Bot.AppName == "" { + logger.Warn().Msg("Bot name is empty in API response") + return "", fmt.Errorf("bot name is empty in API response") + } + + return botInfo.Bot.AppName, nil +} + +// getTenantAccessToken gets a tenant access token using app credentials +func getTenantAccessToken(ctx context.Context, appID, appSecret string) (string, error) { + reqBody := TenantAccessTokenRequest{ + AppID: appID, + AppSecret: appSecret, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("error marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", tenantAccessTokenURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return "", fmt.Errorf("error creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := defaultHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response: %w", err) + } + + var tokenResp TenantAccessTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("error parsing response: %w", err) + } + + if tokenResp.Code != 0 { + return "", fmt.Errorf("API error: %s (code: %d)", tokenResp.Msg, tokenResp.Code) + } + + return tokenResp.TenantAccessToken, nil +} + +// getBotInfo gets information about the bot using the tenant access token +func getBotInfo(ctx context.Context, token string) (*BotInfoResponse, error) { + // Create a new request + req, err := http.NewRequestWithContext(ctx, "GET", botInfoURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Content-Type", "application/json") + + resp, err := defaultHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + + var botResp BotInfoResponse + if err := json.Unmarshal(body, &botResp); err != nil { + return nil, fmt.Errorf("error parsing response: %w", err) + } + + if botResp.Code != 0 { + return nil, fmt.Errorf("API error: %s (code: %d)", botResp.Msg, botResp.Code) + } + + return &botResp, nil +} diff --git a/chatops-lark/pkg/botinfo/client_test.go b/chatops-lark/pkg/botinfo/client_test.go new file mode 100644 index 00000000..a08416d4 --- /dev/null +++ b/chatops-lark/pkg/botinfo/client_test.go @@ -0,0 +1,677 @@ +package botinfo + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// MockHTTPClient is a mock implementation of HTTPClient for testing +type MockHTTPClient struct { + DoFunc func(req *http.Request) (*http.Response, error) +} + +// Do implements the HTTPClient interface +func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { + return m.DoFunc(req) +} + +// setupMockClient sets up the mock HTTP client for testing +func setupMockClient(t *testing.T, doFunc func(req *http.Request) (*http.Response, error)) { + // Save the original client to restore later + originalClient := defaultHTTPClient + t.Cleanup(func() { + // Restore the original client after test + setHTTPClient(originalClient) + }) + + // Set the mock client + mockClient := &MockHTTPClient{DoFunc: doFunc} + setHTTPClient(mockClient) +} + +// Test setHTTPClient function +func TestSetHTTPClient(t *testing.T) { + // Save original client to restore later + originalClient := defaultHTTPClient + defer func() { + defaultHTTPClient = originalClient + }() + + // Create a mock client + mockClient := &MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200}, nil + }, + } + + // Set the mock client + setHTTPClient(mockClient) + + // Verify the client was set correctly + if defaultHTTPClient != mockClient { + t.Errorf("setHTTPClient failed to set the client") + } +} + +// Test getTenantAccessToken with successful response +func TestGetTenantAccessToken_Success(t *testing.T) { + // Mock token response + mockResp := TenantAccessTokenResponse{ + Code: 0, + Msg: "ok", + TenantAccessToken: "mock-token-123", + Expire: 7200, + } + + // Set up the mock client + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + // Verify request URL + if req.URL.String() != tenantAccessTokenURL { + t.Errorf("unexpected URL, got %s, want %s", req.URL.String(), tenantAccessTokenURL) + } + + // Verify request method + if req.Method != "POST" { + t.Errorf("unexpected method, got %s, want POST", req.Method) + } + + // Verify request body + bodyBytes, _ := io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Replace the body for future reads + + var reqBody TenantAccessTokenRequest + if err := json.Unmarshal(bodyBytes, &reqBody); err != nil { + t.Fatalf("failed to unmarshal request body: %v", err) + } + + if reqBody.AppID != "test-app-id" || reqBody.AppSecret != "test-app-secret" { + t.Errorf("unexpected request body, got %+v", reqBody) + } + + // Return mock response + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + }) + + ctx := context.Background() + token, err := getTenantAccessToken(ctx, "test-app-id", "test-app-secret") + + // Check results + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if token != "mock-token-123" { + t.Errorf("unexpected token, got %s, want mock-token-123", token) + } +} + +// Test getTenantAccessToken with API error +func TestGetTenantAccessToken_APIError(t *testing.T) { + // Mock error response + mockResp := TenantAccessTokenResponse{ + Code: 99999, + Msg: "app not found", + } + + // Set up the mock client + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, // API returns 200 even on logical errors + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + }) + + ctx := context.Background() + _, err := getTenantAccessToken(ctx, "invalid-app-id", "invalid-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "API error: app not found (code: 99999)" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test getTenantAccessToken with HTTP client error +func TestGetTenantAccessToken_HTTPError(t *testing.T) { + // Set up the mock client to simulate network error + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network error") + }) + + ctx := context.Background() + _, err := getTenantAccessToken(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "error making request: network error" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test getTenantAccessToken with JSON parsing error +func TestGetTenantAccessToken_JSONError(t *testing.T) { + // Set up the mock client to return invalid JSON + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("invalid json")), + }, nil + }) + + ctx := context.Background() + _, err := getTenantAccessToken(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "error parsing response" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test getTenantAccessToken with error during response body read +func TestGetTenantAccessToken_BodyReadError(t *testing.T) { + // Create a mock response body that returns an error when read + errorReader := &ErrorReader{err: errors.New("read error")} + + // Set up the mock client + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(errorReader), + }, nil + }) + + ctx := context.Background() + _, err := getTenantAccessToken(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "error reading response" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// ErrorReader is a helper type to simulate read errors +type ErrorReader struct { + err error +} + +func (e *ErrorReader) Read(p []byte) (n int, err error) { + return 0, e.err +} + +// Test getBotInfo with successful response +func TestGetBotInfo_Success(t *testing.T) { + // Create mock response + mockResp := BotInfoResponse{ + Code: 0, + Msg: "ok", + Bot: struct { + ActivateStatus int `json:"activate_status"` + AppName string `json:"app_name"` + AvatarURL string `json:"avatar_url"` + IPWhiteList []string `json:"ip_white_list"` + OpenID string `json:"open_id"` + }{ + ActivateStatus: 1, + AppName: "TestBot", + AvatarURL: "https://example.com/avatar.jpg", + IPWhiteList: []string{"127.0.0.1"}, + OpenID: "test-open-id", + }, + } + + // Set up the mock client + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + // Verify request URL + if req.URL.String() != botInfoURL { + t.Errorf("unexpected URL, got %s, want %s", req.URL.String(), botInfoURL) + } + + // Verify request method + if req.Method != "GET" { + t.Errorf("unexpected method, got %s, want GET", req.Method) + } + + // Verify auth header + authHeader := req.Header.Get("Authorization") + expectedAuth := "Bearer mock-token" + if authHeader != expectedAuth { + t.Errorf("unexpected auth header, got %s, want %s", authHeader, expectedAuth) + } + + // Return mock response + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + }) + + ctx := context.Background() + botInfo, err := getBotInfo(ctx, "mock-token") + + // Check results + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if botInfo.Bot.AppName != "TestBot" { + t.Errorf("unexpected bot name, got %s, want TestBot", botInfo.Bot.AppName) + } +} + +// Test getBotInfo with API error +func TestGetBotInfo_APIError(t *testing.T) { + // Mock error response + mockResp := BotInfoResponse{ + Code: 99999, + Msg: "invalid token", + } + + // Set up the mock client + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, // API returns 200 even on logical errors + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + }) + + ctx := context.Background() + _, err := getBotInfo(ctx, "invalid-token") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "API error: invalid token (code: 99999)" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test getBotInfo with HTTP client error +func TestGetBotInfo_HTTPError(t *testing.T) { + // Set up the mock client to simulate network error + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network error") + }) + + ctx := context.Background() + _, err := getBotInfo(ctx, "mock-token") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "error making request: network error" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test getBotInfo with JSON parsing error +func TestGetBotInfo_JSONError(t *testing.T) { + // Set up the mock client to return invalid JSON + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("invalid json")), + }, nil + }) + + ctx := context.Background() + _, err := getBotInfo(ctx, "mock-token") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "error parsing response" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test getBotInfo with error during response body read +func TestGetBotInfo_BodyReadError(t *testing.T) { + // Create a mock response body that returns an error when read + errorReader := &ErrorReader{err: errors.New("read error")} + + // Set up the mock client + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(errorReader), + }, nil + }) + + ctx := context.Background() + _, err := getBotInfo(ctx, "mock-token") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "error reading response" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test GetBotName for successful full flow +func TestGetBotName_Success(t *testing.T) { + // Set up the mock client to handle both API calls sequentially + mockCalls := 0 + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + mockCalls++ + + // First call - tenant access token + if mockCalls == 1 { + mockResp := TenantAccessTokenResponse{ + Code: 0, + Msg: "ok", + TenantAccessToken: "mock-token-123", + Expire: 7200, + } + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + } + + // Second call - bot info + if mockCalls == 2 { + mockResp := BotInfoResponse{ + Code: 0, + Msg: "ok", + Bot: struct { + ActivateStatus int `json:"activate_status"` + AppName string `json:"app_name"` + AvatarURL string `json:"avatar_url"` + IPWhiteList []string `json:"ip_white_list"` + OpenID string `json:"open_id"` + }{ + ActivateStatus: 1, + AppName: "TestBotIntegration", + AvatarURL: "https://example.com/avatar.jpg", + IPWhiteList: []string{"127.0.0.1"}, + OpenID: "test-open-id", + }, + } + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + } + + t.Fatalf("unexpected additional API call #%d", mockCalls) + return nil, nil + }) + + ctx := context.Background() + botName, err := GetBotName(ctx, "test-app-id", "test-app-secret") + + // Check results + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if botName != "TestBotIntegration" { + t.Errorf("unexpected bot name, got %s, want TestBotIntegration", botName) + } + + if mockCalls != 2 { + t.Errorf("expected 2 API calls, got %d", mockCalls) + } +} + +// Test GetBotName when token retrieval fails +func TestGetBotName_TokenError(t *testing.T) { + // Set up the mock client to fail on the first call + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + mockResp := TenantAccessTokenResponse{ + Code: 99999, + Msg: "invalid app credentials", + } + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + }) + + ctx := context.Background() + _, err := GetBotName(ctx, "invalid-app-id", "invalid-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "failed to get tenant access token" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test GetBotName when bot info retrieval fails +func TestGetBotName_BotInfoError(t *testing.T) { + // Set up the mock client to handle both API calls sequentially + mockCalls := 0 + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + mockCalls++ + + // First call - tenant access token (success) + if mockCalls == 1 { + mockResp := TenantAccessTokenResponse{ + Code: 0, + Msg: "ok", + TenantAccessToken: "mock-token-123", + Expire: 7200, + } + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + } + + // Second call - bot info (fails) + if mockCalls == 2 { + mockResp := BotInfoResponse{ + Code: 99999, + Msg: "invalid token", + } + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + } + + t.Fatalf("unexpected additional API call #%d", mockCalls) + return nil, nil + }) + + ctx := context.Background() + _, err := GetBotName(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "failed to get bot info" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test GetBotName when bot name is empty +func TestGetBotName_EmptyBotName(t *testing.T) { + // Set up the mock client to handle both API calls sequentially + mockCalls := 0 + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + mockCalls++ + + // First call - tenant access token (success) + if mockCalls == 1 { + mockResp := TenantAccessTokenResponse{ + Code: 0, + Msg: "ok", + TenantAccessToken: "mock-token-123", + Expire: 7200, + } + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + } + + // Second call - bot info (success but empty name) + if mockCalls == 2 { + mockResp := BotInfoResponse{ + Code: 0, + Msg: "ok", + Bot: struct { + ActivateStatus int `json:"activate_status"` + AppName string `json:"app_name"` + AvatarURL string `json:"avatar_url"` + IPWhiteList []string `json:"ip_white_list"` + OpenID string `json:"open_id"` + }{ + ActivateStatus: 1, + AppName: "", // Empty bot name + AvatarURL: "https://example.com/avatar.jpg", + IPWhiteList: []string{"127.0.0.1"}, + OpenID: "test-open-id", + }, + } + respBody, _ := json.Marshal(mockResp) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBuffer(respBody)), + }, nil + } + + t.Fatalf("unexpected additional API call #%d", mockCalls) + return nil, nil + }) + + ctx := context.Background() + _, err := GetBotName(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "bot name is empty in API response" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} + +// Test GetBotName with context cancellation +func TestGetBotName_ContextCancelled(t *testing.T) { + // Set up the mock client to simulate context cancellation + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + return nil, context.Canceled + }) + + // Create a context + ctx := context.Background() + + _, err := GetBotName(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + // The exact error message should contain context cancellation + if !strings.Contains(err.Error(), "context") { + t.Errorf("error should mention context cancellation, got: %s", err.Error()) + } +} + +// Test GetBotName with context timeout +func TestGetBotName_ContextTimeout(t *testing.T) { + // Create a context with a very short timeout + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + // Wait a bit to ensure the context times out before we even make the request + time.Sleep(5 * time.Millisecond) + + _, err := GetBotName(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error due to context timeout, got nil") + } + + // The error should be context deadline exceeded or context cancelled + if !strings.Contains(err.Error(), "context") && !strings.Contains(err.Error(), "deadline") { + t.Errorf("unexpected error message, got %s, expected context timeout related error", err.Error()) + } +} + +// Test getTenantAccessToken with request error (would happen pre-marshaling) +func TestGetTenantAccessToken_RequestError(t *testing.T) { + // Set up the mock client to simulate an error that would happen before marshaling + setupMockClient(t, func(req *http.Request) (*http.Response, error) { + // This error would occur at request creation time, before marshaling + return nil, errors.New("request creation error") + }) + + ctx := context.Background() + _, err := getTenantAccessToken(ctx, "test-app-id", "test-app-secret") + + // Check results - should return error + if err == nil { + t.Fatal("expected an error, got nil") + } + + expectedErrMsg := "error making request" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("unexpected error message, got %s, want to contain %s", err.Error(), expectedErrMsg) + } +} diff --git a/chatops-lark/pkg/events/handler/root.go b/chatops-lark/pkg/events/handler/root.go index 9a9cc1db..352831b1 100644 --- a/chatops-lark/pkg/events/handler/root.go +++ b/chatops-lark/pkg/events/handler/root.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "regexp" "strings" "time" @@ -131,18 +130,15 @@ func NewRootForMessage(respondCli *lark.Client, cfg map[string]any) func(ctx con cacheCfg.Logger = &log.Logger cache, _ := bigcache.New(context.Background(), cacheCfg) - // Get bot name from config with type assertion at startup - botName := "" - if name, ok := cfg["bot_name"].(string); ok { - botName = name - log.Info().Str("botName", botName).Msg("Bot initialized successfully") - } else { - log.Fatal().Msg("bot name not found in config") - os.Exit(1) - } - baseLogger := log.With().Str("component", "rootHandler").Logger() + botName, ok := cfg["bot_name"].(string) + if !ok { + // This shouldn't happen because main.go already validates this + // We're keeping this check as a safeguard with a more specific error message + baseLogger.Fatal().Msg("Bot name was not provided in config. This should have been caught earlier.") + } + h := &rootHandler{ Client: respondCli, Config: cfg,