From 5dc07e252e9cf4b9166c723f118a99173b969417 Mon Sep 17 00:00:00 2001 From: "Doyin.O" <111298305+0xdev22@users.noreply.github.com> Date: Wed, 21 Feb 2024 10:57:10 -0700 Subject: [PATCH] Implement new tesla API (#264) * Implement new tesla api, create api route for retrieving vehicles using new api, handle tesla auth, write tests to validate new endpoint * WIP: Implement PR review change requesy * Add Tesla secrets, rename a little bit * Implement suggested changes from PR review * Move struct up * Rename setting again * Fix lint errors * Try to fix up Fleet API URL setting usage --------- Co-authored-by: Dylan Moreland --- charts/devices-api/templates/secret.yaml | 8 + charts/devices-api/values.yaml | 2 + cmd/devices-api/api.go | 5 +- go.mod | 2 + go.sum | 5 + internal/config/settings.go | 8 + .../user_devices_controller_test.go | 2 +- .../user_integrations_auth_controller.go | 149 ++++++++++++ .../user_integrations_auth_controller_test.go | 220 ++++++++++++++++++ .../mocks/tesla_fleet_api_service_mock.go | 71 ++++++ internal/services/mocks/tesla_service_mock.go | 5 +- internal/services/tesla_fleet_api_service.go | 140 +++++++++++ settings.sample.yaml | 5 + 13 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 internal/controllers/user_integrations_auth_controller.go create mode 100644 internal/controllers/user_integrations_auth_controller_test.go create mode 100644 internal/services/mocks/tesla_fleet_api_service_mock.go create mode 100644 internal/services/tesla_fleet_api_service.go diff --git a/charts/devices-api/templates/secret.yaml b/charts/devices-api/templates/secret.yaml index 4c29cde2e..89f9f90d5 100644 --- a/charts/devices-api/templates/secret.yaml +++ b/charts/devices-api/templates/secret.yaml @@ -95,6 +95,14 @@ spec: - remoteRef: key: {{ .Release.Namespace }}/vc/vin/issuer_private_key secretKey: ISSUER_PRIVATE_KEY + {{- if eq .Release.Namespace "dev" }} + - remoteRef: + key: {{ .Release.Namespace }}/devices/tesla/client_id + secretKey: TESLA_CLIENT_ID + - remoteRef: + key: {{ .Release.Namespace }}/devices/tesla/client_secret + secretKey: TESLA_CLIENT_SECRET + {{- end }} secretStoreRef: kind: ClusterSecretStore name: aws-secretsmanager-secret-store diff --git a/charts/devices-api/values.yaml b/charts/devices-api/values.yaml index 4f4a017aa..4f296a141 100644 --- a/charts/devices-api/values.yaml +++ b/charts/devices-api/values.yaml @@ -92,6 +92,8 @@ env: DEVICE_DATA_GRPC_ADDR: device-data-api-dev:8086 SYNTHETIC_FINGERPRINT_TOPIC: topic.synthetic.fingerprint SYNTHETIC_FINGERPRINT_CONSUMER_GROUP: consumer.synthetic.fingerprint + TESLA_TOKEN_URL: https://auth.tesla.com/oauth2/v3/token + TESLA_FLEET_API: https://fleet-api.prd.%s.vn.cloud.tesla.com service: type: ClusterIP ports: diff --git a/cmd/devices-api/api.go b/cmd/devices-api/api.go index 1e3a83564..6a064f90e 100644 --- a/cmd/devices-api/api.go +++ b/cmd/devices-api/api.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/base64" + "github.com/DIMO-Network/devices-api/internal/services/fingerprint" "math/big" "net" "os" @@ -35,7 +36,6 @@ import ( "github.com/DIMO-Network/devices-api/internal/controllers" "github.com/DIMO-Network/devices-api/internal/services" "github.com/DIMO-Network/devices-api/internal/services/autopi" - "github.com/DIMO-Network/devices-api/internal/services/fingerprint" "github.com/DIMO-Network/devices-api/internal/services/issuer" "github.com/DIMO-Network/devices-api/internal/services/macaron" "github.com/DIMO-Network/devices-api/internal/services/registry" @@ -102,6 +102,7 @@ func startWebAPI(logger zerolog.Logger, settings *config.Settings, pdb db.Store, smartcarClient := services.NewSmartcarClient(settings) teslaTaskService := services.NewTeslaTaskService(settings, producer) teslaSvc := services.NewTeslaService(settings) + teslaFleetAPISvc := services.NewTeslaFleetAPIService(settings, &logger) autoPiSvc := services.NewAutoPiAPIService(settings, pdb.DBS) valuationsSvc := services.NewValuationsAPIService(settings, &logger) autoPiIngest := services.NewIngestRegistrar(producer) @@ -144,6 +145,7 @@ func startWebAPI(logger zerolog.Logger, settings *config.Settings, pdb db.Store, webhooksController := controllers.NewWebhooksController(settings, pdb.DBS, &logger, autoPiSvc, ddIntSvc) documentsController := controllers.NewDocumentsController(settings, &logger, s3ServiceClient, pdb.DBS) countriesController := controllers.NewCountriesController() + userIntegrationAuthController := controllers.NewUserIntegrationAuthController(settings, pdb.DBS, &logger, ddSvc, teslaFleetAPISvc) // commenting this out b/c the library includes the path in the metrics which saturates prometheus queries - need to fork / make our own //prometheus := fiberprometheus.New("devices-api") @@ -232,6 +234,7 @@ func startWebAPI(logger zerolog.Logger, settings *config.Settings, pdb db.Store, v1Auth.Post("/user/devices/fromvin", userDeviceController.RegisterDeviceForUserFromVIN) v1Auth.Post("/user/devices/fromsmartcar", userDeviceController.RegisterDeviceForUserFromSmartcar) v1Auth.Post("/user/devices", userDeviceController.RegisterDeviceForUser) + v1Auth.Post("/integration/:tokenID/credentials", userIntegrationAuthController.CompleteOAuthExchange) v1Auth.Get("/integrations", userDeviceController.GetIntegrations) diff --git a/go.mod b/go.mod index 6953be160..da4bb70aa 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( go.uber.org/automaxprocs v1.5.1 go.uber.org/mock v0.3.0 golang.org/x/mod v0.14.0 + golang.org/x/oauth2 v0.13.0 ) require ( @@ -104,6 +105,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.5.0 // indirect golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 61091ec0e..6eee184da 100644 --- a/go.sum +++ b/go.sum @@ -1201,6 +1201,8 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1340,6 +1342,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -1471,6 +1474,8 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= diff --git a/internal/config/settings.go b/internal/config/settings.go index 2279ea1b2..e9b44daae 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -88,8 +88,16 @@ type Settings struct { SyntheticFingerprintTopic string `yaml:"SYNTHETIC_FINGERPRINT_TOPIC"` SyntheticFingerprintConsumerGroup string `yaml:"SYNTHETIC_FINGERPRINT_CONSUMER_GROUP"` + Tesla Tesla `yaml:"TESLA"` } func (s *Settings) IsProduction() bool { return s.Environment == "prod" // this string is set in the helm chart values-prod.yaml } + +type Tesla struct { + ClientID string `yaml:"CLIENT_ID"` + ClientSecret string `yaml:"CLIENT_SECRET"` + TokenURL string `yaml:"TOKEN_URL"` + FleetAPI string `yaml:"FLEET_API"` +} diff --git a/internal/controllers/user_devices_controller_test.go b/internal/controllers/user_devices_controller_test.go index 7ad3ed5f5..cbf92bebd 100644 --- a/internal/controllers/user_devices_controller_test.go +++ b/internal/controllers/user_devices_controller_test.go @@ -124,8 +124,8 @@ func (s *UserDevicesControllerTestSuite) SetupSuite() { app.Get("/user/devices/:userDeviceID/valuations", test.AuthInjectorTestHandler(s.testUserID), c.GetValuations) app.Get("/user/devices/:userDeviceID/range", test.AuthInjectorTestHandler(s.testUserID), c.GetRange) app.Post("/user/devices/:userDeviceID/commands/refresh", test.AuthInjectorTestHandler(s.testUserID), c.RefreshUserDeviceStatus) - s.controller = &c + s.controller = &c s.app = app } diff --git a/internal/controllers/user_integrations_auth_controller.go b/internal/controllers/user_integrations_auth_controller.go new file mode 100644 index 000000000..88791488a --- /dev/null +++ b/internal/controllers/user_integrations_auth_controller.go @@ -0,0 +1,149 @@ +package controllers + +import ( + "github.com/DIMO-Network/devices-api/internal/config" + "github.com/DIMO-Network/devices-api/internal/constants" + "github.com/DIMO-Network/devices-api/internal/services" + "github.com/DIMO-Network/shared/db" + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" + "strconv" +) + +type UserIntegrationAuthController struct { + Settings *config.Settings + DBS func() *db.ReaderWriter + DeviceDefSvc services.DeviceDefinitionService + log *zerolog.Logger + teslaFleetAPISvc services.TeslaFleetAPIService +} + +func NewUserIntegrationAuthController( + settings *config.Settings, + dbs func() *db.ReaderWriter, + logger *zerolog.Logger, + ddSvc services.DeviceDefinitionService, + teslaFleetAPISvc services.TeslaFleetAPIService, +) UserIntegrationAuthController { + return UserIntegrationAuthController{ + Settings: settings, + DBS: dbs, + DeviceDefSvc: ddSvc, + log: logger, + teslaFleetAPISvc: teslaFleetAPISvc, + } +} + +// CompleteOAuthExchangeResponseWrapper response wrapper for list of user vehicles +type CompleteOAuthExchangeResponseWrapper struct { + Vehicles []CompleteOAuthExchangeResponse `json:"vehicles"` +} + +// CompleteOAuthExchangeRequest request object for completing tesla OAuth +type CompleteOAuthExchangeRequest struct { + AuthorizationCode string `json:"authorizationCode"` + RedirectURI string `json:"redirectUri"` + Region string `json:"region"` +} + +// CompleteOAuthExchangeResponse response object for tesla vehicles attached to user account +type CompleteOAuthExchangeResponse struct { + ExternalID string `json:"externalId"` + VIN string `json:"vin"` + Definition DeviceDefinition `json:"definition"` +} + +// DeviceDefinition inner definition object containing meta data for each tesla vehicle +type DeviceDefinition struct { + Make string `json:"make"` + Model string `json:"model"` + Year int `json:"year"` + DeviceDefinitionID string `json:"id"` +} + +// CompleteOAuthExchange godoc +// @Description Complete Tesla auth and get devices for authenticated user +// @Tags user-devices +// @Produce json +// @Accept json +// @Param tokenID path string true "token id for integration" +// @Param user_device body controllers.CompleteOAuthExchangeRequest true "all fields are required" +// @Security ApiKeyAuth +// @Success 200 {object} controllers.CompleteOAuthExchangeResponseWrapper +// @Security BearerAuth +// @Router /integration/:tokenID/credentials [post] +func (u *UserIntegrationAuthController) CompleteOAuthExchange(c *fiber.Ctx) error { + tokenID := c.Params("tokenID") + + tkID, err := strconv.ParseUint(tokenID, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "could not process the provided tokenId!") + } + + intd, err := u.DeviceDefSvc.GetIntegrationByTokenID(c.Context(), tkID) + if err != nil { + u.log.Err(err).Str("Calling Function", "GetIntegrationByTokenID").Uint64("tokenID", tkID).Msg("Error occurred trying to get integration using tokenID") + return fiber.NewError(fiber.StatusInternalServerError, "an error occurred completing authorization") + } + if intd.Vendor != constants.TeslaVendor { + return fiber.NewError(fiber.StatusBadRequest, "invalid value provided for tokenId!") + } + + reqBody := new(CompleteOAuthExchangeRequest) + if err := c.BodyParser(reqBody); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Couldn't parse request JSON body.") + } + + if reqBody.Region != "na" && reqBody.Region != "eu" { + return fiber.NewError(fiber.StatusBadRequest, "invalid value provided for region, only na and eu are allowed") + } + + logger := u.log.With(). + Str("region", reqBody.Region). + Str("redirectUri", reqBody.RedirectURI). + Str("route", c.Route().Path). + Logger() + logger.Info().Msg("Attempting to complete Tesla authorization") + + teslaAuth, err := u.teslaFleetAPISvc.CompleteTeslaAuthCodeExchange(c.Context(), reqBody.AuthorizationCode, reqBody.RedirectURI, reqBody.Region) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to get tesla authCode:"+err.Error()) + } + + vehicles, err := u.teslaFleetAPISvc.GetVehicles(c.Context(), teslaAuth.AccessToken, reqBody.Region) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "error occurred fetching vehicles:"+err.Error()) + } + + response := make([]CompleteOAuthExchangeResponse, 0, len(vehicles)) + for _, v := range vehicles { + decodeVIN, err := u.DeviceDefSvc.DecodeVIN(c.Context(), v.VIN, "", 0, "") + if err != nil || len(decodeVIN.DeviceDefinitionId) == 0 { + u.log.Err(err).Msg("An error occurred decoding vin for tesla vehicle") + return fiber.NewError(fiber.StatusFailedDependency, "An error occurred completing tesla authorization") + } + + dd, err := u.DeviceDefSvc.GetDeviceDefinitionByID(c.Context(), decodeVIN.DeviceDefinitionId) + if err != nil || len(decodeVIN.DeviceDefinitionId) == 0 { + u.log.Err(err).Str("deviceDefinitionID", decodeVIN.DeviceDefinitionId).Msg("An error occurred fetching device definition using ID") + return fiber.NewError(fiber.StatusFailedDependency, "An error occurred completing tesla authorization") + } + + response = append(response, CompleteOAuthExchangeResponse{ + ExternalID: strconv.Itoa(v.ID), + VIN: v.VIN, + Definition: DeviceDefinition{ + Make: dd.Type.Make, + Model: dd.Type.Model, + Year: int(dd.Type.Year), + DeviceDefinitionID: decodeVIN.DeviceDefinitionId, + }, + }) + } + + vehicleResp := &CompleteOAuthExchangeResponseWrapper{ + Vehicles: response, + } + + return c.JSON(vehicleResp) +} diff --git a/internal/controllers/user_integrations_auth_controller_test.go b/internal/controllers/user_integrations_auth_controller_test.go new file mode 100644 index 000000000..23691c253 --- /dev/null +++ b/internal/controllers/user_integrations_auth_controller_test.go @@ -0,0 +1,220 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + ddgrpc "github.com/DIMO-Network/device-definitions-api/pkg/grpc" + "github.com/DIMO-Network/devices-api/internal/config" + "github.com/DIMO-Network/devices-api/internal/constants" + "github.com/DIMO-Network/devices-api/internal/services" + mock_services "github.com/DIMO-Network/devices-api/internal/services/mocks" + "github.com/DIMO-Network/devices-api/internal/test" + "github.com/DIMO-Network/shared/db" + "github.com/DIMO-Network/shared/redis/mocks" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" + "go.uber.org/mock/gomock" + "io" + "testing" + "time" +) + +type UserIntegrationAuthControllerTestSuite struct { + suite.Suite + pdb db.Store + controller *UserIntegrationAuthController + container testcontainers.Container + ctx context.Context + mockCtrl *gomock.Controller + app *fiber.App + deviceDefSvc *mock_services.MockDeviceDefinitionService + testUserID string + redisClient *mocks.MockCacheService + teslaFleetAPISvc *mock_services.MockTeslaFleetAPIService +} + +// SetupSuite starts container db +func (s *UserIntegrationAuthControllerTestSuite) SetupSuite() { + s.ctx = context.Background() + s.pdb, s.container = test.StartContainerDatabase(s.ctx, s.T(), migrationsDirRelPath) + logger := test.Logger() + mockCtrl := gomock.NewController(s.T()) + s.mockCtrl = mockCtrl + + s.deviceDefSvc = mock_services.NewMockDeviceDefinitionService(mockCtrl) + s.teslaFleetAPISvc = mock_services.NewMockTeslaFleetAPIService(mockCtrl) + s.redisClient = mocks.NewMockCacheService(mockCtrl) + s.testUserID = "123123" + + c := NewUserIntegrationAuthController(&config.Settings{ + Port: "3000", + Environment: "prod", + }, s.pdb.DBS, logger, s.deviceDefSvc, s.teslaFleetAPISvc) + app := test.SetupAppFiber(*logger) + app.Post("/integration/:tokenID/credentials", test.AuthInjectorTestHandler(s.testUserID), c.CompleteOAuthExchange) + + s.controller = &c + s.app = app +} + +func (s *UserIntegrationAuthControllerTestSuite) SetupTest() { + s.controller.Settings.Environment = "prod" +} + +// TearDownTest after each test truncate tables +func (s *UserIntegrationAuthControllerTestSuite) TearDownTest() { + test.TruncateTables(s.pdb.DBS().Writer.DB, s.T()) +} + +// TearDownSuite cleanup at end by terminating container +func (s *UserIntegrationAuthControllerTestSuite) TearDownSuite() { + fmt.Printf("shutting down postgres at with session: %s \n", s.container.SessionID()) + if err := s.container.Terminate(s.ctx); err != nil { + s.T().Fatal(err) + } + s.mockCtrl.Finish() // might need to do mockctrl on every test, and refactor setup into one method +} + +// Test Runner +func TestUserIntegrationAuthControllerTestSuite(t *testing.T) { + suite.Run(t, new(UserIntegrationAuthControllerTestSuite)) +} + +func (s *UserIntegrationAuthControllerTestSuite) TestCompleteOAuthExchanges() { + mockAuthCode := "Mock_fd941f8da609db8cd66b1734f84ab289e2975b1889a5bedf478f02cf0cc4" + mockRedirectURI := "https://mock-redirect.test.dimo.zone" + mockRegion := "na" + + mockAuthCodeResp := &services.TeslaAuthCodeResponse{ + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + RefreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.UWfqdcCvyzObpI2gaIGcx2r7CcDjlQ0IzGyk8N0_vqw", + IDToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.ouLgsgz-xUWN7lLuo8qE2nueNgJIrBz49QLr_GLHRno", + Expiry: time.Now().Add(time.Hour * 1), + } + s.deviceDefSvc.EXPECT().GetIntegrationByTokenID(gomock.Any(), uint64(2)).Return(&ddgrpc.Integration{ + Vendor: constants.TeslaVendor, + }, nil) + s.teslaFleetAPISvc.EXPECT().CompleteTeslaAuthCodeExchange(gomock.Any(), mockAuthCode, mockRedirectURI, mockRegion).Return(mockAuthCodeResp, nil) + s.deviceDefSvc.EXPECT().DecodeVIN(gomock.Any(), "1GBGC24U93Z337558", "", 0, "").Return(&ddgrpc.DecodeVinResponse{DeviceDefinitionId: "someID-1"}, nil) + s.deviceDefSvc.EXPECT().DecodeVIN(gomock.Any(), "WAUAF78E95A553420", "", 0, "").Return(&ddgrpc.DecodeVinResponse{DeviceDefinitionId: "someID-2"}, nil) + + s.deviceDefSvc.EXPECT().GetDeviceDefinitionByID(gomock.Any(), "someID-1").Return(&ddgrpc.GetDeviceDefinitionItemResponse{ + DeviceDefinitionId: "someID-1", + Type: &ddgrpc.DeviceType{ + Make: "Tesla", + Model: "Y", + Year: 2022, + }, + }, nil) + s.deviceDefSvc.EXPECT().GetDeviceDefinitionByID(gomock.Any(), "someID-2").Return(&ddgrpc.GetDeviceDefinitionItemResponse{ + DeviceDefinitionId: "someID-2", + Type: &ddgrpc.DeviceType{ + Make: "Tesla", + Model: "X", + Year: 2020, + }, + }, nil) + + resp := []services.TeslaVehicle{ + { + ID: 11114464922222, + VIN: "1GBGC24U93Z337558", + }, + { + ID: 22222464999999, + VIN: "WAUAF78E95A553420", + }, + } + s.teslaFleetAPISvc.EXPECT().GetVehicles(gomock.Any(), mockAuthCodeResp.AccessToken, mockRegion).Return(resp, nil) + + request := test.BuildRequest("POST", "/integration/2/credentials", fmt.Sprintf(`{ + "authorizationCode": "%s", + "redirectUri": "%s", + "region": "na" + }`, mockAuthCode, mockRedirectURI)) + response, _ := s.app.Test(request) + + s.Assert().Equal(fiber.StatusOK, response.StatusCode) + body, _ := io.ReadAll(response.Body) + + expResp := &CompleteOAuthExchangeResponseWrapper{ + Vehicles: []CompleteOAuthExchangeResponse{ + { + ExternalID: "11114464922222", + VIN: "1GBGC24U93Z337558", + Definition: DeviceDefinition{ + Make: "Tesla", + Model: "Y", + Year: 2022, + DeviceDefinitionID: "someID-1", + }, + }, + { + ExternalID: "22222464999999", + VIN: "WAUAF78E95A553420", + Definition: DeviceDefinition{ + Make: "Tesla", + Model: "X", + Year: 2020, + DeviceDefinitionID: "someID-2", + }, + }, + }, + } + + expected, err := json.Marshal(expResp) + s.Assert().NoError(err) + + s.Assert().Equal(expected, body) +} + +func (s *UserIntegrationAuthControllerTestSuite) TestCompleteOAuthExchange_InvalidRegion() { + s.deviceDefSvc.EXPECT().GetIntegrationByTokenID(gomock.Any(), uint64(2)).Return(&ddgrpc.Integration{ + Vendor: constants.TeslaVendor, + }, nil) + request := test.BuildRequest("POST", "/integration/2/credentials", fmt.Sprintf(`{ + "authorizationCode": "%s", + "redirectUri": "%s", + "region": "us-central" + }`, "mockAuthCode", "mockRedirectURI")) + response, _ := s.app.Test(request) + + s.Assert().Equal(fiber.StatusBadRequest, response.StatusCode) + body, _ := io.ReadAll(response.Body) + + s.Assert().Equal(`{"code":400,"message":"invalid value provided for region, only na and eu are allowed"}`, string(body)) +} + +func (s *UserIntegrationAuthControllerTestSuite) TestCompleteOAuthExchange_UnprocessableTokenID() { + request := test.BuildRequest("POST", "/integration/wrongTokenID/credentials", fmt.Sprintf(`{ + "authorizationCode": "%s", + "redirectUri": "%s", + "region": "us-central" + }`, "mockAuthCode", "mockRedirectURI")) + response, _ := s.app.Test(request) + + s.Assert().Equal(fiber.StatusBadRequest, response.StatusCode) + body, _ := io.ReadAll(response.Body) + + s.Assert().Equal(`{"code":400,"message":"could not process the provided tokenId!"}`, string(body)) +} + +func (s *UserIntegrationAuthControllerTestSuite) TestCompleteOAuthExchange_InvalidTokenID() { + s.deviceDefSvc.EXPECT().GetIntegrationByTokenID(gomock.Any(), uint64(1)).Return(&ddgrpc.Integration{ + Vendor: constants.SmartCarVendor, + }, nil) + + request := test.BuildRequest("POST", "/integration/1/credentials", fmt.Sprintf(`{ + "authorizationCode": "%s", + "redirectUri": "%s", + "region": "us-central" + }`, "mockAuthCode", "mockRedirectURI")) + response, _ := s.app.Test(request) + + s.Assert().Equal(fiber.StatusBadRequest, response.StatusCode) + body, _ := io.ReadAll(response.Body) + + s.Assert().Equal(`{"code":400,"message":"invalid value provided for tokenId!"}`, string(body)) +} diff --git a/internal/services/mocks/tesla_fleet_api_service_mock.go b/internal/services/mocks/tesla_fleet_api_service_mock.go new file mode 100644 index 000000000..6372a3d1c --- /dev/null +++ b/internal/services/mocks/tesla_fleet_api_service_mock.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/services/tesla_fleet_api_service.go +// +// Generated by this command: +// +// mockgen -source=internal/services/tesla_fleet_api_service.go -destination=internal/services/mocks/tesla_fleet_api_service_mock.go +// + +// Package mock_services is a generated GoMock package. +package mock_services + +import ( + context "context" + reflect "reflect" + + services "github.com/DIMO-Network/devices-api/internal/services" + gomock "go.uber.org/mock/gomock" +) + +// MockTeslaFleetAPIService is a mock of TeslaFleetAPIService interface. +type MockTeslaFleetAPIService struct { + ctrl *gomock.Controller + recorder *MockTeslaFleetAPIServiceMockRecorder +} + +// MockTeslaFleetAPIServiceMockRecorder is the mock recorder for MockTeslaFleetAPIService. +type MockTeslaFleetAPIServiceMockRecorder struct { + mock *MockTeslaFleetAPIService +} + +// NewMockTeslaFleetAPIService creates a new mock instance. +func NewMockTeslaFleetAPIService(ctrl *gomock.Controller) *MockTeslaFleetAPIService { + mock := &MockTeslaFleetAPIService{ctrl: ctrl} + mock.recorder = &MockTeslaFleetAPIServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTeslaFleetAPIService) EXPECT() *MockTeslaFleetAPIServiceMockRecorder { + return m.recorder +} + +// CompleteTeslaAuthCodeExchange mocks base method. +func (m *MockTeslaFleetAPIService) CompleteTeslaAuthCodeExchange(ctx context.Context, authCode, redirectURI, region string) (*services.TeslaAuthCodeResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CompleteTeslaAuthCodeExchange", ctx, authCode, redirectURI, region) + ret0, _ := ret[0].(*services.TeslaAuthCodeResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CompleteTeslaAuthCodeExchange indicates an expected call of CompleteTeslaAuthCodeExchange. +func (mr *MockTeslaFleetAPIServiceMockRecorder) CompleteTeslaAuthCodeExchange(ctx, authCode, redirectURI, region any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CompleteTeslaAuthCodeExchange", reflect.TypeOf((*MockTeslaFleetAPIService)(nil).CompleteTeslaAuthCodeExchange), ctx, authCode, redirectURI, region) +} + +// GetVehicles mocks base method. +func (m *MockTeslaFleetAPIService) GetVehicles(ctx context.Context, token, region string) ([]services.TeslaVehicle, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVehicles", ctx, token, region) + ret0, _ := ret[0].([]services.TeslaVehicle) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetVehicles indicates an expected call of GetVehicles. +func (mr *MockTeslaFleetAPIServiceMockRecorder) GetVehicles(ctx, token, region any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVehicles", reflect.TypeOf((*MockTeslaFleetAPIService)(nil).GetVehicles), ctx, token, region) +} diff --git a/internal/services/mocks/tesla_service_mock.go b/internal/services/mocks/tesla_service_mock.go index c1de7d3d4..e1ac8edbe 100644 --- a/internal/services/mocks/tesla_service_mock.go +++ b/internal/services/mocks/tesla_service_mock.go @@ -1,10 +1,11 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: tesla_service.go +// Source: internal/services/tesla_service.go // // Generated by this command: // -// mockgen -source tesla_service.go -destination mocks/tesla_service_mock.go +// mockgen -source=internal/services/tesla_service.go -destination=internal/services/mocks/tesla_service_mock.go // + // Package mock_services is a generated GoMock package. package mock_services diff --git a/internal/services/tesla_fleet_api_service.go b/internal/services/tesla_fleet_api_service.go new file mode 100644 index 000000000..51ebd81ca --- /dev/null +++ b/internal/services/tesla_fleet_api_service.go @@ -0,0 +1,140 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/DIMO-Network/devices-api/internal/config" + "github.com/rs/zerolog" + "golang.org/x/oauth2" +) + +//go:generate mockgen -source tesla_fleet_api_service.go -destination mocks/tesla_fleet_api_service_mock.go +type TeslaFleetAPIService interface { + CompleteTeslaAuthCodeExchange(ctx context.Context, authCode, redirectURI, region string) (*TeslaAuthCodeResponse, error) + GetVehicles(ctx context.Context, token, region string) ([]TeslaVehicle, error) +} + +var teslaScopes = []string{"openid", "offline_access", "user_data", "vehicle_device_data", "vehicle_cmds", "vehicle_charging_cmds", "energy_device_data", "energy_device_data", "energy_cmds"} + +type GetVehiclesResponse struct { + Response []TeslaVehicle `json:"response"` +} + +type TeslaFleetAPIError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + ReferenceID string `json:"referenceId"` +} + +type TeslaAuthCodeResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + Expiry time.Time `json:"expiry"` + TokenType string `json:"token_type"` +} + +type teslaFleetAPIService struct { + Settings *config.Settings + HTTPClient *http.Client + log *zerolog.Logger +} + +func NewTeslaFleetAPIService(settings *config.Settings, logger *zerolog.Logger) TeslaFleetAPIService { + return &teslaFleetAPIService{ + Settings: settings, + HTTPClient: &http.Client{}, + log: logger, + } +} + +// CompleteTeslaAuthCodeExchange godoc +// @Description Call Tesla Fleet API and exchange auth code for a new auth and refresh token +// @Param authCode - authorization code to exchange +// @Param redirectURI - redirect uri to pass on as part of the request to for oauth exchange +// @Param region - API region which is used to determine which fleet api to call +// @Returns {object} services.TeslaAuthCodeResponse +func (t *teslaFleetAPIService) CompleteTeslaAuthCodeExchange(ctx context.Context, authCode, redirectURI, region string) (*TeslaAuthCodeResponse, error) { + conf := oauth2.Config{ + ClientID: t.Settings.Tesla.ClientID, + ClientSecret: t.Settings.Tesla.ClientSecret, + Endpoint: oauth2.Endpoint{ + TokenURL: t.Settings.Tesla.TokenURL, + }, + RedirectURL: redirectURI, + Scopes: teslaScopes, + } + + ctxTimeout, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + + tok, err := conf.Exchange(ctxTimeout, authCode, oauth2.SetAuthURLParam("audience", fmt.Sprintf(t.Settings.Tesla.FleetAPI, region))) + if err != nil { + var e *oauth2.RetrieveError + errString := err.Error() + if errors.As(err, &e) { + errString = e.ErrorDescription + } + return nil, fmt.Errorf("error occurred completing authorization: %s", errString) + } + + return &TeslaAuthCodeResponse{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + TokenType: tok.TokenType, + }, nil +} + +// GetVehicles godoc +// @Description Call Tesla Fleet API to get a list of vehicles using authorization token +// @Param token - authorization token to be used as bearer token +// @Param region - API region which is used to determine which fleet api to call +// @Returns {array} []services.TeslaVehicle +func (t *teslaFleetAPIService) GetVehicles(ctx context.Context, token, region string) ([]TeslaVehicle, error) { + baseURL := fmt.Sprintf(t.Settings.Tesla.FleetAPI, region) + url := baseURL + "/api/1/vehicles" + + ctxTimeout, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + + req, err := http.NewRequestWithContext(ctxTimeout, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("could not fetch vehicles for user: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := t.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("could not fetch vehicles for user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errBody := new(TeslaFleetAPIError) + if err := json.NewDecoder(resp.Body).Decode(errBody); err != nil { + t.log. + Err(err). + Str("url", fmt.Sprintf(t.Settings.Tesla.FleetAPI, region)). + Msg("An error occurred when attempting to decode the error message from the api.") + return nil, fmt.Errorf("invalid response encountered while fetching user vehicles: %s", errBody.ErrorDescription) + } + return nil, fmt.Errorf("error occurred fetching user vehicles: %s", errBody.ErrorDescription) + } + + vehicles := new(GetVehiclesResponse) + if err := json.NewDecoder(resp.Body).Decode(vehicles); err != nil { + return nil, fmt.Errorf("invalid response encountered while fetching user vehicles: %w", err) + } + + if vehicles.Response == nil { + return nil, fmt.Errorf("error occurred fetching user vehicles") + } + + return vehicles.Response, nil +} diff --git a/settings.sample.yaml b/settings.sample.yaml index c2175f65a..a6463b17d 100644 --- a/settings.sample.yaml +++ b/settings.sample.yaml @@ -75,3 +75,8 @@ DEVICE_FINGERPRINT_CONSUMER_GROUP: device-fingerprint-vc-issuer SYNTHETIC_FINGERPRINT_TOPIC: topic.synthetic.fingerprint SYNTHETIC_FINGERPRINT_CONSUMER_GROUP: consumer.synthetic.fingerprint +TESLA: + CLIENT_ID: + CLIENT_SECRET: + TOKEN_AUTH_URL: + FLEET_API: https://fleet-api.prd.%s.vn.cloud.tesla.com \ No newline at end of file