From 7ef306a03ab3db4de2792d0dc4041d4901dc513f Mon Sep 17 00:00:00 2001 From: "Doyin.O" <111298305+0xdev22@users.noreply.github.com> Date: Thu, 29 Feb 2024 13:47:03 -0700 Subject: [PATCH] Feature/SI-2449: Add Tesla Fleet API implementation to register Tesla Device implementation (#269) * Persist auth response from tesla to redis and write tests for new function * Add Tesla Fleet API implementation to register Tesla Device implementation * Fix issus from PR review and implement api calls for GetVehicle and WakeUpVehicle for new fleet API, generate swagger schema * Make version field naming consistent --- cmd/devices-api/api.go | 2 +- docs/docs.go | 110 ++++++++- docs/swagger.json | 107 +++++++++ docs/swagger.yaml | 67 ++++++ internal/config/settings.go | 2 +- internal/constants/consts.go | 5 + .../device_data_controller_test.go | 20 +- .../controllers/user_devices_controller.go | 3 + .../user_devices_controller_test.go | 2 +- .../user_integrations_auth_controller.go | 6 +- .../user_integrations_auth_controller_test.go | 5 +- .../user_integrations_controller.go | 96 +++++++- .../user_integrations_controller_test.go | 215 ++++++++++++++++-- .../mocks/tesla_fleet_api_service_mock.go | 29 +++ .../services/mocks/tesla_task_service_mock.go | 13 +- internal/services/models.go | 5 +- internal/services/tesla_fleet_api_service.go | 96 ++++++-- internal/services/tesla_task_service.go | 6 +- 18 files changed, 717 insertions(+), 72 deletions(-) diff --git a/cmd/devices-api/api.go b/cmd/devices-api/api.go index 61725c159..b9c5bf4a4 100644 --- a/cmd/devices-api/api.go +++ b/cmd/devices-api/api.go @@ -141,7 +141,7 @@ func startWebAPI(logger zerolog.Logger, settings *config.Settings, pdb db.Store, userDeviceController := controllers.NewUserDevicesController(settings, pdb.DBS, &logger, ddSvc, ddIntSvc, eventService, smartcarClient, scTaskSvc, teslaSvc, teslaTaskService, cipher, autoPiSvc, services.NewNHTSAService(), autoPiIngest, deviceDefinitionRegistrar, autoPiTaskService, producer, s3NFTServiceClient, autoPi, redisCache, openAI, usersClient, - ddaSvc, natsSvc, wallet, userDeviceSvc, valuationsSvc) + ddaSvc, natsSvc, wallet, userDeviceSvc, valuationsSvc, teslaFleetAPISvc) geofenceController := controllers.NewGeofencesController(settings, pdb.DBS, &logger, producer, ddSvc) webhooksController := controllers.NewWebhooksController(settings, pdb.DBS, &logger, autoPiSvc, ddIntSvc) documentsController := controllers.NewDocumentsController(settings, &logger, s3ServiceClient, pdb.DBS) diff --git a/docs/docs.go b/docs/docs.go index fabb91213..e81fc87a6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,4 +1,5 @@ -// Package docs Code generated by swaggo/swag at 2024-02-19 20:21:35.823998 -0500 EST m=+3.225976210. DO NOT EDIT +// Code generated by swaggo/swag at 2024-02-27 17:18:44.130124 -0500 EST m=+1.896616959. DO NOT EDIT. + package docs import "github.com/swaggo/swag" @@ -588,6 +589,54 @@ const docTemplate = `{ } } }, + "/integration/:tokenID/credentials": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Complete Tesla auth and get devices for authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-devices" + ], + "parameters": [ + { + "type": "string", + "description": "token id for integration", + "name": "tokenID", + "in": "path", + "required": true + }, + { + "description": "all fields are required", + "name": "user_device", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_controllers.CompleteOAuthExchangeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_controllers.CompleteOAuthExchangeResponseWrapper" + } + } + } + } + }, "/integrations": { "get": { "security": [ @@ -2993,6 +3042,45 @@ const docTemplate = `{ } } }, + "internal_controllers.CompleteOAuthExchangeRequest": { + "type": "object", + "properties": { + "authorizationCode": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "region": { + "type": "string" + } + } + }, + "internal_controllers.CompleteOAuthExchangeResponse": { + "type": "object", + "properties": { + "definition": { + "$ref": "#/definitions/internal_controllers.DeviceDefinition" + }, + "externalId": { + "type": "string" + }, + "vin": { + "type": "string" + } + } + }, + "internal_controllers.CompleteOAuthExchangeResponseWrapper": { + "type": "object", + "properties": { + "vehicles": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_controllers.CompleteOAuthExchangeResponse" + } + } + } + }, "internal_controllers.CreateGeofence": { "type": "object", "properties": { @@ -3020,6 +3108,23 @@ const docTemplate = `{ } } }, + "internal_controllers.DeviceDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "type": "string" + }, + "year": { + "type": "integer" + } + } + }, "internal_controllers.DeviceOffer": { "type": "object", "properties": { @@ -3546,6 +3651,9 @@ const docTemplate = `{ }, "refreshToken": { "type": "string" + }, + "version": { + "type": "integer" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 86865245a..c1f5b9750 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -580,6 +580,54 @@ } } }, + "/integration/:tokenID/credentials": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Complete Tesla auth and get devices for authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-devices" + ], + "parameters": [ + { + "type": "string", + "description": "token id for integration", + "name": "tokenID", + "in": "path", + "required": true + }, + { + "description": "all fields are required", + "name": "user_device", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_controllers.CompleteOAuthExchangeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_controllers.CompleteOAuthExchangeResponseWrapper" + } + } + } + } + }, "/integrations": { "get": { "security": [ @@ -2985,6 +3033,45 @@ } } }, + "internal_controllers.CompleteOAuthExchangeRequest": { + "type": "object", + "properties": { + "authorizationCode": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "region": { + "type": "string" + } + } + }, + "internal_controllers.CompleteOAuthExchangeResponse": { + "type": "object", + "properties": { + "definition": { + "$ref": "#/definitions/internal_controllers.DeviceDefinition" + }, + "externalId": { + "type": "string" + }, + "vin": { + "type": "string" + } + } + }, + "internal_controllers.CompleteOAuthExchangeResponseWrapper": { + "type": "object", + "properties": { + "vehicles": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_controllers.CompleteOAuthExchangeResponse" + } + } + } + }, "internal_controllers.CreateGeofence": { "type": "object", "properties": { @@ -3012,6 +3099,23 @@ } } }, + "internal_controllers.DeviceDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "type": "string" + }, + "year": { + "type": "integer" + } + } + }, "internal_controllers.DeviceOffer": { "type": "object", "properties": { @@ -3538,6 +3642,9 @@ }, "refreshToken": { "type": "string" + }, + "version": { + "type": "integer" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e78d9550a..56441bcad 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -414,6 +414,31 @@ definitions: requestId: type: string type: object + internal_controllers.CompleteOAuthExchangeRequest: + properties: + authorizationCode: + type: string + redirectUri: + type: string + region: + type: string + type: object + internal_controllers.CompleteOAuthExchangeResponse: + properties: + definition: + $ref: '#/definitions/internal_controllers.DeviceDefinition' + externalId: + type: string + vin: + type: string + type: object + internal_controllers.CompleteOAuthExchangeResponseWrapper: + properties: + vehicles: + items: + $ref: '#/definitions/internal_controllers.CompleteOAuthExchangeResponse' + type: array + type: object internal_controllers.CreateGeofence: properties: h3Indexes: @@ -435,6 +460,17 @@ definitions: type: string type: array type: object + internal_controllers.DeviceDefinition: + properties: + id: + type: string + make: + type: string + model: + type: string + year: + type: integer + type: object internal_controllers.DeviceOffer: properties: offerSets: @@ -812,6 +848,8 @@ definitions: type: string refreshToken: type: string + version: + type: integer type: object internal_controllers.RegisterUserDevice: properties: @@ -1426,6 +1464,35 @@ paths: type: array tags: - integrations + /integration/:tokenID/credentials: + post: + consumes: + - application/json + description: Complete Tesla auth and get devices for authenticated user + parameters: + - description: token id for integration + in: path + name: tokenID + required: true + type: string + - description: all fields are required + in: body + name: user_device + required: true + schema: + $ref: '#/definitions/internal_controllers.CompleteOAuthExchangeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_controllers.CompleteOAuthExchangeResponseWrapper' + security: + - ApiKeyAuth: [] + - BearerAuth: [] + tags: + - user-devices /integrations: get: description: gets list of integrations we have defined diff --git a/internal/config/settings.go b/internal/config/settings.go index db71819da..7ba326626 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -99,6 +99,6 @@ func (s *Settings) IsProduction() bool { type Tesla struct { ClientID string `yaml:"CLIENT_ID"` ClientSecret string `yaml:"CLIENT_SECRET"` - TokenURL string `yaml:"TOKEN_URL"` + TokenURL string `yaml:"TOKEN_AUTH_URL"` FleetAPI string `yaml:"FLEET_API"` } diff --git a/internal/constants/consts.go b/internal/constants/consts.go index 436272dd7..ef7de6ac3 100644 --- a/internal/constants/consts.go +++ b/internal/constants/consts.go @@ -27,6 +27,11 @@ const ( IntegrationTypeAPI string = "API" ) +const ( + TeslaAPIV1 int = 1 + TeslaAPIV2 int = 2 +) + // AutoPiSubStatusEnum integration sub-status type AutoPiSubStatusEnum string diff --git a/internal/controllers/device_data_controller_test.go b/internal/controllers/device_data_controller_test.go index 133ace6d1..160b672e8 100644 --- a/internal/controllers/device_data_controller_test.go +++ b/internal/controllers/device_data_controller_test.go @@ -62,7 +62,7 @@ func TestUserDevicesController_calculateRange(t *testing.T) { DeviceAttributes: attrs, }, nil) - _ = NewUserDevicesController(&config.Settings{Port: "3000"}, nil, &logger, deviceDefSvc, nil, &fakeEventService{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _ = NewUserDevicesController(&config.Settings{Port: "3000"}, nil, &logger, deviceDefSvc, nil, &fakeEventService{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) rge, err := calculateRange(ctx, deviceDefSvc, ddID, styleID, .7) require.NoError(t, err) require.NotNil(t, rge) @@ -147,7 +147,7 @@ func TestUserDevicesController_GetUserDeviceStatus(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, mockDeps.deviceDataSvc, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, mockDeps.deviceDataSvc, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Get("/user/devices/:userDeviceID/status", test.AuthInjectorTestHandler(testUserID), c.GetUserDeviceStatus) @@ -200,7 +200,7 @@ func TestUserDevicesController_QueryDeviceErrorCodes(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Post("/user/devices/:userDeviceID/error-codes", test.AuthInjectorTestHandler(testUserID), c.QueryDeviceErrorCodes) @@ -274,7 +274,7 @@ func TestUserDevicesController_ShouldErrorOnTooManyErrorCodes(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Post("/user/devices/:userDeviceID/error-codes", test.AuthInjectorTestHandler(testUserID), c.QueryDeviceErrorCodes) @@ -347,7 +347,7 @@ func TestUserDevicesController_ShouldErrorInvalidErrorCodes(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Post("/user/devices/:userDeviceID/error-codes", test.AuthInjectorTestHandler(testUserID), c.QueryDeviceErrorCodes) @@ -416,7 +416,7 @@ func TestUserDevicesController_ShouldErrorOnEmptyErrorCodes(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Post("/user/devices/:userDeviceID/error-codes", test.AuthInjectorTestHandler(testUserID), c.QueryDeviceErrorCodes) @@ -485,7 +485,7 @@ func TestUserDevicesController_ShouldStoreErrorCodeResponse(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Post("/user/devices/:userDeviceID/error-codes", test.AuthInjectorTestHandler(testUserID), c.QueryDeviceErrorCodes) @@ -570,7 +570,7 @@ func TestUserDevicesController_GetUserDevicesErrorCodeQueries(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Get("/user/devices/:userDeviceID/error-codes", test.AuthInjectorTestHandler(testUserID), c.GetUserDeviceErrorCodeQueries) @@ -646,7 +646,7 @@ func TestUserDevicesController_ClearUserDeviceErrorCodeQuery(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Post("/user/devices/:userDeviceID/error-codes/clear", test.AuthInjectorTestHandler(testUserID), c.ClearUserDeviceErrorCodeQuery) @@ -733,7 +733,7 @@ func TestUserDevicesController_ErrorOnAllErrorCodesCleared(t *testing.T) { }() testUserID := "123123" - c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, pdb.DBS, &mockDeps.logger, mockDeps.deviceDefSvc, mockDeps.deviceDefIntSvc, &fakeEventService{}, mockDeps.scClient, mockDeps.scTaskSvc, mockDeps.teslaSvc, mockDeps.teslaTaskService, nil, nil, mockDeps.nhtsaService, mockDeps.autoPiIngest, mockDeps.deviceDefinitionIngest, mockDeps.autoPiTaskSvc, nil, nil, nil, nil, mockDeps.openAISvc, nil, nil, nil, nil, nil, mockDeps.valuationsSvc, nil) app := fiber.New() app.Post("/user/devices/:userDeviceID/error-codes/clear", test.AuthInjectorTestHandler(testUserID), c.ClearUserDeviceErrorCodeQuery) diff --git a/internal/controllers/user_devices_controller.go b/internal/controllers/user_devices_controller.go index da4ff414d..19798be7e 100644 --- a/internal/controllers/user_devices_controller.go +++ b/internal/controllers/user_devices_controller.go @@ -83,6 +83,7 @@ type UserDevicesController struct { wallet services.SyntheticWalletInstanceService userDeviceSvc services.UserDeviceService valuationsAPISrv services.ValuationsAPIService + teslaFleetAPISvc services.TeslaFleetAPIService } // PrivilegedDevices contains all devices for which a privilege has been shared @@ -140,6 +141,7 @@ func NewUserDevicesController(settings *config.Settings, wallet services.SyntheticWalletInstanceService, userDeviceSvc services.UserDeviceService, valuationsAPISrv services.ValuationsAPIService, + teslaFleetAPISvc services.TeslaFleetAPIService, ) UserDevicesController { return UserDevicesController{ Settings: settings, @@ -169,6 +171,7 @@ func NewUserDevicesController(settings *config.Settings, wallet: wallet, valuationsAPISrv: valuationsAPISrv, userDeviceSvc: userDeviceSvc, + teslaFleetAPISvc: teslaFleetAPISvc, } } diff --git a/internal/controllers/user_devices_controller_test.go b/internal/controllers/user_devices_controller_test.go index bfd9be3de..1d1f9f568 100644 --- a/internal/controllers/user_devices_controller_test.go +++ b/internal/controllers/user_devices_controller_test.go @@ -111,7 +111,7 @@ func (s *UserDevicesControllerTestSuite) SetupSuite() { s.testUserID = "123123" testUserID2 := "3232451" c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: "prod"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, teslaSvc, teslaTaskService, new(shared.ROT13Cipher), s.autoPiSvc, - s.nhtsaService, autoPiIngest, deviceDefinitionIngest, autoPiTaskSvc, nil, nil, nil, s.redisClient, nil, s.usersClient, nil, s.natsService, nil, s.userDeviceSvc, s.valuationsSrvc) + s.nhtsaService, autoPiIngest, deviceDefinitionIngest, autoPiTaskSvc, nil, nil, nil, s.redisClient, nil, s.usersClient, nil, s.natsService, nil, s.userDeviceSvc, s.valuationsSrvc, nil) app := test.SetupAppFiber(*logger) app.Post("/user/devices", test.AuthInjectorTestHandler(s.testUserID), c.RegisterDeviceForUser) app.Post("/user/devices/fromvin", test.AuthInjectorTestHandler(s.testUserID), c.RegisterDeviceForUserFromVIN) diff --git a/internal/controllers/user_integrations_auth_controller.go b/internal/controllers/user_integrations_auth_controller.go index 4910ca419..4411a5aa6 100644 --- a/internal/controllers/user_integrations_auth_controller.go +++ b/internal/controllers/user_integrations_auth_controller.go @@ -19,6 +19,8 @@ import ( "github.com/rs/zerolog" ) +const teslaFleetAuthCacheKey = "integration_credentials_%s" + type UserIntegrationAuthController struct { Settings *config.Settings DBS func() *db.ReaderWriter @@ -137,7 +139,7 @@ func (u *UserIntegrationAuthController) CompleteOAuthExchange(c *fiber.Ctx) erro if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to get tesla authCode:"+err.Error()) } - + teslaAuth.Region = reqBody.Region // Save tesla oauth credentials in cache err = u.persistOauthCredentials(c.Context(), *teslaAuth, *user.EthereumAddress) if err != nil { @@ -194,7 +196,7 @@ func (u *UserIntegrationAuthController) persistOauthCredentials(ctx context.Cont return fmt.Errorf("an error occurred encrypting auth credentials: %w", err) } - cacheKey := "integration_credentials_" + userEthAddr + cacheKey := fmt.Sprintf(teslaFleetAuthCacheKey, userEthAddr) status := u.cache.Set(ctx, cacheKey, encToken, 5*time.Minute) if status.Err() != nil { return fmt.Errorf("an error occurred saving auth credentials to cache: %w", status.Err()) diff --git a/internal/controllers/user_integrations_auth_controller_test.go b/internal/controllers/user_integrations_auth_controller_test.go index 336efa4a3..2fa38dda4 100644 --- a/internal/controllers/user_integrations_auth_controller_test.go +++ b/internal/controllers/user_integrations_auth_controller_test.go @@ -100,6 +100,7 @@ func (s *UserIntegrationAuthControllerTestSuite) TestCompleteOAuthExchanges() { RefreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.UWfqdcCvyzObpI2gaIGcx2r7CcDjlQ0IzGyk8N0_vqw", IDToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.ouLgsgz-xUWN7lLuo8qE2nueNgJIrBz49QLr_GLHRno", Expiry: time.Now().Add(time.Hour * 1), + Region: mockRegion, } mockUserEthAddr := common.HexToAddress("1").String() s.usersClient.EXPECT().GetUser(gomock.Any(), &users.GetUserRequest{Id: s.testUserID}).Return(&users.User{EthereumAddress: &mockUserEthAddr}, nil) @@ -132,7 +133,7 @@ func (s *UserIntegrationAuthControllerTestSuite) TestCompleteOAuthExchanges() { encToken, err := s.cipher.Encrypt(string(tokenStr)) s.Assert().NoError(err) - cacheKey := "integration_credentials_" + mockUserEthAddr + cacheKey := fmt.Sprintf(teslaFleetAuthCacheKey, mockUserEthAddr) s.redisClient.EXPECT().Set(gomock.Any(), cacheKey, encToken, 5*time.Minute).Return(&redis.StatusCmd{}) resp := []services.TeslaVehicle{ @@ -258,7 +259,7 @@ func (s *UserIntegrationAuthControllerTestSuite) TestPersistOauthCredentials() { encToken, err := s.cipher.Encrypt(string(tokenStr)) s.Assert().NoError(err) - cacheKey := "integration_credentials_" + mockUserEthAddr + cacheKey := fmt.Sprintf(teslaFleetAuthCacheKey, mockUserEthAddr) s.redisClient.EXPECT().Set(gomock.Any(), cacheKey, encToken, 5*time.Minute).Return(&redis.StatusCmd{}) intCtrl := NewUserIntegrationAuthController(&config.Settings{ diff --git a/internal/controllers/user_integrations_controller.go b/internal/controllers/user_integrations_controller.go index 3a3040d13..8796d87bf 100644 --- a/internal/controllers/user_integrations_controller.go +++ b/internal/controllers/user_integrations_controller.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/go-redis/redis/v8" "math/big" "strconv" "strings" @@ -1810,16 +1811,38 @@ func (udc *UserDevicesController) registerDeviceTesla(c *fiber.Ctx, logger *zero return fiber.NewError(fiber.StatusBadRequest, "Couldn't parse request body.") } + // Flag for which api version should be used + apiVersion := constants.TeslaAPIV1 + if reqBody.Version != 0 { + apiVersion = reqBody.Version + } + + if reqBody.ExternalID == "" { + return fiber.NewError(fiber.StatusBadRequest, "Missing externalID parameter") + } + + v := &services.TeslaVehicle{} // We'll use this to kick off the job teslaID, err := strconv.Atoi(reqBody.ExternalID) if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Couldn't parse external id %q as an integer.", teslaID)) } - v, err := udc.teslaService.GetVehicle(reqBody.AccessToken, teslaID) + region := "" + if apiVersion == constants.TeslaAPIV2 { // If version is 2, we are using fleet api which has token stored in cache + deviceIntReq, err := udc.getTeslaAuthFromCache(c.Context(), ud.UserID) + if err != nil { + udc.log.Err(err).Msg("Error occurred retrieving tesla auth from cache") + return fiber.NewError(fiber.StatusBadRequest, "Couldn't retrieve stored credentials: "+err.Error()) + } + reqBody.RefreshToken = deviceIntReq.RefreshToken + reqBody.AccessToken = deviceIntReq.AccessToken + reqBody.ExpiresIn = int(time.Until(deviceIntReq.Expiry).Seconds()) + region = deviceIntReq.Region + } + + v, err = udc.getTeslaVehicle(c.Context(), reqBody.AccessToken, region, teslaID, apiVersion) if err != nil { - logger.Err(err).Msg("Error on initial Tesla call.") - // TODO(elffjs): 400 may not be entirely accurate. return fiber.NewError(fiber.StatusBadRequest, "Couldn't retrieve vehicle from Tesla.") } @@ -1862,6 +1885,7 @@ func (udc *UserDevicesController) registerDeviceTesla(c *fiber.Ctx, logger *zero Commands: &services.UserDeviceAPIIntegrationsMetadataCommands{ Enabled: []string{"doors/unlock", "doors/lock", "trunk/open", "frunk/open", "charge/limit"}, }, + TeslaAPIVersion: apiVersion, } b, err := json.Marshal(meta) @@ -1894,7 +1918,7 @@ func (udc *UserDevicesController) registerDeviceTesla(c *fiber.Ctx, logger *zero return err } - if err := udc.teslaService.WakeUpVehicle(reqBody.AccessToken, teslaID); err != nil { + if err := udc.wakeupTeslaVehicle(c.Context(), reqBody.AccessToken, region, teslaID, apiVersion); err != nil { logger.Err(err).Msg("Couldn't wake up Tesla.") } @@ -1907,7 +1931,7 @@ func (udc *UserDevicesController) registerDeviceTesla(c *fiber.Ctx, logger *zero udc.requestInstantOffer(userDeviceID, tokenID) } - if err := udc.teslaTaskService.StartPoll(v, &integration); err != nil { + if err := udc.teslaTaskService.StartPoll(v, &integration, apiVersion); err != nil { return err } @@ -1920,6 +1944,67 @@ func (udc *UserDevicesController) registerDeviceTesla(c *fiber.Ctx, logger *zero return c.SendStatus(fiber.StatusNoContent) } +func (udc *UserDevicesController) wakeupTeslaVehicle(ctx context.Context, token, region string, vehicleID, version int) error { + var err error + if version == constants.TeslaAPIV2 { + err = udc.teslaFleetAPISvc.WakeUpVehicle(ctx, token, region, vehicleID) + } else { + err = udc.teslaService.WakeUpVehicle(token, vehicleID) + } + return err +} + +func (udc *UserDevicesController) getTeslaVehicle(ctx context.Context, token, region string, vehicleID, version int) (*services.TeslaVehicle, error) { + var vehicle *services.TeslaVehicle + var err error + if version == constants.TeslaAPIV2 { + vehicle, err = udc.teslaFleetAPISvc.GetVehicle(ctx, token, region, vehicleID) + } else { + vehicle, err = udc.teslaService.GetVehicle(token, vehicleID) + } + + return vehicle, err +} + +func (udc *UserDevicesController) getTeslaAuthFromCache(ctx context.Context, userID string) (*services.TeslaAuthCodeResponse, error) { + user, err := udc.usersClient.GetUser(ctx, &pb.GetUserRequest{Id: userID}) + if err != nil { + return nil, fmt.Errorf("could not fetch user information: %w", err) + } + + if user.EthereumAddress == nil { + return nil, fmt.Errorf("missing wallet address for user") + } + + cacheKey := fmt.Sprintf(teslaFleetAuthCacheKey, *user.EthereumAddress) + encTeslaAuth, err := udc.redisCache.Get(ctx, cacheKey).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("tesla authorization token has expired") + } + return nil, fmt.Errorf("could not retrieve Tesla credentials: %w", err) + } + if len(encTeslaAuth) == 0 { + return nil, fmt.Errorf("no credential found") + } + decrypted, err := udc.cipher.Decrypt(encTeslaAuth) + if err != nil { + return nil, fmt.Errorf("failed to decrypt tesla token: %w", err) + } + + teslaAuth := &services.TeslaAuthCodeResponse{} + err = json.Unmarshal([]byte(decrypted), &teslaAuth) + if err != nil { + return nil, fmt.Errorf("failed to parse tesla authorization token: %w", err) + } + + if teslaAuth.AccessToken == "" || teslaAuth.RefreshToken == "" || teslaAuth.Expiry.IsZero() { + return nil, fmt.Errorf("missing tesla auth credentials") + } + + return teslaAuth, nil +} + // fixTeslaDeviceDefinition tries to use the VIN provided by Tesla to correct the device definition // used by a device. // @@ -1975,6 +2060,7 @@ type RegisterDeviceIntegrationRequest struct { AccessToken string `json:"accessToken"` ExpiresIn int `json:"expiresIn"` RefreshToken string `json:"refreshToken"` + Version int `json:"version"` } type GetUserDeviceIntegrationResponse struct { diff --git a/internal/controllers/user_integrations_controller_test.go b/internal/controllers/user_integrations_controller_test.go index 7a5d74490..04426b7a0 100644 --- a/internal/controllers/user_integrations_controller_test.go +++ b/internal/controllers/user_integrations_controller_test.go @@ -2,6 +2,7 @@ package controllers import ( "context" + "database/sql" "encoding/json" "fmt" "io" @@ -75,6 +76,8 @@ type UserIntegrationsControllerTestSuite struct { natsSvc *services.NATSService natsServer *server.Server userDeviceSvc *mock_services.MockUserDeviceService + cipher shared.Cipher + teslaFleetAPISvc *mock_services.MockTeslaFleetAPIService } const testUserID = "123123" @@ -85,7 +88,7 @@ func (s *UserIntegrationsControllerTestSuite) SetupSuite() { s.ctx = context.Background() s.pdb, s.container = test.StartContainerDatabase(s.ctx, s.T(), migrationsDirRelPath) - s.mockCtrl = gomock.NewController(s.T()) + s.mockCtrl = gomock.NewController(s.T(), gomock.WithOverridableExpectations()) var err error s.deviceDefSvc = mock_services.NewMockDeviceDefinitionService(s.mockCtrl) @@ -104,14 +107,17 @@ func (s *UserIntegrationsControllerTestSuite) SetupSuite() { s.natsSvc, s.natsServer, err = mock_services.NewMockNATSService(natsStreamName) s.userDeviceSvc = mock_services.NewMockUserDeviceService(s.mockCtrl) s.valuationsSrvc = mock_services.NewMockValuationsAPIService(s.mockCtrl) + s.teslaFleetAPISvc = mock_services.NewMockTeslaFleetAPIService(s.mockCtrl) + s.cipher = new(shared.ROT13Cipher) if err != nil { s.T().Fatal(err) } logger := test.Logger() - c := NewUserDevicesController(&config.Settings{Port: "3000"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, s.eventSvc, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), s.autopiAPISvc, nil, - s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, s.redisClient, nil, nil, nil, s.natsSvc, nil, s.userDeviceSvc, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, s.eventSvc, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, s.cipher, s.autopiAPISvc, nil, + s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, s.redisClient, nil, s.userClient, nil, s.natsSvc, nil, s.userDeviceSvc, s.valuationsSrvc, + s.teslaFleetAPISvc) app := test.SetupAppFiber(*logger) @@ -121,7 +127,6 @@ func (s *UserIntegrationsControllerTestSuite) SetupSuite() { app.Post("/user2/devices/:userDeviceID/integrations/:integrationID", test.AuthInjectorTestHandler(testUser2), c.RegisterDeviceIntegration) app.Get("/integrations", c.GetIntegrations) s.app = app - } // TearDownTest after each test truncate tables @@ -333,7 +338,7 @@ func (s *UserIntegrationsControllerTestSuite) TestPostSmartCar_FailureTestVIN() logger := test.Logger() c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: "prod"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, s.eventSvc, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), s.autopiAPISvc, nil, - s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, s.redisClient, nil, nil, nil, s.natsSvc, nil, s.userDeviceSvc, s.valuationsSrvc) + s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, s.redisClient, nil, nil, nil, s.natsSvc, nil, s.userDeviceSvc, s.valuationsSrvc, nil) app := test.SetupAppFiber(*logger) @@ -486,8 +491,8 @@ func (s *UserIntegrationsControllerTestSuite) TestPostTesla() { Region: "Americas", }).Return(nil) - s.teslaTaskService.EXPECT().StartPoll(gomock.AssignableToTypeOf(oV), gomock.AssignableToTypeOf(oUdai)).DoAndReturn( - func(v *services.TeslaVehicle, udai *models.UserDeviceAPIIntegration) error { + s.teslaTaskService.EXPECT().StartPoll(gomock.AssignableToTypeOf(oV), gomock.AssignableToTypeOf(oUdai), 1).DoAndReturn( + func(v *services.TeslaVehicle, udai *models.UserDeviceAPIIntegration, _ int) error { oV = v oUdai = udai return nil @@ -532,6 +537,11 @@ func (s *UserIntegrationsControllerTestSuite) TestPostTesla() { assert.Equal(s.T(), "ssst", apiInt.RefreshToken.String) assert.True(s.T(), within(&apiInt.AccessExpiresAt.Time, &expectedExpiry, 15*time.Second), "access token expires at %s, expected something close to %s", apiInt.AccessExpiresAt, expectedExpiry) + meta := &services.UserDeviceAPIIntegrationsMetadata{} + err = apiInt.Metadata.Unmarshal(&meta) + s.Assert().NoError(err) + + s.Assert().Equal(1, meta.TeslaAPIVersion) } func (s *UserIntegrationsControllerTestSuite) TestPostTeslaAndUpdateDD() { @@ -558,7 +568,7 @@ func (s *UserIntegrationsControllerTestSuite) TestPostAutoPiBlockedForDuplicateD // specific dependency and controller autopiAPISvc := mock_services.NewMockAutoPiAPIService(s.mockCtrl) logger := test.Logger() - c := NewUserDevicesController(&config.Settings{Port: "3000"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc, nil) app := test.SetupAppFiber(*logger) app.Post("/user/devices/:userDeviceID/integrations/:integrationID", test.AuthInjectorTestHandler(testUserID), c.RegisterDeviceIntegration) // arrange @@ -594,7 +604,7 @@ func (s *UserIntegrationsControllerTestSuite) TestPostAutoPiBlockedForDuplicateD // specific dependency and controller autopiAPISvc := mock_services.NewMockAutoPiAPIService(s.mockCtrl) logger := test.Logger() - c := NewUserDevicesController(&config.Settings{Port: "3000"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000"}, s.pdb.DBS, logger, s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, s.autoPiTaskService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc, nil) app := test.SetupAppFiber(*logger) app.Post("/user/devices/:userDeviceID/integrations/:integrationID", test.AuthInjectorTestHandler(testUser2), c.RegisterDeviceIntegration) // arrange @@ -630,7 +640,7 @@ func (s *UserIntegrationsControllerTestSuite) TestGetAutoPiInfoNoUDAI_ShouldUpda const environment = "prod" // shouldUpdate only applies in prod // specific dependency and controller autopiAPISvc := mock_services.NewMockAutoPiAPIService(s.mockCtrl) - c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc, nil) app := fiber.New() logger := zerolog.Nop() app.Get("/aftermarket/device/by-serial/:serial", test.AuthInjectorTestHandler(testUserID), owner.AftermarketDevice(s.pdb, s.userClient, &logger), c.GetAutoPiUnitInfo) @@ -670,7 +680,7 @@ func (s *UserIntegrationsControllerTestSuite) TestGetAutoPiInfoNoUDAI_UpToDate() const environment = "prod" // shouldUpdate only applies in prod // specific dependency and controller autopiAPISvc := mock_services.NewMockAutoPiAPIService(s.mockCtrl) - c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc, nil) app := fiber.New() logger := zerolog.Nop() app.Get("/aftermarket/device/by-serial/:serial", test.AuthInjectorTestHandler(testUserID), owner.AftermarketDevice(s.pdb, s.userClient, &logger), c.GetAutoPiUnitInfo) @@ -707,7 +717,7 @@ func (s *UserIntegrationsControllerTestSuite) TestGetAutoPiInfoNoUDAI_FutureUpda const environment = "prod" // shouldUpdate only applies in prod // specific dependency and controller autopiAPISvc := mock_services.NewMockAutoPiAPIService(s.mockCtrl) - c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc, nil) app := fiber.New() logger := zerolog.Nop() app.Get("/aftermarket/device/by-serial/:serial", test.AuthInjectorTestHandler(testUserID), owner.AftermarketDevice(s.pdb, s.userClient, &logger), c.GetAutoPiUnitInfo) @@ -746,7 +756,7 @@ func (s *UserIntegrationsControllerTestSuite) TestGetAutoPiInfoNoUDAI_ShouldUpda const environment = "prod" // shouldUpdate only applies in prod // specific dependency and controller autopiAPISvc := mock_services.NewMockAutoPiAPIService(s.mockCtrl) - c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000", Environment: environment}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, s.valuationsSrvc, nil) app := fiber.New() logger := zerolog.Nop() app.Get("/aftermarket/device/by-serial/:serial", test.AuthInjectorTestHandler(testUserID), owner.AftermarketDevice(s.pdb, s.userClient, &logger), c.GetAutoPiUnitInfo) @@ -796,7 +806,7 @@ func (s *UserIntegrationsControllerTestSuite) TestPairAftermarketNoLegacy() { userAddr := crypto.PubkeyToAddress(privateKey.PublicKey) autopiAPISvc := mock_services.NewMockAutoPiAPIService(s.mockCtrl) - c := NewUserDevicesController(&config.Settings{Port: "3000", DIMORegistryChainID: 1337, DIMORegistryAddr: common.BigToAddress(big.NewInt(7)).Hex()}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, kprod, nil, nil, nil, nil, s.userClient, nil, nil, nil, nil, s.valuationsSrvc) + c := NewUserDevicesController(&config.Settings{Port: "3000", DIMORegistryChainID: 1337, DIMORegistryAddr: common.BigToAddress(big.NewInt(7)).Hex()}, s.pdb.DBS, test.Logger(), s.deviceDefSvc, s.deviceDefIntSvc, &fakeEventService{}, s.scClient, s.scTaskSvc, s.teslaSvc, s.teslaTaskService, new(shared.ROT13Cipher), autopiAPISvc, nil, s.autoPiIngest, s.deviceDefinitionRegistrar, nil, kprod, nil, nil, nil, nil, s.userClient, nil, nil, nil, nil, s.valuationsSrvc, nil) s.deviceDefIntSvc.EXPECT().GetAutoPiIntegration(gomock.Any()).Return(&ddgrpc.Integration{Id: ksuid.New().String()}, nil).AnyTimes() userID := "louxUser" @@ -892,3 +902,180 @@ func (s *UserIntegrationsControllerTestSuite) TestPairAftermarketNoLegacy() { s.Equal(big.NewInt(4), vID) s.Equal(userSig, u2Sig) } + +// Tesla Fleet API Tests +func (s *UserIntegrationsControllerTestSuite) TestPostTesla_V2() { + integration := test.BuildIntegrationGRPC(constants.TeslaVendor, 10, 0) + dd := test.BuildDeviceDefinitionGRPC(ksuid.New().String(), "Tesla", "Model Y", 2020, integration) + ud := test.SetupCreateUserDevice(s.T(), testUserID, dd[0].DeviceDefinitionId, nil, "", s.pdb) + + oV := &services.TeslaVehicle{} + oUdai := &models.UserDeviceAPIIntegration{} + + s.eventSvc.EXPECT().Emit(gomock.Any()).Return(nil).Do( + func(event *shared.CloudEvent[any]) error { + assert.Equal(s.T(), ud.ID, event.Subject) + assert.Equal(s.T(), "com.dimo.zone.device.integration.create", event.Type) + + data := event.Data.(services.UserDeviceIntegrationEvent) + + assert.Equal(s.T(), dd[0].Make.Name, data.Device.Make) + assert.Equal(s.T(), dd[0].Type.Model, data.Device.Model) + assert.Equal(s.T(), int(dd[0].Type.Year), data.Device.Year) + assert.Equal(s.T(), "5YJYGDEF9NF010423", data.Device.VIN) + assert.Equal(s.T(), ud.ID, data.Device.ID) + + assert.Equal(s.T(), constants.TeslaVendor, data.Integration.Vendor) + assert.Equal(s.T(), integration.Id, data.Integration.ID) + return nil + }, + ) + + s.deviceDefinitionRegistrar.EXPECT().Register(services.DeviceDefinitionDTO{ + IntegrationID: integration.Id, + UserDeviceID: ud.ID, + DeviceDefinitionID: ud.DeviceDefinitionID, + Make: dd[0].Make.Name, + Model: dd[0].Type.Model, + Year: int(dd[0].Type.Year), + Region: "Americas", + }).Return(nil) + + s.teslaTaskService.EXPECT().StartPoll(gomock.AssignableToTypeOf(oV), gomock.AssignableToTypeOf(oUdai), 2).DoAndReturn( + func(v *services.TeslaVehicle, udai *models.UserDeviceAPIIntegration, _ int) error { + oV = v + oUdai = udai + return nil + }, + ) + + s.teslaFleetAPISvc.EXPECT().GetVehicle(gomock.Any(), "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", "na", 1145).Return(&services.TeslaVehicle{ + ID: 1145, + VehicleID: 223, + VIN: "5YJYGDEF9NF010423", + }, nil) + s.teslaFleetAPISvc.EXPECT().WakeUpVehicle(gomock.Any(), "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", "na", 1145).Return(nil) + s.deviceDefSvc.EXPECT().GetDeviceDefinitionByID(gomock.Any(), ud.DeviceDefinitionID).Times(2).Return(dd[0], nil) + s.deviceDefSvc.EXPECT().FindDeviceDefinitionByMMY(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(dd[0], nil) + + userEthAddr := common.HexToAddress("1").String() + s.userClient.EXPECT().GetUser(gomock.Any(), &pbuser.GetUserRequest{Id: testUserID}).Return(&pbuser.User{EthereumAddress: &userEthAddr}, nil).AnyTimes() + + expectedExpiry := time.Now().Add(10 * time.Minute) + teslaResp := services.TeslaAuthCodeResponse{ + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + RefreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.UWfqdcCvyzObpI2gaIGcx2r7CcDjlQ0IzGyk8N0_vqw", + IDToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.ouLgsgz-xUWN7lLuo8qE2nueNgJIrBz49QLr_GLHRno", + Expiry: expectedExpiry, + Region: "na", + } + tokenStr, err := json.Marshal(teslaResp) + s.Assert().NoError(err) + + encTeslaAuth, err := s.cipher.Encrypt(string(tokenStr)) + s.Assert().NoError(err) + + cacheKey := fmt.Sprintf(teslaFleetAuthCacheKey, userEthAddr) + s.redisClient.EXPECT().Get(gomock.Any(), cacheKey).Return(redis.NewStringResult(encTeslaAuth, nil)) + + in := `{ + "externalId": "1145", + "version": 2 + }` + request := test.BuildRequest("POST", fmt.Sprintf("/user/devices/%s/integrations/%s", ud.ID, integration.Id), in) + _, err = s.app.Test(request, 60*1000) + s.Assert().NoError(err) + + intd, err := models.UserDeviceAPIIntegrations(models.UserDeviceAPIIntegrationWhere.ExternalID.EQ(null.StringFrom("1145"))).One(s.ctx, s.pdb.DBS().Reader) + s.Assert().NoError(err) + s.Assert().NotEmpty(intd.Metadata) + + encAccessToken, err := s.cipher.Encrypt(teslaResp.AccessToken) + s.Assert().NoError(err) + + meta := &services.UserDeviceAPIIntegrationsMetadata{} + err = intd.Metadata.Unmarshal(&meta) + s.Assert().NoError(err) + + encRefreshToken, err := s.cipher.Encrypt(teslaResp.RefreshToken) + s.Assert().NoError(err) + s.Assert().Equal(1145, oV.ID) + s.Assert().Equal(223, oV.VehicleID) + s.Assert().Equal(null.StringFrom("1145"), intd.ExternalID) + s.Assert().Equal(encAccessToken, intd.AccessToken.String) + s.Assert().Equal(encRefreshToken, intd.RefreshToken.String) + s.Assert().Equal(2, meta.TeslaAPIVersion) +} + +func (s *UserIntegrationsControllerTestSuite) TestPostTesla_V2_PartialCredentials() { + integration := test.BuildIntegrationGRPC(constants.TeslaVendor, 10, 0) + dd := test.BuildDeviceDefinitionGRPC(ksuid.New().String(), "Tesla", "Model Y", 2020, integration) + ud := test.SetupCreateUserDevice(s.T(), testUserID, dd[0].DeviceDefinitionId, nil, "", s.pdb) + + s.deviceDefSvc.EXPECT().GetDeviceDefinitionByID(gomock.Any(), ud.DeviceDefinitionID).Return(dd[0], nil).AnyTimes() + + userEthAddr := common.HexToAddress("1").String() + s.userClient.EXPECT().GetUser(gomock.Any(), &pbuser.GetUserRequest{Id: testUserID}).Return(&pbuser.User{EthereumAddress: &userEthAddr}, nil).AnyTimes() + + teslaResp := services.TeslaAuthCodeResponse{ + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + IDToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.ouLgsgz-xUWN7lLuo8qE2nueNgJIrBz49QLr_GLHRno", + } + + tokenStr, err := json.Marshal(teslaResp) + s.Assert().NoError(err) + + encTeslaAuth, err := s.cipher.Encrypt(string(tokenStr)) + s.Assert().NoError(err) + + cacheKey := fmt.Sprintf(teslaFleetAuthCacheKey, userEthAddr) + s.redisClient.EXPECT().Get(gomock.Any(), cacheKey).Return(redis.NewStringResult(encTeslaAuth, nil)) + + in := `{ + "externalId": "1145", + "version": 2 + }` + request := test.BuildRequest("POST", fmt.Sprintf("/user/devices/%s/integrations/%s", ud.ID, integration.Id), in) + res, _ := s.app.Test(request, 60*1000) + + s.Assert().True(res.StatusCode == fiber.StatusBadRequest) + body, _ := io.ReadAll(res.Body) + + defer res.Body.Close() + + _, err = models.UserDeviceAPIIntegrations(models.UserDeviceAPIIntegrationWhere.ExternalID.EQ(null.StringFrom("1145"))).One(s.ctx, s.pdb.DBS().Reader) + s.Assert().Equal(err.Error(), sql.ErrNoRows.Error()) + + s.Assert().Equal("Couldn't retrieve stored credentials: missing tesla auth credentials", gjson.GetBytes(body, "message").String()) +} + +func (s *UserIntegrationsControllerTestSuite) TestPostTesla_V2_MissingCredentials() { + integration := test.BuildIntegrationGRPC(constants.TeslaVendor, 10, 0) + dd := test.BuildDeviceDefinitionGRPC(ksuid.New().String(), "Tesla", "Model Y", 2020, integration) + ud := test.SetupCreateUserDevice(s.T(), testUserID, dd[0].DeviceDefinitionId, nil, "", s.pdb) + + s.deviceDefSvc.EXPECT().GetDeviceDefinitionByID(gomock.Any(), ud.DeviceDefinitionID).Return(dd[0], nil).AnyTimes() + + userEthAddr := common.HexToAddress("1").String() + s.userClient.EXPECT().GetUser(gomock.Any(), &pbuser.GetUserRequest{Id: testUserID}).Return(&pbuser.User{EthereumAddress: &userEthAddr}, nil).AnyTimes() + + cacheKey := fmt.Sprintf(teslaFleetAuthCacheKey, userEthAddr) + s.redisClient.EXPECT().Get(gomock.Any(), cacheKey).Return(redis.NewStringResult("", nil)) + + in := `{ + "externalId": "1145", + "version": 2 + }` + request := test.BuildRequest("POST", fmt.Sprintf("/user/devices/%s/integrations/%s", ud.ID, integration.Id), in) + res, _ := s.app.Test(request, 60*1000) + + s.Assert().True(res.StatusCode == fiber.StatusBadRequest) + body, _ := io.ReadAll(res.Body) + + defer res.Body.Close() + + _, err := models.UserDeviceAPIIntegrations(models.UserDeviceAPIIntegrationWhere.ExternalID.EQ(null.StringFrom("1145"))).One(s.ctx, s.pdb.DBS().Reader) + s.Assert().Equal(err.Error(), sql.ErrNoRows.Error()) + + s.Assert().Equal("Couldn't retrieve stored credentials: no credential found", gjson.GetBytes(body, "message").String()) +} diff --git a/internal/services/mocks/tesla_fleet_api_service_mock.go b/internal/services/mocks/tesla_fleet_api_service_mock.go index 6372a3d1c..968f31a74 100644 --- a/internal/services/mocks/tesla_fleet_api_service_mock.go +++ b/internal/services/mocks/tesla_fleet_api_service_mock.go @@ -55,6 +55,21 @@ func (mr *MockTeslaFleetAPIServiceMockRecorder) CompleteTeslaAuthCodeExchange(ct return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CompleteTeslaAuthCodeExchange", reflect.TypeOf((*MockTeslaFleetAPIService)(nil).CompleteTeslaAuthCodeExchange), ctx, authCode, redirectURI, region) } +// GetVehicle mocks base method. +func (m *MockTeslaFleetAPIService) GetVehicle(ctx context.Context, token, region string, vehicleID int) (*services.TeslaVehicle, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVehicle", ctx, token, region, vehicleID) + ret0, _ := ret[0].(*services.TeslaVehicle) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetVehicle indicates an expected call of GetVehicle. +func (mr *MockTeslaFleetAPIServiceMockRecorder) GetVehicle(ctx, token, region, vehicleID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVehicle", reflect.TypeOf((*MockTeslaFleetAPIService)(nil).GetVehicle), ctx, token, region, vehicleID) +} + // GetVehicles mocks base method. func (m *MockTeslaFleetAPIService) GetVehicles(ctx context.Context, token, region string) ([]services.TeslaVehicle, error) { m.ctrl.T.Helper() @@ -69,3 +84,17 @@ func (mr *MockTeslaFleetAPIServiceMockRecorder) GetVehicles(ctx, token, region a mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVehicles", reflect.TypeOf((*MockTeslaFleetAPIService)(nil).GetVehicles), ctx, token, region) } + +// WakeUpVehicle mocks base method. +func (m *MockTeslaFleetAPIService) WakeUpVehicle(ctx context.Context, token, region string, vehicleID int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WakeUpVehicle", ctx, token, region, vehicleID) + ret0, _ := ret[0].(error) + return ret0 +} + +// WakeUpVehicle indicates an expected call of WakeUpVehicle. +func (mr *MockTeslaFleetAPIServiceMockRecorder) WakeUpVehicle(ctx, token, region, vehicleID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WakeUpVehicle", reflect.TypeOf((*MockTeslaFleetAPIService)(nil).WakeUpVehicle), ctx, token, region, vehicleID) +} diff --git a/internal/services/mocks/tesla_task_service_mock.go b/internal/services/mocks/tesla_task_service_mock.go index 680f63b23..d23621ebd 100644 --- a/internal/services/mocks/tesla_task_service_mock.go +++ b/internal/services/mocks/tesla_task_service_mock.go @@ -1,10 +1,11 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: tesla_task_service.go +// Source: internal/services/tesla_task_service.go // // Generated by this command: // -// mockgen -source tesla_task_service.go -destination mocks/tesla_task_service_mock.go +// mockgen -source=internal/services/tesla_task_service.go -destination=internal/services/mocks/tesla_task_service_mock.go // + // Package mock_services is a generated GoMock package. package mock_services @@ -85,17 +86,17 @@ func (mr *MockTeslaTaskServiceMockRecorder) OpenTrunk(udai any) *gomock.Call { } // StartPoll mocks base method. -func (m *MockTeslaTaskService) StartPoll(vehicle *services.TeslaVehicle, udai *models.UserDeviceAPIIntegration) error { +func (m *MockTeslaTaskService) StartPoll(vehicle *services.TeslaVehicle, udai *models.UserDeviceAPIIntegration, apiVersion int) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StartPoll", vehicle, udai) + ret := m.ctrl.Call(m, "StartPoll", vehicle, udai, apiVersion) ret0, _ := ret[0].(error) return ret0 } // StartPoll indicates an expected call of StartPoll. -func (mr *MockTeslaTaskServiceMockRecorder) StartPoll(vehicle, udai any) *gomock.Call { +func (mr *MockTeslaTaskServiceMockRecorder) StartPoll(vehicle, udai, apiVersion any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartPoll", reflect.TypeOf((*MockTeslaTaskService)(nil).StartPoll), vehicle, udai) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartPoll", reflect.TypeOf((*MockTeslaTaskService)(nil).StartPoll), vehicle, udai, apiVersion) } // StopPoll mocks base method. diff --git a/internal/services/models.go b/internal/services/models.go index b646d4630..90dfa928e 100644 --- a/internal/services/models.go +++ b/internal/services/models.go @@ -146,8 +146,9 @@ type UserDeviceAPIIntegrationsMetadata struct { SmartcarUserID *string `json:"smartcarUserId,omitempty"` Commands *UserDeviceAPIIntegrationsMetadataCommands `json:"commands,omitempty"` // CANProtocol is the protocol that was detected by edge-network from the autopi. - CANProtocol *string `json:"canProtocol,omitempty"` - TeslaVehicleID int `json:"teslaVehicleId,omitempty"` + CANProtocol *string `json:"canProtocol,omitempty"` + TeslaVehicleID int `json:"teslaVehicleId,omitempty"` + TeslaAPIVersion int `json:"teslaApiVersion,omitempty"` } type UserDeviceAPIIntegrationsMetadataCommands struct { diff --git a/internal/services/tesla_fleet_api_service.go b/internal/services/tesla_fleet_api_service.go index 51ebd81ca..0df7d55c6 100644 --- a/internal/services/tesla_fleet_api_service.go +++ b/internal/services/tesla_fleet_api_service.go @@ -17,6 +17,8 @@ import ( type TeslaFleetAPIService interface { CompleteTeslaAuthCodeExchange(ctx context.Context, authCode, redirectURI, region string) (*TeslaAuthCodeResponse, error) GetVehicles(ctx context.Context, token, region string) ([]TeslaVehicle, error) + GetVehicle(ctx context.Context, token, region string, vehicleID int) (*TeslaVehicle, error) + WakeUpVehicle(ctx context.Context, token, region string, vehicleID int) 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"} @@ -25,6 +27,10 @@ type GetVehiclesResponse struct { Response []TeslaVehicle `json:"response"` } +type GetSingleVehicleItemResponse struct { + Response TeslaVehicle `json:"response"` +} + type TeslaFleetAPIError struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` @@ -37,6 +43,7 @@ type TeslaAuthCodeResponse struct { IDToken string `json:"id_token"` Expiry time.Time `json:"expiry"` TokenType string `json:"token_type"` + Region string `json:"region"` } type teslaFleetAPIService struct { @@ -53,12 +60,7 @@ func NewTeslaFleetAPIService(settings *config.Settings, logger *zerolog.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 +// CompleteTeslaAuthCodeExchange calls Tesla Fleet API and exchange auth code for a new auth and refresh token func (t *teslaFleetAPIService) CompleteTeslaAuthCodeExchange(ctx context.Context, authCode, redirectURI, region string) (*TeslaAuthCodeResponse, error) { conf := oauth2.Config{ ClientID: t.Settings.Tesla.ClientID, @@ -91,50 +93,94 @@ func (t *teslaFleetAPIService) CompleteTeslaAuthCodeExchange(ctx context.Context }, 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 +// GetVehicles calls Tesla Fleet API to get a list of vehicles using authorization token 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" + resp, err := t.performTeslaGetRequest(ctx, url, token) + if err != nil { + return nil, fmt.Errorf("could not fetch vehicles for user: %w", err) + } + defer resp.Body.Close() + + 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 +} + +// GetVehicle calls Tesla Fleet API to get a single vehicle by ID +func (t *teslaFleetAPIService) GetVehicle(ctx context.Context, token, region string, vehicleID int) (*TeslaVehicle, error) { + baseURL := fmt.Sprintf(t.Settings.Tesla.FleetAPI, region) + url := fmt.Sprintf("%s/api/1/vehicles/%d", baseURL, vehicleID) + + resp, err := t.performTeslaGetRequest(ctx, url, token) + if err != nil { + return nil, fmt.Errorf("could not fetch vehicles for user: %w", err) + } + defer resp.Body.Close() + + vehicle := new(GetSingleVehicleItemResponse) + if err := json.NewDecoder(resp.Body).Decode(vehicle); err != nil { + return nil, fmt.Errorf("invalid response encountered while fetching user vehicles: %w", err) + } + + return &vehicle.Response, nil +} + +// WakeUpVehicle Calls Tesla Fleet API to wake a vehicle from sleep +func (t *teslaFleetAPIService) WakeUpVehicle(ctx context.Context, token, region string, vehicleID int) error { + baseURL := fmt.Sprintf(t.Settings.Tesla.FleetAPI, region) + url := fmt.Sprintf("%s/api/1/vehicles/%d/wake_up", baseURL, vehicleID) + + resp, err := t.performTeslaGetRequest(ctx, url, token) + if err != nil { + return fmt.Errorf("could not fetch vehicles for user: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("got status code %d waking up vehicle %d", resp.StatusCode, vehicleID) + } + + return nil +} + +// performTeslaGetRequest a helper function for making http requests, it adds a timeout context and parses error response +func (t *teslaFleetAPIService) performTeslaGetRequest(ctx context.Context, url, token string) (*http.Response, error) { 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) + return nil, 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) + return nil, fmt.Errorf("error occurred calling tesla fleet api: %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)). + Str("url", url). 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 + return resp, nil } diff --git a/internal/services/tesla_task_service.go b/internal/services/tesla_task_service.go index 4705fe4de..241aa3933 100644 --- a/internal/services/tesla_task_service.go +++ b/internal/services/tesla_task_service.go @@ -16,7 +16,7 @@ import ( //go:generate mockgen -source tesla_task_service.go -destination mocks/tesla_task_service_mock.go type TeslaTaskService interface { - StartPoll(vehicle *TeslaVehicle, udai *models.UserDeviceAPIIntegration) error + StartPoll(vehicle *TeslaVehicle, udai *models.UserDeviceAPIIntegration, apiVersion int) error StopPoll(udai *models.UserDeviceAPIIntegration) error UnlockDoors(udai *models.UserDeviceAPIIntegration) (string, error) LockDoors(udai *models.UserDeviceAPIIntegration) (string, error) @@ -51,6 +51,7 @@ type TeslaCredentialsV2 struct { AccessToken string `json:"accessToken"` Expiry time.Time `json:"expiry"` RefreshToken string `json:"refreshToken"` + Version int `json:"version"` } type TeslaTask struct { @@ -81,7 +82,7 @@ type TeslaCredentialsCloudEventV2 struct { Data TeslaCredentialsV2 `json:"data"` } -func (t *teslaTaskService) StartPoll(vehicle *TeslaVehicle, udai *models.UserDeviceAPIIntegration) error { +func (t *teslaTaskService) StartPoll(vehicle *TeslaVehicle, udai *models.UserDeviceAPIIntegration, version int) error { tt := TeslaTaskCloudEvent{ CloudEventHeaders: CloudEventHeaders{ ID: ksuid.New().String(), @@ -119,6 +120,7 @@ func (t *teslaTaskService) StartPoll(vehicle *TeslaVehicle, udai *models.UserDev AccessToken: udai.AccessToken.String, Expiry: udai.AccessExpiresAt.Time, RefreshToken: udai.RefreshToken.String, + Version: version, }, }