diff --git a/.gitignore b/.gitignore index b62fdf26fdc..73141d98f51 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,7 @@ _debug_bin postgres-data/ mysql-data/ .docker/ +docker-compose.override.yml # config files local.js diff --git a/backend/Makefile b/backend/Makefile index 6a30e8a1347..698f9915ec2 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -29,7 +29,7 @@ all: build go-dep: go install github.com/vektra/mockery/v2@v2.43.0 go install github.com/swaggo/swag/cmd/swag@v1.16.1 - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.63.4 go-dev-tools: # go install github.com/atombender/go-jsonschema/cmd/gojsonschema@latest diff --git a/backend/helpers/srvhelper/scope_service_helper.go b/backend/helpers/srvhelper/scope_service_helper.go index e0e0d97dc33..544536f01c2 100644 --- a/backend/helpers/srvhelper/scope_service_helper.go +++ b/backend/helpers/srvhelper/scope_service_helper.go @@ -268,39 +268,39 @@ func (scopeSrv *ScopeSrvHelper[C, S, SC]) getAffectedTables() ([]string, errors. if err != nil { return nil, err } - if pluginModel, ok := meta.(plugin.PluginModel); !ok { + pluginModel, ok := meta.(plugin.PluginModel) + if !ok { panic(errors.Default.New(fmt.Sprintf("plugin \"%s\" does not implement listing its tables", scopeSrv.pluginName))) - } else { - // Unfortunately, can't cache the tables because Python creates some tables on a per-demand basis, so such a cache would possibly get outdated. - // It's a rare scenario in practice, but might as well play it safe and sacrifice some performance here - var allTables []string - if allTables, err = scopeSrv.db.AllTables(); err != nil { - return nil, err - } - // collect raw tables - for _, table := range allTables { - if strings.HasPrefix(table, "_raw_"+scopeSrv.pluginName) { - tables = append(tables, table) - } + } + // Unfortunately, can't cache the tables because Python creates some tables on a per-demand basis, so such a cache would possibly get outdated. + // It's a rare scenario in practice, but might as well play it safe and sacrifice some performance here + var allTables []string + if allTables, err = scopeSrv.db.AllTables(); err != nil { + return nil, err + } + // collect raw tables + for _, table := range allTables { + if strings.HasPrefix(table, "_raw_"+scopeSrv.pluginName) { + tables = append(tables, table) } - // collect tool tables - toolModels := pluginModel.GetTablesInfo() - for _, toolModel := range toolModels { - if !isScopeModel(toolModel) && hasField(toolModel, "RawDataParams") { - tables = append(tables, toolModel.TableName()) - } + } + // collect tool tables + toolModels := pluginModel.GetTablesInfo() + for _, toolModel := range toolModels { + if !isScopeModel(toolModel) && hasField(toolModel, "RawDataParams") { + tables = append(tables, toolModel.TableName()) } - // collect domain tables - for _, domainModel := range domaininfo.GetDomainTablesInfo() { - // we only care about tables with RawOrigin - ok = hasField(domainModel, "RawDataParams") - if ok { - tables = append(tables, domainModel.TableName()) - } + } + // collect domain tables + for _, domainModel := range domaininfo.GetDomainTablesInfo() { + // we only care about tables with RawOrigin + ok = hasField(domainModel, "RawDataParams") + if ok { + tables = append(tables, domainModel.TableName()) } - // additional tables - tables = append(tables, models.CollectorLatestState{}.TableName()) } + // additional tables + tables = append(tables, models.CollectorLatestState{}.TableName()) scopeSrv.log.Debug("Discovered %d tables used by plugin \"%s\": %v", len(tables), scopeSrv.pluginName, tables) return tables, nil } diff --git a/backend/impls/logruslog/init.go b/backend/impls/logruslog/init.go index 17df490f97d..0fdb76fc57f 100644 --- a/backend/impls/logruslog/init.go +++ b/backend/impls/logruslog/init.go @@ -70,11 +70,11 @@ func init() { if basePath == "" { basePath = "./logs" } - if abs, err := filepath.Abs(basePath); err != nil { - panic(err) - } else { - basePath = filepath.Join(abs, "devlake.log") + abs, absErr := filepath.Abs(basePath) + if absErr != nil { + panic(absErr) } + basePath = filepath.Join(abs, "devlake.log") var err errors.Error Global, err = NewDefaultLogger(inner) if err != nil { diff --git a/backend/plugins/bitbucket/api/blueprint_v200.go b/backend/plugins/bitbucket/api/blueprint_v200.go index a0ca0fe50c1..0e2477e80fe 100644 --- a/backend/plugins/bitbucket/api/blueprint_v200.go +++ b/backend/plugins/bitbucket/api/blueprint_v200.go @@ -120,7 +120,13 @@ func makeDataSourcePipelinePlanV200( if err != nil { return nil, err } - cloneUrl.User = url.UserPassword(connection.Username, connection.Password) + // For Bitbucket API tokens, use x-token-auth as username per Bitbucket docs + // https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/ + gitUsername := connection.Username + if connection.UsesApiToken { + gitUsername = "x-bitbucket-api-token-auth" + } + cloneUrl.User = url.UserPassword(gitUsername, connection.Password) stage = append(stage, &coreModels.PipelineTask{ Plugin: "gitextractor", Options: map[string]interface{}{ diff --git a/backend/plugins/bitbucket/api/connection_api.go b/backend/plugins/bitbucket/api/connection_api.go index 15851e3ab07..41978143b64 100644 --- a/backend/plugins/bitbucket/api/connection_api.go +++ b/backend/plugins/bitbucket/api/connection_api.go @@ -52,12 +52,18 @@ func testConnection(ctx context.Context, connection models.BitbucketConn) (*BitB } if res.StatusCode == http.StatusUnauthorized { - return nil, errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error when testing connection") + return nil, errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error when testing connection. Please check your credentials.") } if res.StatusCode != http.StatusOK { return nil, errors.HttpStatus(res.StatusCode).New("unexpected status code when testing connection") } + + // Log deprecation warning if using App Password (not API token) + if !connection.UsesApiToken { + basicRes.GetLogger().Warn(nil, "Bitbucket App passwords are deprecated and will be deactivated on June 9, 2026. Please migrate to API tokens.") + } + connection = connection.Sanitize() body := BitBucketTestConnResponse{} body.Success = true diff --git a/backend/plugins/bitbucket/api/connection_api_test.go b/backend/plugins/bitbucket/api/connection_api_test.go new file mode 100644 index 00000000000..33b26d857cd --- /dev/null +++ b/backend/plugins/bitbucket/api/connection_api_test.go @@ -0,0 +1,264 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + "testing" + + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/bitbucket/models" + "github.com/apache/incubator-devlake/server/api/shared" + "github.com/stretchr/testify/assert" +) + +func TestTestConnection_Validation(t *testing.T) { + // Test that validation errors are handled correctly + connection := models.BitbucketConn{ + RestConnection: api.RestConnection{ + Endpoint: "", // Invalid: empty endpoint + }, + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "token", + }, + UsesApiToken: true, + } + + // Note: This test would require mocking the validator and API client + // For now, we're testing the structure + assert.NotEmpty(t, connection.Username) + assert.NotEmpty(t, connection.Password) + assert.True(t, connection.UsesApiToken) +} + +func TestBitbucketConn_UsesApiToken_ApiToken(t *testing.T) { + // Test API token connection structure + connection := models.BitbucketConn{ + RestConnection: api.RestConnection{ + Endpoint: "https://api.bitbucket.org/2.0/", + }, + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "api_token_123", + }, + UsesApiToken: true, + } + + assert.True(t, connection.UsesApiToken) + assert.Equal(t, "user@example.com", connection.Username) + assert.Equal(t, "https://api.bitbucket.org/2.0/", connection.Endpoint) +} + +func TestBitbucketConn_UsesApiToken_AppPassword(t *testing.T) { + // Test app password connection structure + connection := models.BitbucketConn{ + RestConnection: api.RestConnection{ + Endpoint: "https://api.bitbucket.org/2.0/", + }, + BasicAuth: api.BasicAuth{ + Username: "bitbucket_username", + Password: "app_password_123", + }, + UsesApiToken: false, + } + + assert.False(t, connection.UsesApiToken) + assert.Equal(t, "bitbucket_username", connection.Username) + assert.Equal(t, "https://api.bitbucket.org/2.0/", connection.Endpoint) +} + +func TestBitbucketConn_Sanitize_RemovesPassword(t *testing.T) { + // Test that Sanitize removes sensitive data + connection := models.BitbucketConn{ + RestConnection: api.RestConnection{ + Endpoint: "https://api.bitbucket.org/2.0/", + }, + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "secret_token", + }, + UsesApiToken: true, + } + + sanitized := connection.Sanitize() + assert.Empty(t, sanitized.Password) + assert.Equal(t, "user@example.com", sanitized.Username) + assert.True(t, sanitized.UsesApiToken) +} + +func TestBitBucketTestConnResponse_Structure(t *testing.T) { + // Test the response structure + connection := models.BitbucketConn{ + RestConnection: api.RestConnection{ + Endpoint: "https://api.bitbucket.org/2.0/", + }, + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "", + }, + UsesApiToken: true, + } + + response := BitBucketTestConnResponse{ + ApiBody: shared.ApiBody{ + Success: true, + Message: "success", + }, + Connection: &connection, + } + + assert.True(t, response.Success) + assert.Equal(t, "success", response.Message) + assert.NotNil(t, response.Connection) + assert.True(t, response.Connection.UsesApiToken) +} + +// TestTestConnection_DeprecationWarning tests that deprecation warnings are logged for app passwords +func TestTestConnection_DeprecationWarning(t *testing.T) { + // This is a conceptual test showing what should be tested + // In a real implementation, you would mock the logger and verify the warning is called + + connectionApiToken := models.BitbucketConn{ + UsesApiToken: true, + } + + connectionAppPassword := models.BitbucketConn{ + UsesApiToken: false, + } + + // For API token: no warning should be logged + assert.True(t, connectionApiToken.UsesApiToken, "API token connections should not trigger deprecation warning") + + // For App password: warning should be logged + assert.False(t, connectionAppPassword.UsesApiToken, "App password connections should trigger deprecation warning") +} + +// TestConnectionAuthentication_BothMethodsUseBasicAuth verifies that both auth methods use Basic Auth +func TestConnectionAuthentication_BothMethodsUseBasicAuth(t *testing.T) { + // API Token connection + apiTokenConn := models.BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "api_token", + }, + UsesApiToken: true, + } + + // App Password connection + appPasswordConn := models.BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "bitbucket_username", + Password: "app_password", + }, + UsesApiToken: false, + } + + // Both should use BasicAuth for authentication + req1, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil) + err1 := apiTokenConn.SetupAuthentication(req1) + assert.Nil(t, err1) + assert.NotEmpty(t, req1.Header.Get("Authorization")) + + req2, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil) + err2 := appPasswordConn.SetupAuthentication(req2) + assert.Nil(t, err2) + assert.NotEmpty(t, req2.Header.Get("Authorization")) +} + +// TestMergeFromRequest_HandlesUsesApiToken tests that MergeFromRequest properly handles the UsesApiToken field +func TestMergeFromRequest_HandlesUsesApiToken(t *testing.T) { + // Test that the UsesApiToken field is properly handled during merge operations + connection := models.BitbucketConnection{ + BitbucketConn: models.BitbucketConn{ + RestConnection: api.RestConnection{ + Endpoint: "https://api.bitbucket.org/2.0/", + }, + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "token", + }, + UsesApiToken: true, + }, + } + + // Simulate a merge with new values + newValues := map[string]interface{}{ + "usesApiToken": false, + "username": "new_username", + } + + // After merge, UsesApiToken should be updated + // This is a structural test - actual merge logic is in the connection.go MergeFromRequest method + assert.True(t, connection.UsesApiToken, "Initial value should be true") + + // If we were to apply the merge: + connection.UsesApiToken = newValues["usesApiToken"].(bool) + connection.Username = newValues["username"].(string) + + assert.False(t, connection.UsesApiToken, "After merge, should be false") + assert.Equal(t, "new_username", connection.Username) +} + +func TestConnectionStatusCodes(t *testing.T) { + // Test expected status code handling + tests := []struct { + name string + statusCode int + expectedError bool + errorType string + }{ + { + name: "Success - 200 OK", + statusCode: http.StatusOK, + expectedError: false, + }, + { + name: "Unauthorized - 401", + statusCode: http.StatusUnauthorized, + expectedError: true, + errorType: "BadRequest", + }, + { + name: "Forbidden - 403", + statusCode: http.StatusForbidden, + expectedError: true, + errorType: "Forbidden", + }, + { + name: "Not Found - 404", + statusCode: http.StatusNotFound, + expectedError: true, + errorType: "NotFound", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test that different status codes are handled appropriately + if tt.statusCode == http.StatusOK { + assert.False(t, tt.expectedError) + } else if tt.statusCode == http.StatusUnauthorized { + assert.True(t, tt.expectedError) + assert.Equal(t, "BadRequest", tt.errorType) + } else { + assert.True(t, tt.expectedError) + } + }) + } +} diff --git a/backend/plugins/bitbucket/models/connection.go b/backend/plugins/bitbucket/models/connection.go index abf1ea88e2b..3396abe1e8b 100644 --- a/backend/plugins/bitbucket/models/connection.go +++ b/backend/plugins/bitbucket/models/connection.go @@ -18,6 +18,9 @@ limitations under the License. package models import ( + "net/http" + + "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" ) @@ -28,11 +31,20 @@ var _ plugin.ApiConnection = (*BitbucketConnection)(nil) type BitbucketConn struct { api.RestConnection `mapstructure:",squash"` api.BasicAuth `mapstructure:",squash"` + // UsesApiToken indicates whether the password field contains an API token (true) + // or an App password (false). Both use Basic Auth, but API tokens are the new standard. + UsesApiToken bool `mapstructure:"usesApiToken" json:"usesApiToken"` } -func (connection BitbucketConn) Sanitize() BitbucketConn { - connection.Password = "" - return connection +func (bc BitbucketConn) Sanitize() BitbucketConn { + bc.Password = "" + return bc +} + +// SetupAuthentication sets up HTTP Basic Authentication +// Both App passwords and API tokens use Basic Auth with username:credential format +func (bc *BitbucketConn) SetupAuthentication(req *http.Request) errors.Error { + return bc.BasicAuth.SetupAuthentication(req) } // BitbucketConnection holds BitbucketConn plus ID/Name for database storage diff --git a/backend/plugins/bitbucket/models/connection_test.go b/backend/plugins/bitbucket/models/connection_test.go new file mode 100644 index 00000000000..e11f238a78d --- /dev/null +++ b/backend/plugins/bitbucket/models/connection_test.go @@ -0,0 +1,301 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "net/http" + "testing" + + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/stretchr/testify/assert" +) + +func TestBitbucketConn_SetupAuthentication_ApiToken(t *testing.T) { + // Test API token authentication + conn := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "api_token_123", + }, + UsesApiToken: true, + } + + req, err := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil) + assert.NoError(t, err) + + authErr := conn.SetupAuthentication(req) + assert.Nil(t, authErr) + assert.NotEmpty(t, req.Header.Get("Authorization")) + assert.Contains(t, req.Header.Get("Authorization"), "Basic") +} + +func TestBitbucketConn_SetupAuthentication_AppPassword(t *testing.T) { + // Test app password authentication + conn := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "bitbucket_username", + Password: "app_password_123", + }, + UsesApiToken: false, + } + + req, err := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil) + assert.NoError(t, err) + + authErr := conn.SetupAuthentication(req) + assert.Nil(t, authErr) + assert.NotEmpty(t, req.Header.Get("Authorization")) + assert.Contains(t, req.Header.Get("Authorization"), "Basic") +} + +func TestBitbucketConn_Sanitize(t *testing.T) { + // Test that Sanitize removes the password + conn := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "secret_password", + }, + UsesApiToken: true, + } + + sanitized := conn.Sanitize() + assert.Empty(t, sanitized.Password) + assert.Equal(t, "user@example.com", sanitized.Username) + assert.True(t, sanitized.UsesApiToken) +} + +func TestBitbucketConn_UsesApiToken_Default(t *testing.T) { + // Test that UsesApiToken can be set correctly + conn := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "token", + }, + UsesApiToken: true, + } + + assert.True(t, conn.UsesApiToken) + + // Test app password mode + conn2 := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "username", + Password: "password", + }, + UsesApiToken: false, + } + + assert.False(t, conn2.UsesApiToken) +} + +func TestBitbucketConnection_Sanitize(t *testing.T) { + // Test that BitbucketConnection.Sanitize works correctly + connection := BitbucketConnection{ + BitbucketConn: BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "secret_token", + }, + UsesApiToken: true, + }, + } + + sanitized := connection.Sanitize() + assert.Empty(t, sanitized.Password) + assert.Equal(t, "user@example.com", sanitized.Username) + assert.True(t, sanitized.UsesApiToken) +} + +func TestBitbucketConnection_TableName(t *testing.T) { + connection := BitbucketConnection{} + assert.Equal(t, "_tool_bitbucket_connections", connection.TableName()) +} + +func TestBitbucketConnection_MergeFromRequest_PreservesPassword(t *testing.T) { + original := &BitbucketConnection{ + BitbucketConn: BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "secret_token", + }, + UsesApiToken: true, + }, + } + + target := &BitbucketConnection{} + *target = *original // copy + + // Update without password (empty password should preserve original) + body := map[string]interface{}{ + "username": "new_user@example.com", + "usesApiToken": false, + } + + err := original.MergeFromRequest(target, body) + assert.NoError(t, err) + assert.Equal(t, "new_user@example.com", target.Username) + assert.Equal(t, "secret_token", target.Password) // Should preserve + assert.False(t, target.UsesApiToken) +} + +func TestBitbucketConnection_MergeFromRequest_UpdatesPassword(t *testing.T) { + original := &BitbucketConnection{ + BitbucketConn: BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "old_token", + }, + UsesApiToken: true, + }, + } + + target := &BitbucketConnection{} + *target = *original + + // Update with new password + body := map[string]interface{}{ + "username": "user@example.com", + "password": "new_token", + "usesApiToken": true, + } + + err := original.MergeFromRequest(target, body) + assert.NoError(t, err) + assert.Equal(t, "new_token", target.Password) // Should update +} + +func TestBitbucketConnection_MergeFromRequest_TogglesUsesApiToken(t *testing.T) { + original := &BitbucketConnection{ + BitbucketConn: BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "credential", + }, + UsesApiToken: false, // App password + }, + } + + target := &BitbucketConnection{} + *target = *original + + // Toggle to API token + body := map[string]interface{}{ + "usesApiToken": true, + "password": "api_token_123", + } + + err := original.MergeFromRequest(target, body) + assert.NoError(t, err) + assert.True(t, target.UsesApiToken) + assert.Equal(t, "api_token_123", target.Password) +} + +func TestBitbucketConn_SetupAuthentication_BasicAuthFormat(t *testing.T) { + // Test that BOTH methods produce Basic Auth (not Bearer) + tests := []struct { + name string + username string + password string + usesApiToken bool + }{ + { + name: "API Token produces Basic Auth", + username: "user@example.com", + password: "api_token_123", + usesApiToken: true, + }, + { + name: "App Password produces Basic Auth", + username: "bitbucket_username", + password: "app_password_123", + usesApiToken: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: tt.username, + Password: tt.password, + }, + UsesApiToken: tt.usesApiToken, + } + + req, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil) + err := conn.SetupAuthentication(req) + + assert.Nil(t, err) + authHeader := req.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.Contains(t, authHeader, "Basic ", "Should use Basic auth, not Bearer") + assert.NotContains(t, authHeader, "Bearer", "Should NOT use Bearer token") + }) + } +} + +func TestBitbucketConn_EmptyPassword(t *testing.T) { + conn := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "", + }, + UsesApiToken: true, + } + + req, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil) + err := conn.SetupAuthentication(req) + + // Should still set auth header (though it won't work in practice) + assert.Nil(t, err) + assert.NotEmpty(t, req.Header.Get("Authorization")) +} + +func TestBitbucketConn_SpecialCharactersInPassword(t *testing.T) { + conn := BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "p@ssw0rd!#$%&*()+=", + }, + UsesApiToken: true, + } + + req, _ := http.NewRequest("GET", "https://api.bitbucket.org/2.0/user", nil) + err := conn.SetupAuthentication(req) + + assert.Nil(t, err) + assert.NotEmpty(t, req.Header.Get("Authorization")) +} + +func TestBitbucketConnection_Sanitize_PreservesUsesApiToken(t *testing.T) { + connection := BitbucketConnection{ + BitbucketConn: BitbucketConn{ + BasicAuth: api.BasicAuth{ + Username: "user@example.com", + Password: "secret", + }, + UsesApiToken: true, + }, + } + + sanitized := connection.Sanitize() + + assert.Empty(t, sanitized.Password, "Password should be removed") + assert.Equal(t, "user@example.com", sanitized.Username, "Username should be preserved") + assert.True(t, sanitized.UsesApiToken, "UsesApiToken flag should be preserved") +} diff --git a/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth.go b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth.go new file mode 100644 index 00000000000..9f746d7ae54 --- /dev/null +++ b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth.go @@ -0,0 +1,64 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addApiTokenAuth)(nil) + +type bitbucketConnection20251001 struct { + UsesApiToken bool `mapstructure:"usesApiToken" json:"usesApiToken"` +} + +func (bitbucketConnection20251001) TableName() string { + return "_tool_bitbucket_connections" +} + +type addApiTokenAuth struct{} + +func (script *addApiTokenAuth) Up(basicRes context.BasicRes) errors.Error { + // Add usesApiToken field to support API token tracking + // Existing connections will default to false (app password method) + err := migrationhelper.AutoMigrateTables(basicRes, &bitbucketConnection20251001{}) + if err != nil { + return err + } + + // Set default usesApiToken to false for existing connections + // This ensures backward compatibility with existing App password connections + db := basicRes.GetDal() + err = db.Exec("UPDATE _tool_bitbucket_connections SET uses_api_token = false WHERE uses_api_token IS NULL") + if err != nil { + return err + } + + return nil +} + +func (*addApiTokenAuth) Version() uint64 { + return 20251001000001 +} + +func (script *addApiTokenAuth) Name() string { + return "add API token authentication support to Bitbucket connections" +} diff --git a/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth_test.go b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth_test.go new file mode 100644 index 00000000000..b3ec57347c6 --- /dev/null +++ b/backend/plugins/bitbucket/models/migrationscripts/20251001_add_api_token_auth_test.go @@ -0,0 +1,56 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddApiTokenAuth_Version(t *testing.T) { + script := &addApiTokenAuth{} + assert.Equal(t, uint64(20251001000001), script.Version()) +} + +func TestAddApiTokenAuth_Name(t *testing.T) { + script := &addApiTokenAuth{} + assert.Equal(t, "add API token authentication support to Bitbucket connections", script.Name()) +} + +func TestBitbucketConnection20251001_TableName(t *testing.T) { + conn := bitbucketConnection20251001{} + assert.Equal(t, "_tool_bitbucket_connections", conn.TableName()) +} + +func TestBitbucketConnection20251001_Structure(t *testing.T) { + // Test that the migration struct has the correct field + conn := bitbucketConnection20251001{ + UsesApiToken: true, + } + assert.True(t, conn.UsesApiToken) + + conn2 := bitbucketConnection20251001{ + UsesApiToken: false, + } + assert.False(t, conn2.UsesApiToken) +} + +// Note: Full integration test of the Up() method requires a test database setup. +// The migration is tested in practice when running the actual migrations against a database. +// For unit testing purposes, we verify the structure and metadata. diff --git a/backend/plugins/bitbucket/models/migrationscripts/register.go b/backend/plugins/bitbucket/models/migrationscripts/register.go index 105af236446..c47f7db4f7d 100644 --- a/backend/plugins/bitbucket/models/migrationscripts/register.go +++ b/backend/plugins/bitbucket/models/migrationscripts/register.go @@ -42,5 +42,6 @@ func All() []plugin.MigrationScript { new(reCreatBitBucketPipelineSteps), new(addMergedByToPr), new(changeIssueComponentType), + new(addApiTokenAuth), } } diff --git a/backend/plugins/github_graphql/tasks/issue_extractor.go b/backend/plugins/github_graphql/tasks/issue_extractor.go index 0ebd28d18c3..e27679c83e9 100644 --- a/backend/plugins/github_graphql/tasks/issue_extractor.go +++ b/backend/plugins/github_graphql/tasks/issue_extractor.go @@ -143,7 +143,7 @@ func convertGithubIssue(milestoneMap map[int]int, issue *GraphqlQueryIssue, conn GithubCreatedAt: issue.CreatedAt, GithubUpdatedAt: issue.UpdatedAt, } - if issue.AssigneeList.Assignees != nil && len(issue.AssigneeList.Assignees) > 0 { + if len(issue.AssigneeList.Assignees) > 0 { githubIssue.AssigneeId = issue.AssigneeList.Assignees[0].Id githubIssue.AssigneeName = issue.AssigneeList.Assignees[0].Login } diff --git a/backend/server/api/shared/api_output.go b/backend/server/api/shared/api_output.go index b213f91e963..27e114d8cbc 100644 --- a/backend/server/api/shared/api_output.go +++ b/backend/server/api/shared/api_output.go @@ -18,7 +18,6 @@ limitations under the License. package shared import ( - "fmt" "net/http" "github.com/apache/incubator-devlake/core/errors" @@ -124,7 +123,7 @@ func ApiOutputSuccess(c *gin.Context, body interface{}, status int) { func ApiOutputAbort(c *gin.Context, err error) { if e, ok := err.(errors.Error); ok { logruslog.Global.Error(err, "HTTP %d abort-error", e.GetType().GetHttpCode()) - _ = c.AbortWithError(e.GetType().GetHttpCode(), fmt.Errorf(e.Messages().Format())) + _ = c.AbortWithError(e.GetType().GetHttpCode(), errors.Default.New(e.Messages().Format())) } else { logruslog.Global.Error(err, "HTTP %d abort-error (native)", http.StatusInternalServerError) _ = c.AbortWithError(http.StatusInternalServerError, err) diff --git a/backend/server/services/remote/plugin/plugin_impl.go b/backend/server/services/remote/plugin/plugin_impl.go index 6242b160053..2c4b4a4daaa 100644 --- a/backend/server/services/remote/plugin/plugin_impl.go +++ b/backend/server/services/remote/plugin/plugin_impl.go @@ -186,20 +186,20 @@ func (p *remotePluginImpl) PrepareTaskData(taskCtx plugin.TaskContext, options m }, nil } -func (p *remotePluginImpl) getScopeAndConfig(db dal.Dal, connectionId uint64, scopeId string) (interface{}, interface{}, errors.Error) { +func (p *remotePluginImpl) getScopeAndConfig(db dal.Dal, connectionId uint64, scopeId string) (scope interface{}, scopeConfig interface{}, err errors.Error) { wrappedScope := p.scopeTabler.New() - err := api.CallDB(db.First, wrappedScope, dal.Where("connection_id = ? AND id = ?", connectionId, scopeId)) + err = api.CallDB(db.First, wrappedScope, dal.Where("connection_id = ? AND id = ?", connectionId, scopeId)) if err != nil { return nil, nil, errors.BadInput.New("Invalid scope id") } - scope := models.ScopeModel{} - err = wrappedScope.To(&scope) + scopeModel := models.ScopeModel{} + err = wrappedScope.To(&scopeModel) if err != nil { return nil, nil, errors.BadInput.Wrap(err, "Invalid scope") } - if scope.ScopeConfigId != 0 { + if scopeModel.ScopeConfigId != 0 { wrappedScopeConfig := p.scopeConfigTabler.New() - err = api.CallDB(db.First, wrappedScopeConfig, dal.From(p.scopeConfigTabler.TableName()), dal.Where("id = ?", scope.ScopeConfigId)) + err = api.CallDB(db.First, wrappedScopeConfig, dal.From(p.scopeConfigTabler.TableName()), dal.Where("id = ?", scopeModel.ScopeConfigId)) if err != nil { return nil, nil, err } diff --git a/config-ui/public/onboard/step-2/bitbucket.md b/config-ui/public/onboard/step-2/bitbucket.md index 18a04813896..7ffb71dfcc2 100644 --- a/config-ui/public/onboard/step-2/bitbucket.md +++ b/config-ui/public/onboard/step-2/bitbucket.md @@ -15,23 +15,38 @@ See the License for the specific language governing permissions and limitations under the License. --> -##### Q1. How to generate a Bitbucket app password? - -1. Sign in at [bitbucket.org](https://bitbucket.org). -2. Select the **Settings** cog in the upper-right corner of the top navigation bar. -3. Under **Personal settings**, select **Personal Bitbucket settings**. -4. On the left sidebar, select **App passwords**. -5. Select **Create app password**. -6. Give the 'App password' a name. -7. Select the permissions the 'App password needs'. See **Q2**. -8. Select the **Create** button. - -For detailed instructions, refer to [this doc](https://devlake.apache.org/docs/Configuration/BitBucket/#username-and-app-password). - -##### Q2. Which app password permissions should be included in a token? - -The following permissions are required to collect data from Bitbucket repositories: -`Account:Read` `Workspace` `membership:Read` `Repositories:Read` `Projects:Read` `Pull requests:Read` `Issues:Read` `Pipelines:Read` `Runners:Read` +##### Q1. How to generate a Bitbucket API token? + +**⚠️ Important: App passwords are being deprecated!** +- Creation of App passwords will be discontinued on **September 9, 2025** +- All existing App passwords will be deactivated on **June 9, 2026** +- Please use API tokens for all new connections + +**To create an API token:** + +1. Sign in at [https://id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens). +2. Select **Create API token with scopes**. +3. Give the API token a name and an expiry date (ex: 365 days), then select **Next**. +4. Select **Bitbucket** as the app and select **Next**. +5. Select the required scopes (see **Q2**) and select **Next**. +6. Review your token and select **Create token**. +7. **Copy the generated API token immediately** - it's only displayed once and can't be retrieved later. + +For detailed instructions, refer to [Atlassian's API token documentation](https://support.atlassian.com/bitbucket-cloud/docs/create-an-api-token/). + +##### Q2. Which permissions (scopes) should be included in an API token? + +The following scopes are **required** to collect data from Bitbucket repositories: + +- `read:account` - Required to view users profiles +- `read:issue:bitbucket` - View your issues +- `read:pipeline:bitbucket` - View your pipelines +- `read:project:bitbucket` - View your projects +- `read:pullrequest:bitbucket` - View your pull requests +- `read:repository:bitbucket` - View your repositories +- `read:runner:bitbucket` - View your workspaces/repositories' runners +- `read:user:bitbucket` - View user info (required for connection test) +- `read:workspace:bitbucket` - View your workspaces ##### Q3. Is connecting to the Bitbucket Server/Data Center possible? diff --git a/config-ui/src/plugins/register/bitbucket/config.tsx b/config-ui/src/plugins/register/bitbucket/config.tsx index ba917187198..4de66d6a5d9 100644 --- a/config-ui/src/plugins/register/bitbucket/config.tsx +++ b/config-ui/src/plugins/register/bitbucket/config.tsx @@ -20,6 +20,7 @@ import { DOC_URL } from '@/release'; import { IPluginConfig } from '@/types'; import Icon from './assets/icon.svg?react'; +import { Auth } from './connection-fields'; export const BitbucketConfig: IPluginConfig = { plugin: 'bitbucket', @@ -30,19 +31,21 @@ export const BitbucketConfig: IPluginConfig = { docLink: DOC_URL.PLUGIN.BITBUCKET.BASIS, initialValues: { endpoint: 'https://api.bitbucket.org/2.0/', + usesApiToken: true, }, fields: [ 'name', - { - key: 'endpoint', - subLabel: 'You do not need to enter the endpoint URL, because all versions use the same URL.', - disabled: true, - }, - 'username', - { - key: 'password', - label: 'App Password', - }, + ({ type, initialValues, values, errors, setValues, setErrors }: any) => ( + + ), 'proxy', { key: 'rateLimitPerHour', diff --git a/config-ui/src/plugins/register/bitbucket/connection-fields/auth.tsx b/config-ui/src/plugins/register/bitbucket/connection-fields/auth.tsx new file mode 100644 index 00000000000..f3e74f2cc83 --- /dev/null +++ b/config-ui/src/plugins/register/bitbucket/connection-fields/auth.tsx @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect } from 'react'; +import type { RadioChangeEvent } from 'antd'; +import { Radio, Input } from 'antd'; + +import { Block, ExternalLink } from '@/components'; +import { DOC_URL } from '@/release'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + errors: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +export const Auth = ({ type, initialValues, values, setValues, setErrors }: Props) => { + useEffect(() => { + setValues({ + endpoint: initialValues.endpoint ?? 'https://api.bitbucket.org/2.0/', + usesApiToken: initialValues.usesApiToken ?? true, + username: initialValues.username, + password: initialValues.password, + }); + }, [initialValues.endpoint, initialValues.usesApiToken, initialValues.username, initialValues.password]); + + useEffect(() => { + const required = (values.username && values.password) || type === 'update'; + setErrors({ + endpoint: !values.endpoint ? 'endpoint is required' : '', + auth: required ? '' : 'auth is required', + }); + }, [values]); + + const handleChangeEndpoint = (e: React.ChangeEvent) => { + setValues({ + endpoint: e.target.value, + }); + }; + + const handleChangeMethod = (e: RadioChangeEvent) => { + setValues({ + usesApiToken: (e.target as HTMLInputElement).value === 'apiToken', + username: undefined, + password: undefined, + }); + }; + + const handleChangeUsername = (e: React.ChangeEvent) => { + setValues({ + username: e.target.value, + }); + }; + + const handleChangePassword = (e: React.ChangeEvent) => { + setValues({ + password: e.target.value, + }); + }; + + return ( + <> + + + + + + + API Token (Recommended) + App Password (Deprecated) + + + + + + + + + {values.usesApiToken + ? 'Learn about how to create an API Token' + : 'Learn about how to create an App Password (deprecated)'} + + } + required + > + + + + ); +}; diff --git a/config-ui/src/plugins/register/bitbucket/connection-fields/index.ts b/config-ui/src/plugins/register/bitbucket/connection-fields/index.ts new file mode 100644 index 00000000000..77bad54505f --- /dev/null +++ b/config-ui/src/plugins/register/bitbucket/connection-fields/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export { Auth } from './auth'; diff --git a/config-ui/src/release/stable.ts b/config-ui/src/release/stable.ts index 346f309c127..863ddf371da 100644 --- a/config-ui/src/release/stable.ts +++ b/config-ui/src/release/stable.ts @@ -46,6 +46,8 @@ const URLS = { BITBUCKET: { BASIS: 'https://devlake.apache.org/docs/Configuration/BitBucket', RATE_LIMIT: 'https://devlake.apache.org/docs/Configuration/BitBucket#fixed-rate-limit-optional', + API_TOKEN: 'https://devlake.apache.org/docs/Configuration/BitBucket#api-token-recommended', + APP_PASSWORD: 'https://devlake.apache.org/docs/Configuration/BitBucket#app-password-deprecated', TRANSFORMATION: 'https://devlake.apache.org/docs/Configuration/BitBucket#step-3---adding-transformation-rules-optional', }, diff --git a/config-ui/src/routes/onboard/step-2.tsx b/config-ui/src/routes/onboard/step-2.tsx index d730c0026a3..a01f2cf1aee 100644 --- a/config-ui/src/routes/onboard/step-2.tsx +++ b/config-ui/src/routes/onboard/step-2.tsx @@ -25,8 +25,6 @@ import { Markdown } from '@/components'; import { PATHS } from '@/config'; import { getPluginConfig } from '@/plugins'; import { ConnectionToken } from '@/plugins/components/connection-form/fields/token'; -import { ConnectionUsername } from '@/plugins/components/connection-form/fields/username'; -import { ConnectionPassword } from '@/plugins/components/connection-form/fields/password'; import { operator } from '@/utils'; import { Context } from './context'; @@ -42,6 +40,7 @@ const paramsMap: Record = { }, bitbucket: { endpoint: 'https://api.bitbucket.org/2.0/', + usesApiToken: true, }, azuredevops: {}, }; @@ -57,6 +56,14 @@ export const Step2 = () => { const config = useMemo(() => getPluginConfig(plugin as string), [plugin]); + // Get the auth field component for Bitbucket + const BitbucketAuthField = useMemo(() => { + if (plugin === 'bitbucket' && config?.connection?.fields) { + return config.connection.fields[1]; + } + return null; + }, [plugin, config]); + useEffect(() => { fetch(`/onboard/step-2/${plugin}.md`) .then((res) => res.text()) @@ -133,7 +140,7 @@ export const Step2 = () => { github: 'GitHub', gitlab: 'GitLab', azuredevops: 'Azure DevOps', - } + }; return ( <> @@ -145,8 +152,8 @@ export const Step2 = () => { label="Personal Access Token" subLabel={

- Create a personal access token in {platformNames[plugin]}. For self-managed {config.name}, please skip the onboarding - and configure via Data Connections. + Create a personal access token in {platformNames[plugin]}. For self-managed {config.name}, please skip + the onboarding and configure via Data Connections.

} initialValue="" @@ -171,30 +178,24 @@ export const Step2 = () => { )} - {['bitbucket'].includes(plugin) && ( + {['bitbucket'].includes(plugin) && BitbucketAuthField && (
- { - setPayload({ ...payload, username }); + {BitbucketAuthField({ + type: 'create', + initialValues: { + endpoint: 'https://api.bitbucket.org/2.0/', + usesApiToken: true, + username: '', + password: '', + }, + values: payload, + errors: {}, + setValues: (values: any) => { + setPayload({ ...payload, ...values }); setTestStatus(false); - }} - error="" - setError={() => {}} - /> - { - setPayload({ ...payload, password }); - setTestStatus(false); - }} - error="" - setError={() => {}} - /> + }, + setErrors: () => {}, + })}