diff --git a/Project/Interfaces/IEncryptionService.cs b/Project/Interfaces/IEncryptionService.cs new file mode 100644 index 0000000..8c89eca --- /dev/null +++ b/Project/Interfaces/IEncryptionService.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using Unity.Services.CloudCode.Apis; +using Unity.Services.CloudCode.Core; + +namespace Unity.WalmartAuthRelay.Interfaces; + +public interface IEncryptionService +{ + Task EncryptAsync(IExecutionContext ctx, IGameApiClient client, string plainText); + Task DecryptAsync(IExecutionContext ctx, IGameApiClient client, string encryptedText); + Task IsEncryptedAsync(string value); +} diff --git a/Project/ModuleConfig.cs b/Project/ModuleConfig.cs index b13d4d6..c50a1fb 100644 --- a/Project/ModuleConfig.cs +++ b/Project/ModuleConfig.cs @@ -15,6 +15,7 @@ public void Setup(ICloudCodeConfig config) config.Dependencies.AddSingleton(); config.Dependencies.AddSingleton(); config.Dependencies.AddSingleton(); + config.Dependencies.AddSingleton(); config.Dependencies.AddSingleton(); config.Dependencies.AddSingleton(); config.Dependencies.AddSingleton(); diff --git a/Project/Services/AesEncryptionService.cs b/Project/Services/AesEncryptionService.cs new file mode 100644 index 0000000..ce5f963 --- /dev/null +++ b/Project/Services/AesEncryptionService.cs @@ -0,0 +1,136 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.WalmartAuthRelay.Interfaces; +using Unity.Services.CloudCode.Apis; +using Unity.Services.CloudCode.Core; + +namespace Unity.WalmartAuthRelay.Services; + +public class AesEncryptionService : IEncryptionService +{ + private readonly ILogger _logger; + private readonly ISecretService _secretService; + + private const string ENCRYPTION_PREFIX = "ENC:"; + private const string ENCRYPTION_KEY_NAME = "LCID_ENCRYPTION_KEY"; + private const int IV_SIZE = 12; // 96-bit IV for GCM + private const int TAG_SIZE = 16; // 128-bit tag for GCM + + public AesEncryptionService(ILogger logger, ISecretService secretService) + { + _logger = logger; + _secretService = secretService; + } + + public async Task EncryptAsync(IExecutionContext ctx, IGameApiClient client, string plainText) + { + if (string.IsNullOrEmpty(plainText)) + { + throw new ArgumentException("Plain text cannot be null or empty", nameof(plainText)); + } + + try + { + var key = await GetEncryptionKeyAsync(ctx, client); + var plainBytes = Encoding.UTF8.GetBytes(plainText); + + using var aes = new AesGcm(key, TAG_SIZE); + var iv = new byte[IV_SIZE]; + var ciphertext = new byte[plainBytes.Length]; + var tag = new byte[TAG_SIZE]; + + RandomNumberGenerator.Fill(iv); + aes.Encrypt(iv, plainBytes, ciphertext, tag); + + // Format: IV + Tag + Ciphertext + var encryptedData = new byte[IV_SIZE + TAG_SIZE + ciphertext.Length]; + Buffer.BlockCopy(iv, 0, encryptedData, 0, IV_SIZE); + Buffer.BlockCopy(tag, 0, encryptedData, IV_SIZE, TAG_SIZE); + Buffer.BlockCopy(ciphertext, 0, encryptedData, IV_SIZE + TAG_SIZE, ciphertext.Length); + + return ENCRYPTION_PREFIX + Convert.ToBase64String(encryptedData); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to encrypt data"); + throw new InvalidOperationException("Encryption failed", ex); + } + } + + public async Task DecryptAsync(IExecutionContext ctx, IGameApiClient client, string encryptedText) + { + if (string.IsNullOrEmpty(encryptedText)) + { + throw new ArgumentException("Encrypted text cannot be null or empty", nameof(encryptedText)); + } + + if (!encryptedText.StartsWith(ENCRYPTION_PREFIX)) + { + throw new ArgumentException("Invalid encrypted data format", nameof(encryptedText)); + } + + try + { + var key = await GetEncryptionKeyAsync(ctx, client); + var base64Data = encryptedText.Substring(ENCRYPTION_PREFIX.Length); + var encryptedData = Convert.FromBase64String(base64Data); + + if (encryptedData.Length < IV_SIZE + TAG_SIZE) + { + throw new ArgumentException("Invalid encrypted data length", nameof(encryptedText)); + } + + var iv = new byte[IV_SIZE]; + var tag = new byte[TAG_SIZE]; + var ciphertext = new byte[encryptedData.Length - IV_SIZE - TAG_SIZE]; + + Buffer.BlockCopy(encryptedData, 0, iv, 0, IV_SIZE); + Buffer.BlockCopy(encryptedData, IV_SIZE, tag, 0, TAG_SIZE); + Buffer.BlockCopy(encryptedData, IV_SIZE + TAG_SIZE, ciphertext, 0, ciphertext.Length); + + using var aes = new AesGcm(key, TAG_SIZE); + var plainBytes = new byte[ciphertext.Length]; + aes.Decrypt(iv, ciphertext, tag, plainBytes); + + return Encoding.UTF8.GetString(plainBytes); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to decrypt data"); + throw new InvalidOperationException("Decryption failed", ex); + } + } + + public Task IsEncryptedAsync(string value) + { + return Task.FromResult(!string.IsNullOrEmpty(value) && value.StartsWith(ENCRYPTION_PREFIX)); + } + + private async Task GetEncryptionKeyAsync(IExecutionContext ctx, IGameApiClient client) + { + try + { + var keyBase64 = await _secretService.GetValueWithRetryAsync(ctx, client, ENCRYPTION_KEY_NAME); + if (string.IsNullOrEmpty(keyBase64)) + { + throw new InvalidOperationException($"Encryption key '{ENCRYPTION_KEY_NAME}' not found in Secret Manager"); + } + + var key = Convert.FromBase64String(keyBase64); + if (key.Length != 32) // 256 bits = 32 bytes + { + throw new InvalidOperationException($"Encryption key must be 256 bits (32 bytes), but was {key.Length} bytes"); + } + + return key; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve encryption key from Secret Manager"); + throw; + } + } +} diff --git a/Project/Services/PlayerDataService.cs b/Project/Services/PlayerDataService.cs index c650f69..097b892 100644 --- a/Project/Services/PlayerDataService.cs +++ b/Project/Services/PlayerDataService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -14,31 +15,32 @@ namespace Unity.WalmartAuthRelay.Services; public class PlayerDataService : IPlayerDataService { private readonly ILogger _logger; + private readonly IEncryptionService _encryptionService; // Name of key within Player Data that stores the user's LCID. private const string LCID_PLAYER_DATA_KEY = "LCID"; - public PlayerDataService(ILogger logger) + public PlayerDataService(ILogger logger, IEncryptionService encryptionService) { - _logger = logger; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _encryptionService = encryptionService ?? throw new ArgumentNullException(nameof(encryptionService)); } public async Task GetPlayerLcidAsync(IExecutionContext ctx, IGameApiClient client) { - string playerLcid; - if (ctx.PlayerId == null) { - var message = "Received null PlayerId from context in StorePlayerLcidAsync()"; + var message = "Received null PlayerId from context in GetPlayerLcidAsync()"; _logger.LogError(message); throw new ApiException(ApiExceptionType.InvalidParameters, message); } + string storedValue; try { var result = await client.CloudSaveData.GetItemsAsync(ctx, ctx.AccessToken, ctx.ProjectId, ctx.PlayerId, new List{ LCID_PLAYER_DATA_KEY }); - playerLcid = (string)result.Data.Results.First().Value; + storedValue = (string)result.Data.Results.First().Value; } catch (ApiException e) { @@ -46,13 +48,42 @@ public async Task GetPlayerLcidAsync(IExecutionContext ctx, IGameApiClie throw; } - return playerLcid; + // Check if the value is already encrypted + if (await _encryptionService.IsEncryptedAsync(storedValue)) + { + // Decrypt and return + try + { + var decryptedValue = await _encryptionService.DecryptAsync(ctx, client, storedValue); + _logger.LogDebug("Successfully decrypted LCID for player {PlayerId}", ctx.PlayerId); + return decryptedValue; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to decrypt LCID for player {PlayerId}", ctx.PlayerId); + throw; + } + } + + // Value is plain text - perform lazy migration + _logger.LogInformation("Performing lazy migration: encrypting plain text LCID for player {PlayerId}", ctx.PlayerId); + + try + { + // Store the encrypted version (lazy migration) + await StorePlayerLcidAsync(ctx, client, storedValue); + return storedValue; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to migrate LCID to encrypted format for player {PlayerId}, returning plain text", ctx.PlayerId); + // Return plain text even if migration fails to maintain functionality + return storedValue; + } } public async Task StorePlayerLcidAsync(IExecutionContext ctx, IGameApiClient client, string lcid) { - bool stored; - if (ctx.PlayerId == null) { var message = "Received null PlayerId from context in StorePlayerLcidAsync()"; @@ -60,15 +91,42 @@ public async Task StorePlayerLcidAsync(IExecutionContext ctx, IGameApiClie throw new ApiException(ApiExceptionType.InvalidParameters, message); } + if (string.IsNullOrEmpty(lcid)) + { + var message = "LCID cannot be null or empty"; + _logger.LogError(message); + throw new ApiException(ApiExceptionType.InvalidParameters, message); + } + + string encryptedLcid; + try + { + // Always encrypt the LCID before storing + encryptedLcid = await _encryptionService.EncryptAsync(ctx, client, lcid); + _logger.LogDebug("Successfully encrypted LCID for player {PlayerId}", ctx.PlayerId); + } + catch (Exception ex) + { + var message = $"Failed to encrypt LCID for player {ctx.PlayerId}: {ex.Message}"; + _logger.LogError(ex, message); + throw new InvalidOperationException(message, ex); + } + + bool stored; try { var result = await client.CloudSaveData.SetItemAsync(ctx, ctx.AccessToken, ctx.ProjectId, ctx.PlayerId, - new SetItemBody(LCID_PLAYER_DATA_KEY, lcid)); + new SetItemBody(LCID_PLAYER_DATA_KEY, encryptedLcid)); stored = result.StatusCode == HttpStatusCode.OK; + + if (stored) + { + _logger.LogDebug("Successfully stored encrypted LCID for player {PlayerId}", ctx.PlayerId); + } } catch (ApiException e) { - var message = $"Failed to store player's LCID in Player Data: {e.Message}"; + var message = $"Failed to store encrypted LCID in Player Data: {e.Message}"; _logger.LogError(message); throw; } diff --git a/UnitTests/AesEncryptionServiceTests.cs b/UnitTests/AesEncryptionServiceTests.cs new file mode 100644 index 0000000..a0a79bd --- /dev/null +++ b/UnitTests/AesEncryptionServiceTests.cs @@ -0,0 +1,199 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Unity.WalmartAuthRelay.Interfaces; +using Unity.WalmartAuthRelay.Services; +using Unity.WalmartAuthRelay.UnitTests.Utils; +using Unity.Services.CloudCode.Apis; +using Unity.Services.CloudCode.Core; + +namespace Unity.WalmartAuthRelay.UnitTests; + +public class AesEncryptionServiceTests +{ + private readonly Mock> _loggerMock; + private readonly Mock _secretServiceMock; + private readonly Mock _gameApiClientMock; + private readonly IExecutionContext _context; + private readonly AesEncryptionService _encryptionService; + + private const string TestEncryptionKey = "VGhpc0lzQVRlc3RLZXlGb3IyNTZCaXRBRVMwMSEhISE="; // 256-bit key in base64 + + public AesEncryptionServiceTests() + { + _loggerMock = new Mock>(); + _secretServiceMock = new Mock(); + _gameApiClientMock = new Mock(); + _context = new FakeContext(); + + _encryptionService = new AesEncryptionService(_loggerMock.Object, _secretServiceMock.Object); + + // Setup default behavior for secret service + _secretServiceMock + .Setup(x => x.GetValueWithRetryAsync(_context, _gameApiClientMock.Object, "LCID_ENCRYPTION_KEY")) + .ReturnsAsync(TestEncryptionKey); + } + + [Fact] + public async Task EncryptAsyncReturnsEncryptedStringWhenValidPlainText() + { + // Arrange + const string plainText = "test-lcid-12345"; + + // Act + var result = await _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, plainText); + + // Assert + Assert.NotNull(result); + Assert.StartsWith("ENC:", result); + Assert.NotEqual(plainText, result); + } + + [Fact] + public async Task DecryptAsyncReturnsOriginalPlainTextWhenValidEncryptedText() + { + // Arrange + const string plainText = "test-lcid-12345"; + var encryptedText = await _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, plainText); + + // Act + var result = await _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, encryptedText); + + // Assert + Assert.Equal(plainText, result); + } + + [Fact] + public async Task EncryptDecryptRoundTripPreservesOriginalDataForMultipleValues() + { + // Arrange + var testValues = new[] { "lcid1", "another-lcid", "special-chars-!@#$%", "unicode-テスト" }; + + foreach (var testValue in testValues) + { + // Act + var encrypted = await _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, testValue); + var decrypted = await _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, encrypted); + + // Assert + Assert.Equal(testValue, decrypted); + } + } + + [Fact] + public async Task EncryptAsyncGeneratesDifferentOutputsForSameInput() + { + // Arrange + const string plainText = "same-input"; + + // Act + var encrypted1 = await _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, plainText); + var encrypted2 = await _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, plainText); + + // Assert - Different because of random IV + Assert.NotEqual(encrypted1, encrypted2); + + // But both should decrypt to the same value + var decrypted1 = await _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, encrypted1); + var decrypted2 = await _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, encrypted2); + Assert.Equal(plainText, decrypted1); + Assert.Equal(plainText, decrypted2); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task EncryptAsyncThrowsArgumentExceptionWhenInputIsNullOrEmpty(string? input) + { + // Act & Assert + await Assert.ThrowsAsync(() => + _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, input!)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task DecryptAsyncThrowsArgumentExceptionWhenInputIsNullOrEmpty(string? input) + { + // Act & Assert + await Assert.ThrowsAsync(() => + _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, input!)); + } + + [Fact] + public async Task DecryptAsyncThrowsArgumentExceptionWhenFormatIsInvalid() + { + // Arrange - text that doesn't start with "ENC:" + const string invalidText = "not-encrypted-text"; + + // Act & Assert + await Assert.ThrowsAsync(() => + _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, invalidText)); + } + + [Fact] + public async Task DecryptAsyncThrowsInvalidOperationExceptionWhenBase64DataIsInvalid() + { + // Arrange - invalid base64 after "ENC:" prefix + const string invalidText = "ENC:invalid-base64-data!!!"; + + // Act & Assert + await Assert.ThrowsAsync(() => + _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, invalidText)); + } + + [Fact] + public async Task IsEncryptedAsyncReturnsTrueWhenValueIsEncrypted() + { + // Arrange + var encryptedText = await _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, "test"); + + // Act + var result = await _encryptionService.IsEncryptedAsync(encryptedText); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("plain-text")] + [InlineData("")] + [InlineData(null)] + [InlineData("ENC")] + [InlineData("ENCRYPT:data")] + public async Task IsEncryptedAsyncReturnsFalseWhenValueIsNotEncrypted(string? input) + { + // Act + var result = await _encryptionService.IsEncryptedAsync(input!); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task EncryptAsyncThrowsInvalidOperationExceptionWhenEncryptionKeyIsInvalid() + { + // Arrange - Setup secret service to return invalid key + _secretServiceMock + .Setup(x => x.GetValueWithRetryAsync(_context, _gameApiClientMock.Object, "LCID_ENCRYPTION_KEY")) + .ReturnsAsync("invalid-key-too-short"); + + // Act & Assert + await Assert.ThrowsAsync(() => + _encryptionService.EncryptAsync(_context, _gameApiClientMock.Object, "test")); + } + + [Fact] + public async Task DecryptAsyncThrowsInvalidOperationExceptionWhenEncryptionKeyIsMissing() + { + // Arrange + _secretServiceMock + .Setup(x => x.GetValueWithRetryAsync(_context, _gameApiClientMock.Object, "LCID_ENCRYPTION_KEY")) + .ReturnsAsync((string)null!); + + // Act & Assert + await Assert.ThrowsAsync(() => + _encryptionService.DecryptAsync(_context, _gameApiClientMock.Object, "ENC:dGVzdA==")); + } +} diff --git a/UnitTests/PlayerDataServiceTests.cs b/UnitTests/PlayerDataServiceTests.cs new file mode 100644 index 0000000..9491d8b --- /dev/null +++ b/UnitTests/PlayerDataServiceTests.cs @@ -0,0 +1,323 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Moq; +using Unity.WalmartAuthRelay.Interfaces; +using Unity.WalmartAuthRelay.Services; +using Unity.WalmartAuthRelay.UnitTests.Utils; +using Unity.Services.CloudCode.Apis; +using Unity.Services.CloudCode.Core; +using Unity.Services.CloudCode.Shared; +using Unity.Services.CloudSave.Api; +using Unity.Services.CloudSave.Model; + +namespace Unity.WalmartAuthRelay.UnitTests; + +public class PlayerDataServiceTests +{ + private readonly Mock> _loggerMock; + private readonly Mock _encryptionServiceMock; + private readonly IExecutionContext _context; + private readonly PlayerDataService _playerDataService; + + private const string TestLcid = "test-lcid-12345"; + private const string EncryptedTestLcid = "ENC:test-lcid-12345-encrypted"; + + public PlayerDataServiceTests() + { + _loggerMock = new Mock>(); + _encryptionServiceMock = new Mock(); + _context = new FakeContext(); + + _playerDataService = new PlayerDataService(_loggerMock.Object, _encryptionServiceMock.Object); + + // Setup encryption service behavior + _encryptionServiceMock + .Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IExecutionContext ctx, IGameApiClient client, string plainText) => $"ENC:{plainText}-encrypted"); + + _encryptionServiceMock + .Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IExecutionContext ctx, IGameApiClient client, string encryptedText) => + encryptedText.Replace("ENC:", "").Replace("-encrypted", "")); + + _encryptionServiceMock + .Setup(x => x.IsEncryptedAsync(It.Is(s => s.StartsWith("ENC:")))) + .ReturnsAsync(true); + + _encryptionServiceMock + .Setup(x => x.IsEncryptedAsync(It.Is(s => !s.StartsWith("ENC:")))) + .ReturnsAsync(false); + } + + [Fact] + public void ConstructorDoesNotThrowWhenValidDependencies() + { + // Act & Assert + var service = new PlayerDataService(_loggerMock.Object, _encryptionServiceMock.Object); + Assert.NotNull(service); + } + + [Fact] + public void ConstructorThrowsArgumentNullExceptionWhenLoggerIsNull() + { + // Act & Assert + Assert.Throws(() => + new PlayerDataService(null!, _encryptionServiceMock.Object)); + } + + [Fact] + public void ConstructorThrowsArgumentNullExceptionWhenEncryptionServiceIsNull() + { + // Act & Assert + Assert.Throws(() => + new PlayerDataService(_loggerMock.Object, null!)); + } + + [Fact] + public async Task StorePlayerLcidAsyncThrowsApiExceptionWhenPlayerIdIsNull() + { + // Arrange + var contextWithNullPlayerId = new Mock(); + contextWithNullPlayerId.Setup(x => x.PlayerId).Returns((string?)null); + var mockClient = new Mock(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _playerDataService.StorePlayerLcidAsync(contextWithNullPlayerId.Object, mockClient.Object, TestLcid)); + + Assert.Contains("null PlayerId", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task StorePlayerLcidAsyncThrowsApiExceptionWhenLcidIsNullOrEmpty(string? lcid) + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert + await Assert.ThrowsAsync(() => + _playerDataService.StorePlayerLcidAsync(_context, mockClient.Object, lcid!)); + } + + [Fact] + public async Task GetPlayerLcidAsyncThrowsApiExceptionWhenPlayerIdIsNull() + { + // Arrange + var contextWithNullPlayerId = new Mock(); + contextWithNullPlayerId.Setup(x => x.PlayerId).Returns((string?)null); + var mockClient = new Mock(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _playerDataService.GetPlayerLcidAsync(contextWithNullPlayerId.Object, mockClient.Object)); + + Assert.Contains("null PlayerId", exception.Message); + } + + [Fact] + public async Task StorePlayerLcidAsyncReturnsTrueWhenSuccessful() + { + // Arrange + var mockClient = new Mock(); + var mockCloudSaveData = new Mock(); + mockClient.Setup(x => x.CloudSaveData).Returns(mockCloudSaveData.Object); + + // Create test response with StatusCode property + var cloudSaveResponse = new ApiResponse(); + typeof(ApiResponse).GetProperty("StatusCode")!.SetValue(cloudSaveResponse, HttpStatusCode.OK); + + mockCloudSaveData + .Setup(x => x.SetItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), default)) + .Returns(() => Task.FromResult(cloudSaveResponse)); + + // Act + var result = await _playerDataService.StorePlayerLcidAsync(_context, mockClient.Object, TestLcid); + + // Assert + Assert.True(result); + + // Verify that encryption was called with the original LCID + _encryptionServiceMock.Verify(x => x.EncryptAsync(_context, mockClient.Object, TestLcid), Times.Once); + + // Verify CloudSave was called with correct context, credentials, and SetItemBody + var expectedEncryptedValue = $"ENC:{TestLcid}-encrypted"; + mockCloudSaveData.Verify(x => x.SetItemAsync( + _context, + _context.AccessToken, + _context.ProjectId, + _context.PlayerId, + It.Is(body => + body.Key == "LCID" && + body.Value.Equals(expectedEncryptedValue) + ), + default), Times.Once); + } + + [Fact] + public async Task RemovePlayerLcidAsyncReturnsTrueWhenSuccessful() + { + // Arrange + var mockClient = new Mock(); + var mockCloudSaveData = new Mock(); + mockClient.Setup(x => x.CloudSaveData).Returns(mockCloudSaveData.Object); + + // Create test response with StatusCode property using reflection + var cloudSaveResponse = new ApiResponse(); + typeof(ApiResponse).GetProperty("StatusCode")!.SetValue(cloudSaveResponse, HttpStatusCode.OK); + + mockCloudSaveData + .Setup(x => x.DeleteItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), default)) + .Returns(() => Task.FromResult(cloudSaveResponse)); + + // Act + var result = await _playerDataService.RemovePlayerLcidAsync(_context, mockClient.Object); + + // Assert + Assert.True(result); + + // Verify CloudSave DeleteItemAsync was called with correct parameters + mockCloudSaveData.Verify(x => x.DeleteItemAsync( + _context, + _context.AccessToken, + "LCID", + _context.ProjectId, + _context.PlayerId, + null, + default), Times.Once); + + // Verify no encryption service calls were made (removal doesn't need encryption) + _encryptionServiceMock.Verify(x => x.IsEncryptedAsync(It.IsAny()), Times.Never); + _encryptionServiceMock.Verify(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _encryptionServiceMock.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetPlayerLcidAsyncReturnsDecryptedValueWhenDataIsEncrypted() + { + // Arrange + var mockClient = new Mock(); + var mockCloudSaveData = new Mock(); + mockClient.Setup(x => x.CloudSaveData).Returns(mockCloudSaveData.Object); + + var encryptedLcid = EncryptedTestLcid; // "ENC:dGVzdC1lbmNyeXB0ZWQtZGF0YQ==" + + // Mock GetItemsAsync response using reflection - similar to existing working test pattern + var item = Activator.CreateInstance(typeof(Item), true); + typeof(Item).GetProperty("Key")?.SetValue(item, "LCID"); + typeof(Item).GetProperty("Value")?.SetValue(item, encryptedLcid); + + var itemList = new List { (Item)item! }; + + var getItemsResponse = Activator.CreateInstance(typeof(GetItemsResponse), true); + typeof(GetItemsResponse).GetProperty("Results")?.SetValue(getItemsResponse, itemList); + + var cloudSaveResponse = new ApiResponse(); + typeof(ApiResponse).GetProperty("Data")!.SetValue(cloudSaveResponse, getItemsResponse); + + mockCloudSaveData + .Setup(x => x.GetItemsAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny(), default)) + .ReturnsAsync(cloudSaveResponse); + + // Act + var result = await _playerDataService.GetPlayerLcidAsync(_context, mockClient.Object); + + // Assert + Assert.Equal(TestLcid, result); + + // Verify CloudSave GetItemsAsync was called correctly + mockCloudSaveData.Verify(x => x.GetItemsAsync( + _context, + _context.AccessToken, + _context.ProjectId, + _context.PlayerId, + It.Is>(keys => keys.Contains("LCID")), + null, + default), Times.Once); + + // Verify encryption service was called correctly for encrypted data + _encryptionServiceMock.Verify(x => x.IsEncryptedAsync(encryptedLcid), Times.Once); + _encryptionServiceMock.Verify(x => x.DecryptAsync(_context, mockClient.Object, encryptedLcid), Times.Once); + + // Verify no encryption (migration) happened + _encryptionServiceMock.Verify(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetPlayerLcidAsyncPerformsLazyMigrationWhenDataIsPlainText() + { + // Arrange + var mockClient = new Mock(); + var mockCloudSaveData = new Mock(); + mockClient.Setup(x => x.CloudSaveData).Returns(mockCloudSaveData.Object); + + var plainTextLcid = TestLcid; // "test-lcid-12345" + + // Mock GetItemsAsync response using reflection - similar to existing working test pattern + var item = Activator.CreateInstance(typeof(Item), true); + typeof(Item).GetProperty("Key")?.SetValue(item, "LCID"); + typeof(Item).GetProperty("Value")?.SetValue(item, plainTextLcid); + + var itemList = new List { (Item)item! }; + + var getItemsResponse = Activator.CreateInstance(typeof(GetItemsResponse), true); + typeof(GetItemsResponse).GetProperty("Results")?.SetValue(getItemsResponse, itemList); + + var getCloudSaveResponse = new ApiResponse(); + typeof(ApiResponse).GetProperty("Data")!.SetValue(getCloudSaveResponse, getItemsResponse); + + mockCloudSaveData + .Setup(x => x.GetItemsAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny(), default)) + .ReturnsAsync(getCloudSaveResponse); + + // Mock SetItemAsync for lazy migration + var setCloudSaveResponse = new ApiResponse(); + typeof(ApiResponse).GetProperty("StatusCode")!.SetValue(setCloudSaveResponse, HttpStatusCode.OK); + + mockCloudSaveData + .Setup(x => x.SetItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), default)) + .Returns(() => Task.FromResult(setCloudSaveResponse)); + + // Act + var result = await _playerDataService.GetPlayerLcidAsync(_context, mockClient.Object); + + // Assert + Assert.Equal(TestLcid, result); + + // Verify CloudSave GetItemsAsync was called correctly + mockCloudSaveData.Verify(x => x.GetItemsAsync( + _context, + _context.AccessToken, + _context.ProjectId, + _context.PlayerId, + It.Is>(keys => keys.Contains("LCID")), + null, + default), Times.Once); + + // Verify encryption service detected plain text + _encryptionServiceMock.Verify(x => x.IsEncryptedAsync(plainTextLcid), Times.Once); + + // Verify lazy migration occurred (encryption and re-storage) + var expectedEncryptedValue = $"ENC:{TestLcid}-encrypted"; + _encryptionServiceMock.Verify(x => x.EncryptAsync(_context, mockClient.Object, plainTextLcid), Times.Once); + mockCloudSaveData.Verify(x => x.SetItemAsync( + _context, + _context.AccessToken, + _context.ProjectId, + _context.PlayerId, + It.Is(body => + body.Key == "LCID" && + body.Value.Equals(expectedEncryptedValue) + ), + default), Times.Once); + + // Verify no decryption happened (since it was plain text) + _encryptionServiceMock.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/docs/SETUP.md b/docs/SETUP.md index 3978264..12122f8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -88,11 +88,24 @@ Create a `configuration.rc` file with the following structure: } ``` -### 4. Set up Secret Manager +### 4. Set up Walmart client secret in Secret Manager The following secret must be added at the project level: `WALMART_IAM_CLIENT_SECRET`. The secret is obtained from Unity dashboard, in the Setup section. More details about adding a secret can be found in the [documentation](https://docs.unity.com/ugs/en-us/manual/secret-manager/manual/tutorials/store-secrets#add-a-secret). -### 5. Deploy Cloud Code C# module +### 5. Set up LCID encryption key in Secret Manager +The PlayerDataService encrypts LCID values using AES-256-GCM. +Generate a secure 256-bit (32-byte) key and encode it as base64: +```bash +# Generate random 32-byte key and encode as base64 +openssl rand -base64 32 +``` +Set the `LCID_ENCRYPTION_KEY` project secret using the value from above. + +#### Important Notes +- The key must be exactly 256 bits (32 bytes) when decoded from base64 +- Keep the key secure - losing it will make existing encrypted data unrecoverable + +### 6. Deploy Cloud Code C# module See the main [README.md](../README.md) file for deployment instructions.