From 848eaccb78422a1fd598279b29688a89fe752d7d Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 21 Jan 2026 09:35:18 +0100 Subject: [PATCH 1/5] Refactor: Add out-of-process Dataverse Proxy support Major architectural overhaul to enable online Dataverse data fetching via an out-of-process proxy. Introduces new projects for proxy server and IPC contracts, implements named pipe communication, and refactors core logic to use a new IOnlineDataService abstraction. Removes legacy direct connection code, adds support for parallel test execution, and packages the proxy with NuGet for auto-discovery. --- .claude/settings.local.json | 3 +- .github/workflows/ci.yml | 8 + .github/workflows/release.yml | 8 + .gitignore | 3 + XrmMockup.slnx | 3 + .../EntitySerializationHelper.cs | 192 ++++ .../MockDataFile.cs | 19 + .../ProxyContracts.cs | 95 ++ ...mMockup.DataverseProxy.Contracts.projitems | 16 + .../XrmMockup.DataverseProxy.Contracts.shproj | 13 + .../DataverseServiceFactory.cs | 18 + .../IDataverseServiceFactory.cs | 15 + .../MockDataService.cs | 149 +++ .../MockDataServiceFactory.cs | 30 + src/XrmMockup.DataverseProxy/Program.cs | 130 +++ src/XrmMockup.DataverseProxy/ProxyServer.cs | 278 ++++++ .../XrmMockup.DataverseProxy.csproj | 33 + src/XrmMockup365/Core.cs | 74 +- src/XrmMockup365/Database/DeviceIdManager.cs | 871 ------------------ .../Database/OrganizationHelper.cs | 99 -- src/XrmMockup365/Database/XrmDb.cs | 36 +- .../Internal/CoreInitializationData.cs | 6 +- .../Online/DefaultFileSystemHelper.cs | 34 + src/XrmMockup365/Online/IFileSystemHelper.cs | 16 + src/XrmMockup365/Online/IOnlineDataService.cs | 27 + src/XrmMockup365/Online/ProxyDllFinder.cs | 141 +++ .../Online/ProxyOnlineDataService.cs | 296 ++++++ .../Online/ProxyProcessManager.cs | 337 +++++++ src/XrmMockup365/StaticMetadataCache.cs | 14 +- src/XrmMockup365/XrmMockup365.csproj | 20 + src/XrmMockup365/XrmMockupBase.cs | 9 + src/XrmMockup365/XrmMockupSettings.cs | 33 +- .../Fixtures/ProxyServerTestBase.cs | 71 ++ .../Fixtures/TestDataverseServiceFactory.cs | 19 + .../Fixtures/TestPipeClient.cs | 123 +++ .../Integration/ProxyIntegrationTests.cs | 278 ++++++ .../Server/AuthenticationTests.cs | 95 ++ .../Server/MessageFramingTests.cs | 156 ++++ .../Server/RequestHandlerTests.cs | 194 ++++ .../Startup/ProgramStartupTests.cs | 205 +++++ .../XrmMockup.DataverseProxy.Tests.csproj | 27 + .../Online/MockOnlineDataService.cs | 121 +++ .../Online/OnlineDataServiceUnitTests.cs | 231 +++++ .../ProcessManager/PipeNameGenerationTests.cs | 129 +++ .../ProcessManager/ProxyDllFinderTests.cs | 325 +++++++ .../ProcessManager/SharedProxyStateTests.cs | 134 +++ .../ProcessManager/TestFileSystemHelper.cs | 71 ++ .../Online/ProxySpinUpIntegrationTests.cs | 456 +++++++++ tests/XrmMockup365Test/TestSettings.cs | 30 - tests/XrmMockup365Test/UnitTestBase.cs | 4 - .../XrmMockup365Test/XrmMockup365Test.csproj | 4 +- tests/XrmMockup365Test/XrmmockupFixture.cs | 32 +- .../XrmmockupFixtureNoProxyTypes.cs | 6 +- 53 files changed, 4622 insertions(+), 1115 deletions(-) create mode 100644 src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs create mode 100644 src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs create mode 100644 src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs create mode 100644 src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems create mode 100644 src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj create mode 100644 src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs create mode 100644 src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs create mode 100644 src/XrmMockup.DataverseProxy/MockDataService.cs create mode 100644 src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs create mode 100644 src/XrmMockup.DataverseProxy/Program.cs create mode 100644 src/XrmMockup.DataverseProxy/ProxyServer.cs create mode 100644 src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj delete mode 100644 src/XrmMockup365/Database/DeviceIdManager.cs delete mode 100644 src/XrmMockup365/Database/OrganizationHelper.cs create mode 100644 src/XrmMockup365/Online/DefaultFileSystemHelper.cs create mode 100644 src/XrmMockup365/Online/IFileSystemHelper.cs create mode 100644 src/XrmMockup365/Online/IOnlineDataService.cs create mode 100644 src/XrmMockup365/Online/ProxyDllFinder.cs create mode 100644 src/XrmMockup365/Online/ProxyOnlineDataService.cs create mode 100644 src/XrmMockup365/Online/ProxyProcessManager.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs create mode 100644 tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj create mode 100644 tests/XrmMockup365Test/Online/MockOnlineDataService.cs create mode 100644 tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs create mode 100644 tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs create mode 100644 tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs create mode 100644 tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs create mode 100644 tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs create mode 100644 tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ddf5f63e..6cb145e9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(cat:*)", "Bash(dotnet build:*)", "Bash(dotnet test:*)", - "Bash(dotnet pack:*)" + "Bash(dotnet pack:*)", + "Bash(find:*)" ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9615c5b0..abcccf2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,14 @@ jobs: shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup365/XrmMockup365.csproj + - name: Set XrmMockup.DataverseProxy.Contracts version from RELEASE_NOTES.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.csproj + + - name: Set XrmMockup.DataverseProxy version from RELEASE_NOTES.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj + - name: Set MetadataGenerator.Tool version from CHANGELOG.md shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./src/MetadataGen/MetadataGenerator.Tool/CHANGELOG.md -CsprojPath ./src/MetadataGen/MetadataGenerator.Tool/MetadataGenerator.Tool.csproj diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95e8b1ee..dd6af6c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,14 @@ jobs: shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup365/XrmMockup365.csproj + - name: Set XrmMockup.DataverseProxy.Contracts version from RELEASE_NOTES.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.csproj + + - name: Set XrmMockup.DataverseProxy version from RELEASE_NOTES.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj + - name: Set MetadataGenerator.Tool version from CHANGELOG.md shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./src/MetadataGen/MetadataGenerator.Tool/CHANGELOG.md -CsprojPath ./src/MetadataGen/MetadataGenerator.Tool/MetadataGenerator.Tool.csproj diff --git a/.gitignore b/.gitignore index 7a89f14f..f6d83a36 100644 --- a/.gitignore +++ b/.gitignore @@ -207,3 +207,6 @@ docs/tools/FSharp.Formatting.svclog /XrmMockup/tools/Signature/delegate.snk /tools/XrmContext/EnvInfo.config /src/MetadataGen/XrmMockupTest.csproj + +# Claude temp files +tmpclaude-*-cwd diff --git a/XrmMockup.slnx b/XrmMockup.slnx index 60946edd..fcb37276 100644 --- a/XrmMockup.slnx +++ b/XrmMockup.slnx @@ -33,9 +33,12 @@ + + + diff --git a/src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs b/src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs new file mode 100644 index 00000000..8b79d425 --- /dev/null +++ b/src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs @@ -0,0 +1,192 @@ +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP +#nullable enable +#endif +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.Serialization; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace XrmMockup.DataverseProxy.Contracts +{ + /// + /// Helper class for serializing and deserializing CRM SDK types + /// using DataContractSerializer. + /// + internal static class EntitySerializationHelper + { + private static readonly DataContractSerializer EntitySerializer = new DataContractSerializer( + typeof(Entity), + GetKnownTypes()); + + private static readonly DataContractSerializer EntityCollectionSerializer = new DataContractSerializer( + typeof(EntityCollection), + GetKnownTypes()); + + private static readonly DataContractSerializer QueryExpressionSerializer = new DataContractSerializer( + typeof(QueryExpression), + GetKnownTypes()); + + private static IEnumerable GetKnownTypes() + { + return new[] + { + typeof(Entity), + typeof(EntityCollection), + typeof(EntityReference), + typeof(OptionSetValue), + typeof(Money), + typeof(AliasedValue), + typeof(QueryExpression), + typeof(ColumnSet), + typeof(ConditionExpression), + typeof(FilterExpression), + typeof(LinkEntity), + typeof(OrderExpression), + typeof(PagingInfo) + }; + } + + /// + /// Serializes an Entity to a byte array. + /// Converts derived types (early-bound entities) to base Entity to ensure serialization works. + /// + public static byte[] SerializeEntity(Entity entity) + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + + // Convert to base Entity type to ensure serialization works for early-bound types + var baseEntity = ToBaseEntity(entity); + + using (var ms = new MemoryStream()) + { + EntitySerializer.WriteObject(ms, baseEntity); + return ms.ToArray(); + } + } + + /// + /// Converts an entity (potentially early-bound) to base Entity type.
+ /// We need to do this because DataContractSerializer has issues with derived types. + ///
+ private static Entity ToBaseEntity(Entity entity) + { + if (entity.GetType() == typeof(Entity)) + return entity; + + var baseEntity = new Entity(entity.LogicalName, entity.Id); + + if (entity.Attributes != null && entity.Attributes.Count > 0) + baseEntity.Attributes.AddRange(entity.Attributes); + + if (entity.FormattedValues != null && entity.FormattedValues.Count > 0) + baseEntity.FormattedValues.AddRange(entity.FormattedValues); + + if (entity.RelatedEntities != null && entity.RelatedEntities.Count > 0) + baseEntity.RelatedEntities.AddRange(entity.RelatedEntities); + + if (entity.KeyAttributes != null && entity.KeyAttributes.Count > 0) + baseEntity.KeyAttributes.AddRange(entity.KeyAttributes); + + baseEntity.RowVersion = entity.RowVersion; + + return baseEntity; + } + + /// + /// Deserializes an Entity from a byte array. + /// + public static Entity DeserializeEntity(byte[] data) + { + if (data == null || data.Length == 0) + throw new ArgumentException("Data cannot be null or empty", nameof(data)); + + using (var ms = new MemoryStream(data)) + { + var result = EntitySerializer.ReadObject(ms); + return result as Entity + ?? throw new InvalidOperationException("Deserialization returned null or unexpected type"); + } + } + + /// + /// Serializes an EntityCollection to a byte array. + /// Converts derived types (early-bound entities) to base Entity to ensure serialization works. + /// + public static byte[] SerializeEntityCollection(EntityCollection collection) + { + if (collection == null) + throw new ArgumentNullException(nameof(collection)); + + // Convert all entities to base Entity type + var baseCollection = new EntityCollection + { + EntityName = collection.EntityName, + MoreRecords = collection.MoreRecords, + PagingCookie = collection.PagingCookie, + TotalRecordCount = collection.TotalRecordCount, + TotalRecordCountLimitExceeded = collection.TotalRecordCountLimitExceeded + }; + + foreach (var entity in collection.Entities) + { + baseCollection.Entities.Add(ToBaseEntity(entity)); + } + + using (var ms = new MemoryStream()) + { + EntityCollectionSerializer.WriteObject(ms, baseCollection); + return ms.ToArray(); + } + } + + /// + /// Deserializes an EntityCollection from a byte array. + /// + public static EntityCollection DeserializeEntityCollection(byte[] data) + { + if (data == null || data.Length == 0) + throw new ArgumentException("Data cannot be null or empty", nameof(data)); + + using (var ms = new MemoryStream(data)) + { + var result = EntityCollectionSerializer.ReadObject(ms); + return result as EntityCollection + ?? throw new InvalidOperationException("Deserialization returned null or unexpected type"); + } + } + + /// + /// Serializes a QueryExpression to a byte array. + /// + public static byte[] SerializeQueryExpression(QueryExpression query) + { + if (query == null) + throw new ArgumentNullException(nameof(query)); + + using (var ms = new MemoryStream()) + { + QueryExpressionSerializer.WriteObject(ms, query); + return ms.ToArray(); + } + } + + /// + /// Deserializes a QueryExpression from a byte array. + /// + public static QueryExpression DeserializeQueryExpression(byte[] data) + { + if (data == null || data.Length == 0) + throw new ArgumentException("Data cannot be null or empty", nameof(data)); + + using (var ms = new MemoryStream(data)) + { + var result = QueryExpressionSerializer.ReadObject(ms); + return result as QueryExpression + ?? throw new InvalidOperationException("Deserialization returned null or unexpected type"); + } + } + } +} diff --git a/src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs b/src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs new file mode 100644 index 00000000..1378154b --- /dev/null +++ b/src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs @@ -0,0 +1,19 @@ +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP +#nullable enable +#endif +using System.Collections.Generic; + +namespace XrmMockup.DataverseProxy.Contracts +{ + /// + /// JSON schema for mock data file used in testing. + /// + internal class MockDataFile + { + /// + /// List of serialized entities to use as mock data. + /// Each entity is serialized using DataContractSerializer as a byte array. + /// + public List Entities { get; set; } = new List(); + } +} diff --git a/src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs b/src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs new file mode 100644 index 00000000..7e29caf6 --- /dev/null +++ b/src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs @@ -0,0 +1,95 @@ +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP +#nullable enable +#endif +using System; + +namespace XrmMockup.DataverseProxy.Contracts +{ + /// + /// Types of requests that can be sent to the proxy. + /// + public enum ProxyRequestType : byte + { + Ping = 0, + Retrieve = 1, + RetrieveMultiple = 2, + Shutdown = 3 + } + + /// + /// Base request envelope for proxy communication. + /// + public class ProxyRequest + { + public ProxyRequestType RequestType { get; set; } + + /// + /// JSON-serialized payload for the specific request type. + /// +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP + public string? Payload { get; set; } +#else + public string Payload { get; set; } +#endif + + /// + /// Authentication token. Must match the token passed to the proxy at startup. + /// +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP + public string? AuthToken { get; set; } +#else + public string AuthToken { get; set; } +#endif + } + + /// + /// Request to retrieve a single entity by ID. + /// + public class ProxyRetrieveRequest + { + public string EntityName { get; set; } = string.Empty; + public Guid Id { get; set; } + + /// + /// Column names to retrieve. Null means all columns. + /// +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP + public string[]? Columns { get; set; } +#else + public string[] Columns { get; set; } +#endif + } + + /// + /// Request to retrieve multiple entities using a QueryExpression. + /// + public class ProxyRetrieveMultipleRequest + { + /// + /// QueryExpression serialized using DataContractSerializer. + /// + public byte[] SerializedQuery { get; set; } = Array.Empty(); + } + + /// + /// Response from the proxy. + /// + public class ProxyResponse + { + public bool Success { get; set; } +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP + public string? ErrorMessage { get; set; } +#else + public string ErrorMessage { get; set; } +#endif + + /// + /// Serialized Entity or EntityCollection using DataContractSerializer. + /// +#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP + public byte[]? SerializedData { get; set; } +#else + public byte[] SerializedData { get; set; } +#endif + } +} diff --git a/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems b/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems new file mode 100644 index 00000000..b4dc0ab4 --- /dev/null +++ b/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems @@ -0,0 +1,16 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + + + XrmMockup.DataverseProxy.Contracts + + + + + + + diff --git a/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj b/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj new file mode 100644 index 00000000..6f203110 --- /dev/null +++ b/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj @@ -0,0 +1,13 @@ + + + + 6F508B63-7125-4061-AC30-6C6E3E3C357D + 14.0 + + + + + + + + diff --git a/src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs b/src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs new file mode 100644 index 00000000..8a8e88d8 --- /dev/null +++ b/src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.PowerPlatform.Dataverse.Client; + +namespace XrmMockup.DataverseProxy; + +/// +/// Factory that provides IOrganizationServiceAsync2 from a ServiceClient. +/// +public class DataverseServiceFactory : IDataverseServiceFactory +{ + private readonly ServiceClient _serviceClient; + + public DataverseServiceFactory(ServiceClient serviceClient) + { + _serviceClient = serviceClient ?? throw new ArgumentNullException(nameof(serviceClient)); + } + + public IOrganizationServiceAsync2 CreateService() => _serviceClient; +} diff --git a/src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs b/src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs new file mode 100644 index 00000000..dd6288a3 --- /dev/null +++ b/src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.PowerPlatform.Dataverse.Client; + +namespace XrmMockup.DataverseProxy; + +/// +/// Factory interface for creating IOrganizationServiceAsync2 instances. +/// Enables dependency injection and testing of ProxyServer. +/// +public interface IDataverseServiceFactory +{ + /// + /// Creates or returns an IOrganizationServiceAsync2 instance for Dataverse operations. + /// + IOrganizationServiceAsync2 CreateService(); +} diff --git a/src/XrmMockup.DataverseProxy/MockDataService.cs b/src/XrmMockup.DataverseProxy/MockDataService.cs new file mode 100644 index 00000000..30dee8fe --- /dev/null +++ b/src/XrmMockup.DataverseProxy/MockDataService.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace XrmMockup.DataverseProxy; + +/// +/// Mock implementation of IOrganizationServiceAsync2 backed by in-memory data. +/// Used for testing proxy communication without connecting to Dataverse. +/// +internal class MockDataService : IOrganizationServiceAsync2 +{ + private readonly Dictionary<(string LogicalName, Guid Id), Entity> _entities = new(); + + public MockDataService(IEnumerable entities) + { + foreach (var entity in entities) + { + _entities[(entity.LogicalName, entity.Id)] = entity; + } + } + + public Task RetrieveAsync(string entityName, Guid id, ColumnSet columnSet, CancellationToken cancellationToken = default) + { + if (_entities.TryGetValue((entityName, id), out var entity)) + { + return Task.FromResult(ApplyColumnSet(entity, columnSet)); + } + + throw new Exception($"Entity {entityName} with id {id} not found"); + } + + public Task RetrieveMultipleAsync(QueryExpression query, CancellationToken cancellationToken = default) + { + var matches = _entities.Values + .Where(e => e.LogicalName == query.EntityName) + .Where(e => MatchesCriteria(e, query.Criteria)) + .Select(e => ApplyColumnSet(e, query.ColumnSet)) + .ToList(); + + return Task.FromResult(new EntityCollection(matches) { EntityName = query.EntityName }); + } + + private static Entity ApplyColumnSet(Entity entity, ColumnSet columnSet) + { + if (columnSet.AllColumns) + { + return CloneEntity(entity); + } + + var result = new Entity(entity.LogicalName, entity.Id); + foreach (var column in columnSet.Columns) + { + if (entity.Contains(column)) + { + result[column] = entity[column]; + } + } + return result; + } + + private static Entity CloneEntity(Entity entity) + { + var clone = new Entity(entity.LogicalName, entity.Id); + foreach (var attr in entity.Attributes) + { + clone[attr.Key] = attr.Value; + } + return clone; + } + + private static bool MatchesCriteria(Entity entity, FilterExpression filter) + { + if (filter == null || filter.Conditions.Count == 0 && filter.Filters.Count == 0) + { + return true; + } + + var conditionResults = filter.Conditions.Select(c => MatchesCondition(entity, c)); + var filterResults = filter.Filters.Select(f => MatchesCriteria(entity, f)); + var allResults = conditionResults.Concat(filterResults).ToList(); + + return filter.FilterOperator == LogicalOperator.And + ? allResults.All(r => r) + : allResults.Any(r => r); + } + + private static bool MatchesCondition(Entity entity, ConditionExpression condition) + { + var attributeName = condition.AttributeName; + var entityValue = entity.Contains(attributeName) ? entity[attributeName] : null; + + return condition.Operator switch + { + ConditionOperator.Equal => Equals(entityValue, condition.Values.FirstOrDefault()), + ConditionOperator.NotEqual => !Equals(entityValue, condition.Values.FirstOrDefault()), + ConditionOperator.Like => entityValue is string s && condition.Values.FirstOrDefault() is string pattern + && s.Contains(pattern.Replace("%", "")), + ConditionOperator.BeginsWith => entityValue is string s2 && condition.Values.FirstOrDefault() is string prefix + && s2.StartsWith(prefix, StringComparison.OrdinalIgnoreCase), + ConditionOperator.Null => entityValue == null, + ConditionOperator.NotNull => entityValue != null, + _ => true // Default to matching for unsupported operators + }; + } + + // Synchronous methods - delegate to async versions + public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) + => RetrieveAsync(entityName, id, columnSet).GetAwaiter().GetResult(); + + public EntityCollection RetrieveMultiple(QueryBase query) + => RetrieveMultipleAsync((QueryExpression)query).GetAwaiter().GetResult(); + + public Task RetrieveMultipleAsync(QueryBase query, CancellationToken cancellationToken = default) + => RetrieveMultipleAsync((QueryExpression)query, cancellationToken); + + // Not implemented - not needed for testing + public Guid Create(Entity entity) => throw new NotImplementedException(); + public Task CreateAsync(Entity entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task CreateAsync(Entity entity) => throw new NotImplementedException(); + public Task CreateAndReturnAsync(Entity entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public void Update(Entity entity) => throw new NotImplementedException(); + public Task UpdateAsync(Entity entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task UpdateAsync(Entity entity) => throw new NotImplementedException(); + public void Delete(string entityName, Guid id) => throw new NotImplementedException(); + public Task DeleteAsync(string entityName, Guid id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task DeleteAsync(string entityName, Guid id) => throw new NotImplementedException(); + public void Associate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); + public Task AssociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task AssociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); + public void Disassociate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); + public Task DisassociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task DisassociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); + public OrganizationResponse Execute(OrganizationRequest request) => throw new NotImplementedException(); + public Task ExecuteAsync(OrganizationRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task ExecuteAsync(OrganizationRequest request) => throw new NotImplementedException(); + + // IOrganizationServiceAsync methods without CancellationToken + Task IOrganizationServiceAsync.RetrieveAsync(string entityName, Guid id, ColumnSet columnSet) + => RetrieveAsync(entityName, id, columnSet, CancellationToken.None); + + Task IOrganizationServiceAsync.RetrieveMultipleAsync(QueryBase query) + => RetrieveMultipleAsync(query, CancellationToken.None); +} diff --git a/src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs b/src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs new file mode 100644 index 00000000..b8cae699 --- /dev/null +++ b/src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs @@ -0,0 +1,30 @@ +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.PowerPlatform.Dataverse.Client; +using XrmMockup.DataverseProxy.Contracts; + +namespace XrmMockup.DataverseProxy; + +/// +/// Factory that creates MockDataService instances from a JSON data file. +/// Used for testing proxy communication without connecting to Dataverse. +/// +internal class MockDataServiceFactory : IDataverseServiceFactory +{ + private readonly MockDataService _service; + + public MockDataServiceFactory(string dataFilePath) + { + var json = File.ReadAllText(dataFilePath); + var data = JsonSerializer.Deserialize(json); + + var entities = data?.Entities? + .Select(EntitySerializationHelper.DeserializeEntity) + .ToList() ?? []; + + _service = new MockDataService(entities); + } + + public IOrganizationServiceAsync2 CreateService() => _service; +} diff --git a/src/XrmMockup.DataverseProxy/Program.cs b/src/XrmMockup.DataverseProxy/Program.cs new file mode 100644 index 00000000..e6a2653e --- /dev/null +++ b/src/XrmMockup.DataverseProxy/Program.cs @@ -0,0 +1,130 @@ +using System.CommandLine; +using DataverseConnection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; +using XrmMockup.DataverseProxy; + +// Define CLI options +var urlOption = new Option("--url", "-u") +{ + Description = "Dataverse environment URL (e.g., https://org.crm.dynamics.com)", + Arity = ArgumentArity.ExactlyOne +}; + +var pipeOption = new Option("--pipe", "-p") +{ + Description = "Named pipe name for IPC communication", + Arity = ArgumentArity.ExactlyOne +}; + +var mockDataFileOption = new Option("--mock-data-file", "-m") +{ + Description = "Path to JSON file containing mock data. When set, the proxy loads entities from this file instead of connecting to Dataverse. Used for testing.", + Arity = ArgumentArity.ZeroOrOne +}; + +// Build root command +var rootCommand = new RootCommand("XrmMockup Dataverse Proxy - Out-of-process bridge for online data fetching") +{ + urlOption, + pipeOption, + mockDataFileOption +}; + +rootCommand.SetAction(async (parseResult, cancellationToken) => +{ + var url = parseResult.GetValue(urlOption); + var pipeName = parseResult.GetValue(pipeOption); + var mockDataFile = parseResult.GetValue(mockDataFileOption); + + if (string.IsNullOrEmpty(pipeName)) + { + Console.Error.WriteLine("Error: --pipe is required"); + return 1; + } + + // Read auth token from stdin (with timeout) - more secure than command line args + string? authToken; + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + { + try + { + authToken = await Console.In.ReadLineAsync(cts.Token); + } + catch (OperationCanceledException) + { + Console.Error.WriteLine("Error: Timeout waiting for auth token on stdin"); + return 1; + } + } + + if (string.IsNullOrEmpty(authToken)) + { + Console.Error.WriteLine("Error: Auth token is required (pass via stdin)"); + return 1; + } + + // Set up DI + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + + // Check if running in mock mode or real Dataverse mode + var useMockData = !string.IsNullOrEmpty(mockDataFile); + + if (!useMockData && string.IsNullOrEmpty(url)) + { + Console.Error.WriteLine("Error: --url is required unless --mock-data-file is specified"); + return 1; + } + + if (!useMockData) + { + // Configure DataverseConnection with the URL passed via command line + services.AddDataverse(options => options.DataverseUrl = url!); + } + + await using var serviceProvider = services.BuildServiceProvider(); + + var logger = serviceProvider.GetRequiredService>(); + + try + { + IDataverseServiceFactory serviceFactory; + + if (useMockData) + { + logger.LogInformation("Starting XrmMockup Dataverse Proxy in mock mode with data file: {MockDataFile}", mockDataFile); + serviceFactory = new MockDataServiceFactory(mockDataFile); + } + else + { + logger.LogInformation("Starting XrmMockup Dataverse Proxy for {Url}", url); + var serviceClient = serviceProvider.GetRequiredService(); + + // Verify connection + var whoAmI = serviceClient.Execute(new Microsoft.Crm.Sdk.Messages.WhoAmIRequest()); + logger.LogInformation("Connected to Dataverse as user {UserId}", ((Microsoft.Crm.Sdk.Messages.WhoAmIResponse)whoAmI).UserId); + + serviceFactory = new DataverseServiceFactory(serviceClient); + } + + // Start the proxy server + var proxyServer = new ProxyServer(serviceFactory, pipeName, authToken, serviceProvider.GetRequiredService>()); + await proxyServer.RunAsync(cancellationToken); + + return 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Proxy server failed"); + return 1; + } +}); + +return await rootCommand.Parse(args).InvokeAsync(); diff --git a/src/XrmMockup.DataverseProxy/ProxyServer.cs b/src/XrmMockup.DataverseProxy/ProxyServer.cs new file mode 100644 index 00000000..9a612ace --- /dev/null +++ b/src/XrmMockup.DataverseProxy/ProxyServer.cs @@ -0,0 +1,278 @@ +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk.Query; +using XrmMockup.DataverseProxy.Contracts; + +namespace XrmMockup.DataverseProxy; + +/// +/// Named pipe server that handles proxy requests from XrmMockup clients. +/// Supports multiple concurrent client connections for parallel test execution. +/// +/// +/// Creates a new ProxyServer with a service factory. +/// +public class ProxyServer(IDataverseServiceFactory serviceFactory, string pipeName, string authToken, ILogger logger) +{ + private readonly IDataverseServiceFactory _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); + private readonly string _pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName)); + private readonly string _authToken = authToken ?? throw new ArgumentNullException(nameof(authToken)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly List _activeClients = []; + private readonly object _clientsLock = new(); + + /// + /// Runs the proxy server, accepting connections and processing requests. + /// Handles multiple clients concurrently for parallel test execution. + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting proxy server on pipe: {PipeName}", _pipeName); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var pipeServer = new NamedPipeServerStream( + _pipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + _logger.LogDebug("Waiting for client connection..."); + await pipeServer.WaitForConnectionAsync(cancellationToken); + _logger.LogDebug("Client connected, spawning handler task"); + + // Spawn a task to handle this client, allowing the main loop to accept more connections + var clientTask = HandleClientAsync(pipeServer, cancellationToken); + TrackClientTask(clientTask); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Proxy server shutting down, waiting for active clients..."); + await WaitForActiveClientsAsync(); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error accepting client connection"); + } + } + } + + private void TrackClientTask(Task clientTask) + { + lock (_clientsLock) + { + // Remove completed tasks + _activeClients.RemoveAll(t => t.IsCompleted); + _activeClients.Add(clientTask); + } + } + + private async Task WaitForActiveClientsAsync() + { + Task[] tasksToWait; + lock (_clientsLock) + { + tasksToWait = [.. _activeClients]; + } + + if (tasksToWait.Length > 0) + { + _logger.LogDebug("Waiting for {Count} active client(s) to complete", tasksToWait.Length); + await Task.WhenAll(tasksToWait); + } + } + + private async Task HandleClientAsync(NamedPipeServerStream pipeServer, CancellationToken cancellationToken) + { + try + { + while (pipeServer.IsConnected && !cancellationToken.IsCancellationRequested) + { + // Read message length (4 bytes, little-endian) + var lengthBuffer = new byte[4]; + var bytesRead = await pipeServer.ReadAsync(lengthBuffer.AsMemory(0, 4), cancellationToken); + if (bytesRead == 0) + { + _logger.LogDebug("Client disconnected"); + break; + } + + if (bytesRead < 4) + { + _logger.LogWarning("Incomplete message length received"); + continue; + } + + var messageLength = BitConverter.ToInt32(lengthBuffer, 0); + if (messageLength <= 0 || messageLength > 100 * 1024 * 1024) // Max 100MB + { + _logger.LogWarning("Invalid message length: {Length}", messageLength); + continue; + } + + // Read message body + var messageBuffer = new byte[messageLength]; + var totalRead = 0; + while (totalRead < messageLength) + { + bytesRead = await pipeServer.ReadAsync(messageBuffer.AsMemory(totalRead, messageLength - totalRead), cancellationToken); + if (bytesRead == 0) + break; + totalRead += bytesRead; + } + + if (totalRead < messageLength) + { + _logger.LogWarning("Incomplete message received"); + continue; + } + + // Deserialize and process request + var request = JsonSerializer.Deserialize(messageBuffer); + if (request is null) + { + _logger.LogWarning("Failed to deserialize request"); + continue; + } + var response = await ProcessRequestAsync(request); + + // Serialize and send response + var responseBytes = JsonSerializer.SerializeToUtf8Bytes(response); + var responseLength = BitConverter.GetBytes(responseBytes.Length); + await pipeServer.WriteAsync(responseLength.AsMemory(0, 4), cancellationToken); + await pipeServer.WriteAsync(responseBytes, cancellationToken); + await pipeServer.FlushAsync(cancellationToken); + + // Handle shutdown request - note: with multiple clients, only this client's connection closes + if (request.RequestType == ProxyRequestType.Shutdown) + { + _logger.LogDebug("Client requested disconnect"); + break; + } + } + } + catch (IOException) + { + _logger.LogDebug("Pipe broken, client disconnected"); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Client handler cancelled"); + } + finally + { + // Always dispose the pipe when client handling is done + await pipeServer.DisposeAsync(); + } + } + + private async Task ProcessRequestAsync(ProxyRequest request) + { + // Validate authentication token using constant-time comparison to prevent timing attacks + if (!ValidateAuthToken(request.AuthToken)) + { + _logger.LogWarning("Invalid authentication token received"); + return new ProxyResponse { Success = false, ErrorMessage = "Authentication failed" }; + } + + try + { + return request.RequestType switch + { + ProxyRequestType.Ping => new ProxyResponse { Success = true }, + ProxyRequestType.Retrieve => await HandleRetrieveAsync(request.Payload), + ProxyRequestType.RetrieveMultiple => await HandleRetrieveMultipleAsync(request.Payload), + ProxyRequestType.Shutdown => new ProxyResponse { Success = true }, + _ => new ProxyResponse { Success = false, ErrorMessage = $"Unknown request type: {request.RequestType}" } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing {RequestType} request", request.RequestType); + return new ProxyResponse + { + Success = false, + ErrorMessage = ex.Message + }; + } + } + + /// + /// Validates the authentication token using constant-time comparison to prevent timing attacks. + /// + private bool ValidateAuthToken(string? token) + { + if (token == null) + return false; + + var expectedBytes = Encoding.UTF8.GetBytes(_authToken); + var actualBytes = Encoding.UTF8.GetBytes(token); + + // Use FixedTimeEquals for constant-time comparison + return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); + } + + private async Task HandleRetrieveAsync(string payload) + { + if (string.IsNullOrEmpty(payload)) + { + return new ProxyResponse { Success = false, ErrorMessage = "Empty payload" }; + } + + var retrieveRequest = JsonSerializer.Deserialize(payload); + if (retrieveRequest is null) + { + return new ProxyResponse { Success = false, ErrorMessage = "Invalid retrieve request payload" }; + } + _logger.LogDebug("Retrieve: {EntityName} {Id}", retrieveRequest.EntityName, retrieveRequest.Id); + + var columnSet = retrieveRequest.Columns == null + ? new ColumnSet(true) + : new ColumnSet(retrieveRequest.Columns); + + var service = _serviceFactory.CreateService(); + var entity = await service.RetrieveAsync(retrieveRequest.EntityName, retrieveRequest.Id, columnSet); + var serializedEntity = EntitySerializationHelper.SerializeEntity(entity); + + return new ProxyResponse + { + Success = true, + SerializedData = serializedEntity + }; + } + + private async Task HandleRetrieveMultipleAsync(string payload) + { + if (string.IsNullOrEmpty(payload)) + { + return new ProxyResponse { Success = false, ErrorMessage = "Empty payload" }; + } + + var retrieveMultipleRequest = JsonSerializer.Deserialize(payload); + if (retrieveMultipleRequest is null) + { + return new ProxyResponse { Success = false, ErrorMessage = "Invalid retrieve multiple request payload" }; + } + + var queryExpression = EntitySerializationHelper.DeserializeQueryExpression(retrieveMultipleRequest.SerializedQuery); + _logger.LogDebug("RetrieveMultiple: {EntityName}", queryExpression.EntityName); + + var service = _serviceFactory.CreateService(); + var entityCollection = await service.RetrieveMultipleAsync(queryExpression); + var serializedCollection = EntitySerializationHelper.SerializeEntityCollection(entityCollection); + + return new ProxyResponse + { + Success = true, + SerializedData = serializedCollection + }; + } +} diff --git a/src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj b/src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj new file mode 100644 index 00000000..4feee238 --- /dev/null +++ b/src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj @@ -0,0 +1,33 @@ + + + + Exe + false + net8.0 + enable + enable + XrmMockup Dataverse Proxy - Out-of-process bridge for online data fetching + + + + + + + + + + + + + + + + + <_Parameter1>XrmMockup.DataverseProxy.Tests + + + <_Parameter1>XrmMockup365Test + + + + diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index 8fe3d26d..3b74f87a 100644 --- a/src/XrmMockup365/Core.cs +++ b/src/XrmMockup365/Core.cs @@ -1,10 +1,10 @@ -using DG.Tools.XrmMockup.Database; +using DG.Tools.XrmMockup.Database; using DG.Tools.XrmMockup.Internal; using DG.Tools.XrmMockup.Serialization; +using DG.Tools.XrmMockup.Online; using XrmPluginCore.Enums; using Microsoft.Crm.Sdk.Messages; using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Client; using Microsoft.Xrm.Sdk.Messages; using Microsoft.Xrm.Sdk.Metadata; using Microsoft.Xrm.Sdk.Organization; @@ -59,7 +59,7 @@ internal class Core : IXrmMockupExtension private XrmDb db; private Dictionary snapshots; private Dictionary entityTypeMap = new Dictionary(); - private OrganizationServiceProxy OnlineProxy; + private IOnlineDataService OnlineDataService; private int baseCurrencyPrecision; private FormulaFieldEvaluator FormulaFieldEvaluator { get; set; } private List systemAttributeNames; @@ -79,7 +79,7 @@ public Core(XrmMockupSettings Settings, MetadataSkeleton metadata, List SecurityRoles = SecurityRoles, BaseCurrency = metadata.BaseOrganization.GetAttributeValue("basecurrencyid"), BaseCurrencyPrecision = metadata.BaseOrganization.GetAttributeValue("pricingdecimalprecision"), - OnlineProxy = null, + OnlineDataService = null, EntityTypeMap = new Dictionary() }; @@ -99,7 +99,7 @@ public Core(XrmMockupSettings Settings, StaticMetadataCache staticCache) SecurityRoles = staticCache.SecurityRoles, BaseCurrency = staticCache.BaseCurrency, BaseCurrencyPrecision = staticCache.BaseCurrencyPrecision, - OnlineProxy = staticCache.OnlineProxy, + OnlineDataService = staticCache.OnlineDataService, EntityTypeMap = staticCache.EntityTypeMap }; @@ -116,10 +116,10 @@ private void InitializeCore(CoreInitializationData initData) metadata = initData.Metadata; BaseCurrency = initData.BaseCurrency; baseCurrencyPrecision = initData.BaseCurrencyPrecision; - OnlineProxy = initData.OnlineProxy; + OnlineDataService = initData.OnlineDataService; entityTypeMap = initData.EntityTypeMap; - db = new XrmDb(initData.Metadata.EntityMetadata, initData.OnlineProxy); + db = new XrmDb(initData.Metadata.EntityMetadata, initData.OnlineDataService); EnsureFileAttachmentMetadata(); FileBlockStore = new FileBlockStore(); snapshots = new Dictionary(); @@ -185,7 +185,7 @@ public static StaticMetadataCache BuildStaticMetadataCache(XrmMockupSettings set var baseCurrency = metadata.BaseOrganization.GetAttributeValue("basecurrencyid"); var baseCurrencyPrecision = metadata.BaseOrganization.GetAttributeValue("pricingdecimalprecision"); - var onlineProxy = BuildOnlineProxy(settings); + var onlineDataService = BuildOnlineDataService(settings); var entityTypeMap = new Dictionary(); // Build entity type map for proxy types if enabled @@ -194,29 +194,27 @@ public static StaticMetadataCache BuildStaticMetadataCache(XrmMockupSettings set BuildEntityTypeMap(settings, entityTypeMap); } - // Note: IPluginMetadata is handled per-instance in the Core constructor + // Note: IPluginMetadata is handled per-instance in the Core constructor // to avoid modifying the shared cache - return new StaticMetadataCache(metadata, workflows, securityRoles, entityTypeMap, - baseCurrency, baseCurrencyPrecision, onlineProxy); + return new StaticMetadataCache(metadata, workflows, securityRoles, entityTypeMap, + baseCurrency, baseCurrencyPrecision, onlineDataService); } - private static OrganizationServiceProxy BuildOnlineProxy(XrmMockupSettings settings) + private static IOnlineDataService BuildOnlineDataService(XrmMockupSettings settings) { + // Allow injection for testing + if (settings.OnlineDataServiceFactory != null) + { + return settings.OnlineDataServiceFactory(); + } + if (settings.OnlineEnvironment.HasValue) { var env = settings.OnlineEnvironment.Value; - var orgHelper = new OrganizationHelper( - new Uri(env.uri), - env.providerType, - env.username, - env.password, - env.domain); - var proxy = orgHelper.GetServiceProxy(); - if (settings.EnableProxyTypes == true) - proxy.EnableProxyTypes(); - return proxy; + return new ProxyOnlineDataService(env.Url, env.ProxyPath); } + return null; } @@ -427,23 +425,17 @@ internal void EnableProxyTypes(Assembly assembly) } } - private OrganizationServiceProxy GetOnlineProxy() + private IOnlineDataService GetOnlineDataService() { - if (OnlineProxy == null && settings.OnlineEnvironment.HasValue) + if (OnlineDataService == null) { - var env = settings.OnlineEnvironment.Value; - var orgHelper = new OrganizationHelper( - new Uri(env.uri), - env.providerType, - env.username, - env.password, - env.domain); - this.OnlineProxy = orgHelper.GetServiceProxy(); - if (settings.EnableProxyTypes == true) - OnlineProxy.EnableProxyTypes(); + if (settings.OnlineEnvironment.HasValue) + { + var env = settings.OnlineEnvironment.Value; + OnlineDataService = new ProxyOnlineDataService(env.Url, env.ProxyPath); + } } - - return OnlineProxy; + return OnlineDataService; } internal IOrganizationService GetWorkflowService() @@ -1121,6 +1113,14 @@ internal void PopulateWith(Entity[] entities) } } + /// + /// Prefills the local database with data from the online service based on the query. + /// + internal void PrefillDBWithOnlineData(QueryExpression query) + { + db.PrefillDBWithOnlineData(query); + } + internal Dictionary> GetPrivilege(Guid principleId) { return security.GetPrincipalPrivilege(principleId); @@ -1356,7 +1356,7 @@ internal void ResetEnvironment() workflowManager.ResetWorkflows(settings.IncludeAllWorkflows); pluginManager.ResetPlugins(); - this.db = new XrmDb(metadata.EntityMetadata, GetOnlineProxy()); + this.db = new XrmDb(metadata.EntityMetadata, OnlineDataService); EnsureFileAttachmentMetadata(); this.RequestHandlers = GetRequestHandlers(db); InitializeDB(); diff --git a/src/XrmMockup365/Database/DeviceIdManager.cs b/src/XrmMockup365/Database/DeviceIdManager.cs deleted file mode 100644 index d3e340ce..00000000 --- a/src/XrmMockup365/Database/DeviceIdManager.cs +++ /dev/null @@ -1,871 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace DG.Tools.XrmMockup -{ - // ==================================================================== - // - // This file is part of the Microsoft Dynamics CRM SDK code samples. - // - // Copyright (C) Microsoft Corporation. All rights reserved. - // - // This source code is intended only as a supplement to Microsoft - // Development Tools and/or on-line documentation. See these other - // materials for detailed information regarding Microsoft code samples. - // - // THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY - // KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE - // IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A - // PARTICULAR PURPOSE. - // - // ==================================================================== - // - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.Diagnostics.CodeAnalysis; - using System.Globalization; - using System.IO; - using System.Net; - using System.Runtime.Serialization; - using System.Security.Cryptography; - using System.ServiceModel.Description; - using System.Text; - using System.Xml; - using System.Xml.Serialization; - - namespace Microsoft.Crm.Services.Utility { - /// - /// Management utility for the Device Id - /// - public static class DeviceIdManager { - #region Fields - private static readonly Random RandomInstance = new Random(); - - public const int MaxDeviceNameLength = 24; - public const int MaxDevicePasswordLength = 24; - #endregion - - #region Constructor - static DeviceIdManager() { - PersistToFile = true; - } - #endregion - - #region Properties - /// - /// Indicates whether the registered device credentials should be persisted to the database - /// - public static bool PersistToFile { get; set; } - - /// - /// Indicates that the credentials should be persisted to the disk if registration fails with DeviceAlreadyExists. - /// - /// - /// If the device already exists, there is a possibility that the credentials are the same as the current credentials that - /// are being registered. This is especially true in automated environments where the same credentials are used continually (to avoid - /// registering spurious device credentials. - /// - public static bool PersistIfDeviceAlreadyExists { get; set; } - #endregion - - #region Methods - /// - /// Loads the device credentials (if they exist). - /// - /// - public static ClientCredentials LoadOrRegisterDevice() { - return LoadOrRegisterDevice(null); - } - - /// - /// Loads the device credentials (if they exist). - /// - /// Device name that should be registered - /// Device password that should be registered - public static ClientCredentials LoadOrRegisterDevice(string deviceName, string devicePassword) { - return LoadOrRegisterDevice(null, deviceName, devicePassword); - } - - /// - /// Loads the device credentials (if they exist). - /// - /// URL for the current token issuer - /// - /// The issuerUri can be retrieved from the IServiceConfiguration interface's CurrentIssuer property. - /// - public static ClientCredentials LoadOrRegisterDevice(Uri issuerUri) { - return LoadOrRegisterDevice(issuerUri, null, null); - } - - /// - /// Loads the device credentials (if they exist). - /// - /// URL for the current token issuer - /// Device name that should be registered - /// Device password that should be registered - /// - /// The issuerUri can be retrieved from the IServiceConfiguration interface's CurrentIssuer property. - /// - public static ClientCredentials LoadOrRegisterDevice(Uri issuerUri, string deviceName, string devicePassword) { - ClientCredentials credentials = LoadDeviceCredentials(issuerUri); - if (null == credentials) { - credentials = RegisterDevice(Guid.NewGuid(), issuerUri, deviceName, devicePassword); - } - - return credentials; - } - - /// - /// Registers the given device with Microsoft account with a random application ID - /// - /// ClientCredentials that were registered - public static ClientCredentials RegisterDevice() { - return RegisterDevice(Guid.NewGuid()); - } - - /// - /// Registers the given device with Microsoft account - /// - /// ID for the application - /// ClientCredentials that were registered - public static ClientCredentials RegisterDevice(Guid applicationId) { - return RegisterDevice(applicationId, (Uri)null); - } - - /// - /// Registers the given device with Microsoft account - /// - /// ID for the application - /// URL for the current token issuer - /// ClientCredentials that were registered - /// - /// The issuerUri can be retrieved from the IServiceConfiguration interface's CurrentIssuer property. - /// - public static ClientCredentials RegisterDevice(Guid applicationId, Uri issuerUri) { - return RegisterDevice(applicationId, issuerUri, null, null); - } - - /// - /// Registers the given device with Microsoft account - /// - /// ID for the application - /// Device name that should be registered - /// Device password that should be registered - /// ClientCredentials that were registered - public static ClientCredentials RegisterDevice(Guid applicationId, string deviceName, string devicePassword) { - return RegisterDevice(applicationId, (Uri)null, deviceName, devicePassword); - } - - /// - /// Registers the given device with Microsoft account - /// - /// ID for the application - /// URL for the current token issuer - /// Device name that should be registered - /// Device password that should be registered - /// ClientCredentials that were registered - /// - /// The issuerUri can be retrieved from the IServiceConfiguration interface's CurrentIssuer property. - /// - public static ClientCredentials RegisterDevice(Guid applicationId, Uri issuerUri, string deviceName, string devicePassword) { - if (string.IsNullOrEmpty(deviceName) && !PersistToFile) { - throw new ArgumentNullException("deviceName", "If PersistToFile is false, then deviceName must be specified."); - } else if (string.IsNullOrEmpty(deviceName) != string.IsNullOrEmpty(devicePassword)) { - throw new ArgumentNullException("deviceName", "Either deviceName/devicePassword should both be specified or they should be null."); - } - - LiveDevice device = GenerateDevice(deviceName, devicePassword); - return RegisterDevice(applicationId, issuerUri, device); - } - - /// - /// Loads the device's credentials from the file system - /// - /// Device Credentials (if set) or null - public static ClientCredentials LoadDeviceCredentials() { - return LoadDeviceCredentials(null); - } - - /// - /// Loads the device's credentials from the file system - /// - /// URL for the current token issuer - /// Device Credentials (if set) or null - /// - /// The issuerUri can be retrieved from the IServiceConfiguration interface's CurrentIssuer property. - /// - public static ClientCredentials LoadDeviceCredentials(Uri issuerUri) { - //If the credentials should not be persisted to a file, then they won't be present on the disk. - if (!PersistToFile) { - return null; - } - - EnvironmentConfiguration environment = DiscoverEnvironmentInternal(issuerUri); - - LiveDevice device = ReadExistingDevice(environment); - if (null == device || null == device.User) { - return null; - } - - return device.User.ToClientCredentials(); - } - - /// - /// Discovers the Microsoft account environment based on the Token Issuer - /// - public static string DiscoverEnvironment(Uri issuerUri) { - return DiscoverEnvironmentInternal(issuerUri).Environment; - } - #endregion - - #region Private Methods - private static EnvironmentConfiguration DiscoverEnvironmentInternal(Uri issuerUri) { - if (null == issuerUri) { - return new EnvironmentConfiguration(EnvironmentType.LiveDeviceID, "login.live.com", null); - } - - Dictionary searchList = new Dictionary(); - searchList.Add(EnvironmentType.LiveDeviceID, "login.live"); - searchList.Add(EnvironmentType.OrgDeviceID, "login.microsoftonline"); - - foreach (KeyValuePair searchPair in searchList) { - if (issuerUri.Host.Length > searchPair.Value.Length && - issuerUri.Host.StartsWith(searchPair.Value, StringComparison.OrdinalIgnoreCase)) { - string environment = issuerUri.Host.Substring(searchPair.Value.Length); - - //Parse out the environment - if ('-' == environment[0]) { - int separatorIndex = environment.IndexOf('.', 1); - if (-1 != separatorIndex) { - environment = environment.Substring(1, separatorIndex - 1); - } else { - environment = null; - } - } else { - environment = null; - } - - return new EnvironmentConfiguration(searchPair.Key, issuerUri.Host, environment); - } - } - - //In all other cases the environment is either not applicable or it is a production system - return new EnvironmentConfiguration(EnvironmentType.LiveDeviceID, issuerUri.Host, null); - } - - private static void Serialize(Stream stream, T value) { - XmlSerializer serializer = new XmlSerializer(typeof(T), string.Empty); - - XmlSerializerNamespaces xmlNamespaces = new XmlSerializerNamespaces(); - xmlNamespaces.Add(string.Empty, string.Empty); - - serializer.Serialize(stream, value, xmlNamespaces); - } - - private static T Deserialize(string operationName, Stream stream) { - //Read the XML into memory so that the data can be used in an exception if necessary - using (StreamReader reader = new StreamReader(stream)) { - return Deserialize(operationName, reader.ReadToEnd()); - } - } - - private static T Deserialize(string operationName, string xml) { - //Attempt to deserialize the data. If deserialization fails, include the XML in the exception that is thrown for further - //investigation - using (StringReader reader = new StringReader(xml)) { - try { - XmlSerializer serializer = new XmlSerializer(typeof(T), string.Empty); - return (T)serializer.Deserialize(reader); - } catch (InvalidOperationException ex) { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, - "Unable to Deserialize XML (Operation = {0}):{1}{2}", operationName, Environment.NewLine, xml), ex); - } - } - } - - private static FileInfo GetDeviceFile(EnvironmentConfiguration environment) { - return new FileInfo(string.Format(CultureInfo.InvariantCulture, LiveIdConstants.FileNameFormat, - environment.Type, - string.IsNullOrEmpty(environment.Environment) ? null : "-" + environment.Environment.ToUpperInvariant())); - } - - private static ClientCredentials RegisterDevice(Guid applicationId, Uri issuerUri, LiveDevice device) { - EnvironmentConfiguration environment = DiscoverEnvironmentInternal(issuerUri); - - DeviceRegistrationRequest request = new DeviceRegistrationRequest(applicationId, device); - - string url = string.Format(CultureInfo.InvariantCulture, LiveIdConstants.RegistrationEndpointUriFormat, - environment.HostName); - - DeviceRegistrationResponse response = ExecuteRegistrationRequest(url, request); - if (!response.IsSuccess) { - bool throwException = true; - if (DeviceRegistrationErrorCode.DeviceAlreadyExists == response.Error.RegistrationErrorCode) { - if (!PersistToFile) { - //If the file is not persisted, the registration will always occur (since the credentials are not - //persisted to the disk. However, the credentials may already exist. To avoid an exception being continually - //processed by the calling user, DeviceAlreadyExists will be ignored if the credentials are not persisted to the disk. - return device.User.ToClientCredentials(); - } else if (PersistIfDeviceAlreadyExists) { - // This flag indicates that the - throwException = false; - } - } - - if (throwException) { - throw new DeviceRegistrationFailedException(response.Error.RegistrationErrorCode, response.ErrorSubCode); - } - } - - if (PersistToFile || PersistIfDeviceAlreadyExists) { - WriteDevice(environment, device); - } - - return device.User.ToClientCredentials(); - } - - private static LiveDevice GenerateDevice(string deviceName, string devicePassword) { - // If the deviceName hasn't been specified, it should be generated using random characters. - DeviceUserName userNameCredentials; - if (string.IsNullOrEmpty(deviceName)) { - userNameCredentials = GenerateDeviceUserName(); - } else { - userNameCredentials = new DeviceUserName() { DeviceName = deviceName, DecryptedPassword = devicePassword }; - } - - return new LiveDevice() { User = userNameCredentials, Version = 1 }; - } - - private static LiveDevice ReadExistingDevice(EnvironmentConfiguration environment) { - //Retrieve the file info - FileInfo file = GetDeviceFile(environment); - if (!file.Exists) { - return null; - } - - using (FileStream stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read)) { - return Deserialize("Loading Device Credentials from Disk", stream); - } - } - - private static void WriteDevice(EnvironmentConfiguration environment, LiveDevice device) { - FileInfo file = GetDeviceFile(environment); - if (!file.Directory.Exists) { - file.Directory.Create(); - } - - using (FileStream stream = file.Open(FileMode.CreateNew, FileAccess.Write, FileShare.None)) { - Serialize(stream, device); - } - } - - private static DeviceRegistrationResponse ExecuteRegistrationRequest(string url, DeviceRegistrationRequest registrationRequest) { - //Create the request that will submit the request to the server - WebRequest request = WebRequest.Create(url); - request.ContentType = "application/soap+xml; charset=UTF-8"; - request.Method = "POST"; - request.Timeout = 180000; - - //Write the envelope to the RequestStream - using (Stream stream = request.GetRequestStream()) { - Serialize(stream, registrationRequest); - } - - // Read the response into an XmlDocument and return that doc - try { - using (WebResponse response = request.GetResponse()) { - using (Stream stream = response.GetResponseStream()) { - return Deserialize("Deserializing Registration Response", stream); - } - } - } catch (WebException ex) { - System.Diagnostics.Trace.TraceError("Microsoft account Device Registration Failed (HTTP Code: {0}): {1}", - ex.Status, ex.Message); - - if (null != ex.Response) { - using (Stream stream = ex.Response.GetResponseStream()) { - return Deserialize("Deserializing Failed Registration Response", stream); - } - } - - throw; - } - } - - private static DeviceUserName GenerateDeviceUserName() { - DeviceUserName userName = new DeviceUserName(); - userName.DeviceName = GenerateRandomString(LiveIdConstants.ValidDeviceNameCharacters, MaxDeviceNameLength); - userName.DecryptedPassword = GenerateRandomString(LiveIdConstants.ValidDevicePasswordCharacters, MaxDevicePasswordLength); - - return userName; - } - - private static string GenerateRandomString(string characterSet, int count) { - //Create an array of the characters that will hold the final list of random characters - char[] value = new char[count]; - - //Convert the character set to an array that can be randomly accessed - char[] set = characterSet.ToCharArray(); - - lock (RandomInstance) { - //Populate the array with random characters from the character set - for (int i = 0; i < count; i++) { - value[i] = set[RandomInstance.Next(0, set.Length)]; - } - } - - return new string(value); - } - #endregion - - #region Private Classes - private enum EnvironmentType { - LiveDeviceID, - OrgDeviceID - } - - private sealed class EnvironmentConfiguration { - public EnvironmentConfiguration(EnvironmentType type, string hostName, string environment) { - if (string.IsNullOrWhiteSpace(hostName)) { - throw new ArgumentNullException("hostName"); - } - - this.Type = type; - this.HostName = hostName; - this.Environment = environment; - } - - #region Properties - public EnvironmentType Type { get; private set; } - - public string HostName { get; private set; } - - public string Environment { get; private set; } - #endregion - } - - private static class LiveIdConstants { - public const string RegistrationEndpointUriFormat = @"https://{0}/ppsecure/DeviceAddCredential.srf"; - - public static readonly string FileNameFormat = Path.Combine( - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LiveDeviceID"), - "{0}{1}.xml"); - - public const string ValidDeviceNameCharacters = "0123456789abcdefghijklmnopqrstuvqxyz"; - - //Consists of the list of characters specified in the documentation - public const string ValidDevicePasswordCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^*()-_=+;,./?`~"; - } - #endregion - } - - #region Public Classes & Enums - /// - /// Indicates an error during registration - /// - public enum DeviceRegistrationErrorCode { - /// - /// Unspecified or Unknown Error occurred - /// - Unknown = 0, - - /// - /// Interface Disabled - /// - InterfaceDisabled = 1, - - /// - /// Invalid Request Format - /// - InvalidRequestFormat = 3, - - /// - /// Unknown Client Version - /// - UnknownClientVersion = 4, - - /// - /// Blank Password - /// - BlankPassword = 6, - - /// - /// Missing Device User Name or Password - /// - MissingDeviceUserNameOrPassword = 7, - - /// - /// Invalid Parameter Syntax - /// - InvalidParameterSyntax = 8, - - /// - /// Invalid Characters are used in the device credentials. - /// - InvalidCharactersInCredentials = 9, - - /// - /// Internal Error - /// - InternalError = 11, - - /// - /// Device Already Exists - /// - DeviceAlreadyExists = 13 - } - - /// - /// Indicates that Device Registration failed - /// - [Serializable] - public sealed class DeviceRegistrationFailedException : Exception { - /// - /// Construct an instance of the DeviceRegistrationFailedException class - /// - public DeviceRegistrationFailedException() - : base() { - } - - /// - /// Construct an instance of the DeviceRegistrationFailedException class - /// - /// Message to pass - public DeviceRegistrationFailedException(string message) - : base(message) { - } - - /// - /// Construct an instance of the DeviceRegistrationFailedException class - /// - /// Message to pass - /// Exception to include - public DeviceRegistrationFailedException(string message, Exception innerException) - : base(message, innerException) { - } - - /// - /// Construct an instance of the DeviceRegistrationFailedException class - /// - /// Error code that occurred - /// Subcode that occurred - public DeviceRegistrationFailedException(DeviceRegistrationErrorCode code, string subCode) - : this(code, subCode, null) { - } - - /// - /// Construct an instance of the DeviceRegistrationFailedException class - /// - /// Error code that occurred - /// Subcode that occurred - /// Inner exception - public DeviceRegistrationFailedException(DeviceRegistrationErrorCode code, string subCode, Exception innerException) - : base(string.Concat(code.ToString(), ": ", subCode), innerException) { - this.RegistrationErrorCode = code; - } - - /// - /// Construct an instance of the DeviceRegistrationFailedException class - /// - /// - /// - private DeviceRegistrationFailedException(SerializationInfo si, StreamingContext sc) - : base(si, sc) { - } - - #region Properties - /// - /// Error code that occurred during registration - /// - public DeviceRegistrationErrorCode RegistrationErrorCode { get; private set; } - #endregion - - #region Methods - public override void GetObjectData(SerializationInfo info, StreamingContext context) { - base.GetObjectData(info, context); - } - #endregion - } - - #region Serialization Classes - #region DeviceRegistrationRequest Class - [EditorBrowsable(EditorBrowsableState.Never)] - [XmlRoot("DeviceAddRequest")] - public sealed class DeviceRegistrationRequest { - #region Constructors - public DeviceRegistrationRequest() { - } - - public DeviceRegistrationRequest(Guid applicationId, LiveDevice device) - : this() { - if (null == device) { - throw new ArgumentNullException("device"); - } - - this.ClientInfo = new DeviceRegistrationClientInfo() { ApplicationId = applicationId, Version = "1.0" }; - this.Authentication = new DeviceRegistrationAuthentication() { - MemberName = device.User.DeviceId, - Password = device.User.DecryptedPassword - }; - } - #endregion - - #region Properties - [XmlElement("ClientInfo")] - public DeviceRegistrationClientInfo ClientInfo { get; set; } - - [XmlElement("Authentication")] - public DeviceRegistrationAuthentication Authentication { get; set; } - #endregion - } - #endregion - - #region DeviceRegistrationClientInfo Class - [EditorBrowsable(EditorBrowsableState.Never)] - [XmlRoot("ClientInfo")] - public sealed class DeviceRegistrationClientInfo { - #region Properties - [XmlAttribute("name")] - public Guid ApplicationId { get; set; } - - [XmlAttribute("version")] - public string Version { get; set; } - #endregion - } - #endregion - - #region DeviceRegistrationAuthentication Class - [EditorBrowsable(EditorBrowsableState.Never)] - [XmlRoot("Authentication")] - public sealed class DeviceRegistrationAuthentication { - #region Properties - [XmlElement("Membername")] - public string MemberName { get; set; } - - [XmlElement("Password")] - public string Password { get; set; } - #endregion - } - #endregion - - #region DeviceRegistrationResponse Class - [EditorBrowsable(EditorBrowsableState.Never)] - [XmlRoot("DeviceAddResponse")] - public sealed class DeviceRegistrationResponse { - #region Properties - [XmlElement("success")] - public bool IsSuccess { get; set; } - - [XmlElement("puid")] - public string Puid { get; set; } - - [XmlElement("Error")] - public DeviceRegistrationResponseError Error { get; set; } - - [XmlElement("ErrorSubcode")] - public string ErrorSubCode { get; set; } - #endregion - } - #endregion - - #region DeviceRegistrationResponse Class - [EditorBrowsable(EditorBrowsableState.Never)] - [XmlRoot("Error")] - public sealed class DeviceRegistrationResponseError { - private string _code; - - #region Properties - [XmlAttribute("Code")] - public string Code { - get { - return this._code; - } - - set { - this._code = value; - - //Parse the error code - if (!string.IsNullOrEmpty(value)) { - //Parse the error code - if (value.StartsWith("dc", StringComparison.Ordinal)) { - int code; - if (int.TryParse(value.Substring(2), NumberStyles.Integer, - CultureInfo.InvariantCulture, out code) && - Enum.IsDefined(typeof(DeviceRegistrationErrorCode), code)) { - this.RegistrationErrorCode = (DeviceRegistrationErrorCode)Enum.ToObject( - typeof(DeviceRegistrationErrorCode), code); - } - } - } - } - } - - [XmlIgnore] - public DeviceRegistrationErrorCode RegistrationErrorCode { get; private set; } - #endregion - } - #endregion - - #region LiveDevice Class - [EditorBrowsable(EditorBrowsableState.Never)] - [XmlRoot("Data")] - public sealed class LiveDevice { - #region Properties - [XmlAttribute("version")] - public int Version { get; set; } - - [XmlElement("User")] - public DeviceUserName User { get; set; } - - [SuppressMessage("Microsoft.Design", "CA1059:MembersShouldNotExposeCertainConcreteTypes", MessageId = "System.Xml.XmlNode", Justification = "This is required for proper XML Serialization")] - [XmlElement("Token")] - public XmlNode Token { get; set; } - - [XmlElement("Expiry")] - public string Expiry { get; set; } - - [XmlElement("ClockSkew")] - public string ClockSkew { get; set; } - #endregion - } - #endregion - - #region DeviceUserName Class - [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class DeviceUserName { - private string _encryptedPassword; - private string _decryptedPassword; - private bool _encryptedValueIsUpdated; - - #region Constants - private const string UserNamePrefix = "11"; - #endregion - - #region Constructors - public DeviceUserName() { - this.UserNameType = "Logical"; - } - #endregion - - #region Properties - [XmlAttribute("username")] - public string DeviceName { get; set; } - - [XmlAttribute("type")] - public string UserNameType { get; set; } - - [XmlElement("Pwd")] - public string EncryptedPassword { - get { - this.ThrowIfNoEncryption(); - - if (!this._encryptedValueIsUpdated) { - this._encryptedPassword = this.Encrypt(this._decryptedPassword); - this._encryptedValueIsUpdated = true; - } - - return this._encryptedPassword; - } - - set { - this.ThrowIfNoEncryption(); - this.UpdateCredentials(value, null); - } - } - - public string DeviceId { - get { - return UserNamePrefix + DeviceName; - } - } - - [XmlIgnore] - public string DecryptedPassword { - get { - return this._decryptedPassword; - } - - set { - this.UpdateCredentials(null, value); - } - } - - private bool IsEncryptionEnabled { - get { - //If the object is not going to be persisted to a file, then the value does not need to be encrypted. This is extra - //overhead and will not function in partial trust. - return DeviceIdManager.PersistToFile; - } - } - #endregion - - #region Methods - public ClientCredentials ToClientCredentials() { - ClientCredentials credentials = new ClientCredentials(); - credentials.UserName.UserName = this.DeviceId; - credentials.UserName.Password = this.DecryptedPassword; - - return credentials; - } - - private void ThrowIfNoEncryption() { - if (!this.IsEncryptionEnabled) { - throw new NotSupportedException("Not supported when DeviceIdManager.UseEncryptionApis is false."); - } - } - - private void UpdateCredentials(string encryptedValue, string decryptedValue) { - bool isValueUpdated = false; - if (string.IsNullOrEmpty(encryptedValue) && string.IsNullOrEmpty(decryptedValue)) { - isValueUpdated = true; - } else if (string.IsNullOrEmpty(encryptedValue)) { - if (this.IsEncryptionEnabled) { - encryptedValue = this.Encrypt(decryptedValue); - isValueUpdated = true; - } else { - encryptedValue = null; - isValueUpdated = false; - } - } else { - this.ThrowIfNoEncryption(); - - decryptedValue = this.Decrypt(encryptedValue); - isValueUpdated = true; - } - - this._encryptedPassword = encryptedValue; - this._decryptedPassword = decryptedValue; - this._encryptedValueIsUpdated = isValueUpdated; - } - - private string Encrypt(string value) { - if (string.IsNullOrEmpty(value)) { - return value; - } - - byte[] encryptedBytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(value), null, DataProtectionScope.CurrentUser); - return Convert.ToBase64String(encryptedBytes); - } - - private string Decrypt(string value) { - if (string.IsNullOrEmpty(value)) { - return value; - } - - byte[] decryptedBytes = ProtectedData.Unprotect(Convert.FromBase64String(value), null, DataProtectionScope.CurrentUser); - if (null == decryptedBytes || 0 == decryptedBytes.Length) { - return null; - } - - return Encoding.UTF8.GetString(decryptedBytes, 0, decryptedBytes.Length); - } - #endregion - } - #endregion - #endregion - #endregion - } - // -} diff --git a/src/XrmMockup365/Database/OrganizationHelper.cs b/src/XrmMockup365/Database/OrganizationHelper.cs deleted file mode 100644 index 3d3c9d96..00000000 --- a/src/XrmMockup365/Database/OrganizationHelper.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Client; -using System; -using System.Collections.Generic; -using System.Configuration; -using System.Text; - -namespace DG.Tools.XrmMockup { - public class OrganizationHelper { - - private Uri _uri; - private AuthenticationProviderType _ap; - private string _userName; - private string _password; - private string _domain; - - public OrganizationHelper() { - _uri = new Uri(GetConnectionString("CrmUri") ?? ""); - _ap = (AuthenticationProviderType)Enum.Parse(typeof(AuthenticationProviderType), - GetConnectionString("CrmAp")); - _userName = GetConnectionString("CrmUsr"); - _password = GetConnectionString("CrmPwd"); - _domain = GetConnectionString("CrmDmn"); - } - - public OrganizationHelper(Uri uri, AuthenticationProviderType ap, string userName, string password, string domain = null) { - _uri = uri; - _ap = ap; - _userName = userName; - _password = password; - _domain = domain; - } - - private string GetConnectionString(string name) { - return ConfigurationManager.ConnectionStrings[name]?.ConnectionString; - } - - public OrganizationServiceProxy GetServiceProxy() { - var proxy = GetServiceProxyInternal(); -#if DATAVERSE_SERVICE_CLIENT - proxy.ServiceConfiguration.CurrentServiceEndpoint.EndpointBehaviors.Add(new ProxyTypesBehavior()); -#else - proxy.ServiceConfiguration.CurrentServiceEndpoint.Behaviors.Add(new ProxyTypesBehavior()); -#endif - proxy.Timeout = new TimeSpan(1, 0, 0); - return proxy; - } - - private OrganizationServiceProxy GetServiceProxyInternal() { - var management = ServiceConfigurationFactory.CreateManagement(_uri); - var ac = management.Authenticate(GetCredentials(management, _ap)); - - switch (_ap) { - case AuthenticationProviderType.ActiveDirectory: - return new OrganizationServiceProxy(management, ac.ClientCredentials); - default: - return new OrganizationServiceProxy(management, ac.SecurityTokenResponse); - } - } - - private AuthenticationCredentials GetCredentials(IServiceManagement service, AuthenticationProviderType endpointType) { - AuthenticationCredentials authCredentials = new AuthenticationCredentials(); - - switch (endpointType) { - case AuthenticationProviderType.ActiveDirectory: - authCredentials.ClientCredentials.Windows.ClientCredential = - new System.Net.NetworkCredential(_userName, _password, _domain); - break; - case AuthenticationProviderType.LiveId: - authCredentials.ClientCredentials.UserName.UserName = _userName; - authCredentials.ClientCredentials.UserName.Password = _password; - authCredentials.SupportingCredentials = new AuthenticationCredentials(); - authCredentials.SupportingCredentials.ClientCredentials = - Microsoft.Crm.Services.Utility.DeviceIdManager.LoadOrRegisterDevice(); - break; - default: // For Federated and OnlineFederated environments. - authCredentials.ClientCredentials.UserName.UserName = _userName; - authCredentials.ClientCredentials.UserName.Password = _password; - // For OnlineFederated single-sign on, you could just use current UserPrincipalName instead of passing user name and password. - // authCredentials.UserPrincipalName = UserPrincipal.Current.UserPrincipalName; // Windows Kerberos - - // The service is configured for User Id authentication, but the user might provide Microsoft - // account credentials. If so, the supporting credentials must contain the device credentials. - if (endpointType == AuthenticationProviderType.OnlineFederation) { - IdentityProvider provider = service.GetIdentityProvider(authCredentials.ClientCredentials.UserName.UserName); - if (provider != null && provider.IdentityProviderType == IdentityProviderType.LiveId) { - authCredentials.SupportingCredentials = new AuthenticationCredentials(); - authCredentials.SupportingCredentials.ClientCredentials = - Microsoft.Crm.Services.Utility.DeviceIdManager.LoadOrRegisterDevice(); - } - } - - break; - } - - return authCredentials; - } - } -} diff --git a/src/XrmMockup365/Database/XrmDb.cs b/src/XrmMockup365/Database/XrmDb.cs index 30055182..546d18fa 100644 --- a/src/XrmMockup365/Database/XrmDb.cs +++ b/src/XrmMockup365/Database/XrmDb.cs @@ -1,28 +1,28 @@ -using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.ServiceModel; using Microsoft.Xrm.Sdk.Metadata; -using Microsoft.Xrm.Sdk.Client; using Microsoft.Xrm.Sdk.Query; using System.Threading; using DG.Tools.XrmMockup.Serialization; using DG.Tools.XrmMockup.Internal; +using DG.Tools.XrmMockup.Online; namespace DG.Tools.XrmMockup.Database { internal class XrmDb { // Using ConcurrentDictionary for thread-safe table access in parallel test scenarios private ConcurrentDictionary TableDict = new ConcurrentDictionary(); - private Dictionary EntityMetadata; - private OrganizationServiceProxy OnlineProxy; + private readonly Dictionary EntityMetadata; + private readonly IOnlineDataService OnlineDataService; private int sequence; - public XrmDb(Dictionary entityMetadata, OrganizationServiceProxy onlineProxy) { + public XrmDb(Dictionary entityMetadata, IOnlineDataService onlineDataService) { this.EntityMetadata = entityMetadata; - this.OnlineProxy = onlineProxy; + this.OnlineDataService = onlineDataService; sequence = 0; } @@ -37,7 +37,7 @@ public DbTable this[string tableName] { } } - public void Add(Entity xrmEntity, bool withReferenceChecks = true) + public void Add(Entity xrmEntity, bool withReferenceChecks = true) { int nextSequence = Interlocked.Increment(ref sequence); var dbEntity = ToDbRow(xrmEntity,nextSequence, withReferenceChecks); @@ -113,9 +113,9 @@ internal void RegisterEntityMetadata(EntityMetadata entityMetadata) internal void PrefillDBWithOnlineData(QueryExpression queryExpr) { - if (OnlineProxy != null) + if (OnlineDataService != null) { - var onlineEntities = OnlineProxy.RetrieveMultiple(queryExpr).Entities; + var onlineEntities = OnlineDataService.RetrieveMultiple(queryExpr).Entities; foreach (var onlineEntity in onlineEntities) { if (this[onlineEntity.LogicalName][onlineEntity.Id] == null) @@ -133,13 +133,15 @@ internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = tru if (reference?.Id != Guid.Empty) { currentDbRow = this[reference.LogicalName][reference.Id]; - if (currentDbRow == null && OnlineProxy != null) + if (currentDbRow == null && OnlineDataService != null) { if (!withReferenceCheck) + { currentDbRow = DbRow.MakeDBRowRef(reference, this); + } else { - var onlineEntity = OnlineProxy.Retrieve(reference.LogicalName, reference.Id, new ColumnSet(true)); + var onlineEntity = OnlineDataService.Retrieve(reference.LogicalName, reference.Id, new ColumnSet(true)); Add(onlineEntity, withReferenceCheck); currentDbRow = this[reference.LogicalName][reference.Id]; } @@ -165,7 +167,7 @@ internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = tru // No identification given for the entity, throw error else { - throw new FaultException($"Missing a form of identification for the desired record in order to retrieve it."); + throw new FaultException("Missing a form of identification for the desired record in order to retrieve it."); } return currentDbRow; @@ -234,8 +236,7 @@ internal bool TryGetDbRow(EntityReference reference, out DbRow dbRow) internal DbRow GetDbRowOrNull(EntityReference reference) { - DbRow row; - if (TryGetDbRow(reference, out row)) + if (TryGetDbRow(reference, out DbRow row)) { return row; } @@ -247,8 +248,7 @@ internal DbRow GetDbRowOrNull(EntityReference reference) internal Entity GetEntityOrNull(EntityReference reference) { - DbRow row; - if (TryGetDbRow(reference, out row)) + if (TryGetDbRow(reference, out DbRow row)) { return row.ToEntity(); } @@ -263,7 +263,7 @@ internal Entity GetEntityOrNull(EntityReference reference) public XrmDb Clone() { var clonedTables = this.TableDict.ToDictionary(x => x.Key, x => x.Value.Clone()); - var clonedDB = new XrmDb(this.EntityMetadata, this.OnlineProxy) + var clonedDB = new XrmDb(this.EntityMetadata, this.OnlineDataService) { TableDict = new ConcurrentDictionary(clonedTables) }; @@ -281,7 +281,7 @@ public DbDTO ToSerializableDTO() public static XrmDb RestoreSerializableDTO(XrmDb current, DbDTO model) { var clonedTables = model.Tables.ToDictionary(x => x.Key, x => DbTable.RestoreSerializableDTO(new DbTable(current.EntityMetadata[x.Key]), x.Value)); - var clonedDB = new XrmDb(current.EntityMetadata, current.OnlineProxy) + var clonedDB = new XrmDb(current.EntityMetadata, current.OnlineDataService) { TableDict = new ConcurrentDictionary(clonedTables) }; diff --git a/src/XrmMockup365/Internal/CoreInitializationData.cs b/src/XrmMockup365/Internal/CoreInitializationData.cs index 4be6b99e..df78bbb9 100644 --- a/src/XrmMockup365/Internal/CoreInitializationData.cs +++ b/src/XrmMockup365/Internal/CoreInitializationData.cs @@ -1,7 +1,7 @@ -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Client; +using Microsoft.Xrm.Sdk; using System; using System.Collections.Generic; +using DG.Tools.XrmMockup.Online; namespace DG.Tools.XrmMockup.Internal { @@ -16,7 +16,7 @@ internal class CoreInitializationData public List SecurityRoles { get; set; } public EntityReference BaseCurrency { get; set; } public int BaseCurrencyPrecision { get; set; } - public OrganizationServiceProxy OnlineProxy { get; set; } + public IOnlineDataService OnlineDataService { get; set; } public Dictionary EntityTypeMap { get; set; } } } diff --git a/src/XrmMockup365/Online/DefaultFileSystemHelper.cs b/src/XrmMockup365/Online/DefaultFileSystemHelper.cs new file mode 100644 index 00000000..d0e2bb99 --- /dev/null +++ b/src/XrmMockup365/Online/DefaultFileSystemHelper.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using System.Reflection; + +namespace DG.Tools.XrmMockup.Online +{ + /// + /// Default implementation of IFileSystemHelper using real file system operations. + /// + internal class DefaultFileSystemHelper : IFileSystemHelper + { + public bool FileExists(string path) => File.Exists(path); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public string GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); + + public string GetExecutingAssemblyLocation() => Assembly.GetExecutingAssembly().Location; + + public string GetAssemblyInformationalVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + return assembly.GetCustomAttribute()?.InformationalVersion; + } + + public string GetUserProfilePath() => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + public string GetParentDirectory(string path) + { + var parent = Directory.GetParent(path); + return parent?.FullName; + } + } +} diff --git a/src/XrmMockup365/Online/IFileSystemHelper.cs b/src/XrmMockup365/Online/IFileSystemHelper.cs new file mode 100644 index 00000000..a683e808 --- /dev/null +++ b/src/XrmMockup365/Online/IFileSystemHelper.cs @@ -0,0 +1,16 @@ +namespace DG.Tools.XrmMockup.Online +{ + /// + /// Abstraction for file system operations to enable testing. + /// + internal interface IFileSystemHelper + { + bool FileExists(string path); + bool DirectoryExists(string path); + string GetEnvironmentVariable(string name); + string GetExecutingAssemblyLocation(); + string GetAssemblyInformationalVersion(); + string GetUserProfilePath(); + string GetParentDirectory(string path); + } +} diff --git a/src/XrmMockup365/Online/IOnlineDataService.cs b/src/XrmMockup365/Online/IOnlineDataService.cs new file mode 100644 index 00000000..21c31a11 --- /dev/null +++ b/src/XrmMockup365/Online/IOnlineDataService.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace DG.Tools.XrmMockup.Online +{ + /// + /// Interface for fetching data from an online Dataverse environment. + /// + internal interface IOnlineDataService : IDisposable + { + /// + /// Retrieves a single entity by ID. + /// + Entity Retrieve(string entityName, Guid id, ColumnSet columnSet); + + /// + /// Retrieves multiple entities using a QueryExpression. + /// + EntityCollection RetrieveMultiple(QueryExpression query); + + /// + /// Gets whether the service is connected. + /// + bool IsConnected { get; } + } +} diff --git a/src/XrmMockup365/Online/ProxyDllFinder.cs b/src/XrmMockup365/Online/ProxyDllFinder.cs new file mode 100644 index 00000000..81c331f9 --- /dev/null +++ b/src/XrmMockup365/Online/ProxyDllFinder.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; + +namespace DG.Tools.XrmMockup.Online +{ + /// + /// Finds the XrmMockup.DataverseProxy.dll using various search strategies. + /// + internal class ProxyDllFinder + { + internal const string ProxyDllName = "XrmMockup.DataverseProxy.dll"; + + private readonly IFileSystemHelper _fileSystem; + + public ProxyDllFinder() : this(new DefaultFileSystemHelper()) + { + } + + public ProxyDllFinder(IFileSystemHelper fileSystem) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + } + + /// + /// Finds the proxy DLL using the search order: + /// 1. Explicit path (if provided) + /// 2. NuGet packages directory (version-specific) + /// 3. Same directory as XrmMockup365.dll + /// 4. tools/net8.0 subdirectory + /// 5. Parent directories (for development builds) + /// + /// Optional explicit path to the proxy DLL. + /// Full path to the proxy DLL. + /// Thrown if the proxy DLL cannot be found. + public string FindProxyDll(string explicitProxyPath = null) + { + // 1. Use explicit path if provided + if (!string.IsNullOrEmpty(explicitProxyPath)) + { + if (!_fileSystem.FileExists(explicitProxyPath)) + throw new FileNotFoundException(string.Format("Proxy DLL not found at: {0}", explicitProxyPath)); + return explicitProxyPath; + } + + // 2. Check NuGet packages directory + var nugetPath = FindInNuGetPackages(); + if (nugetPath != null) + { + return nugetPath; + } + + // 3. Check relative to XrmMockup365.dll (for development) + var assemblyLocation = _fileSystem.GetExecutingAssemblyLocation(); + var assemblyDir = Path.GetDirectoryName(assemblyLocation); + if (assemblyDir != null) + { + // Try dll in same directory + var dllPath = Path.Combine(assemblyDir, ProxyDllName); + if (_fileSystem.FileExists(dllPath)) + { + return dllPath; + } + + // Try in tools/net8.0 subdirectory (NuGet package structure) + var toolsDllPath = Path.Combine(assemblyDir, "tools", "net8.0", ProxyDllName); + if (_fileSystem.FileExists(toolsDllPath)) + { + return toolsDllPath; + } + + // Try in parent directories (for development) + var parentDir = _fileSystem.GetParentDirectory(assemblyDir); + while (parentDir != null) + { + var devPath = Path.Combine(parentDir, "XrmMockup.DataverseProxy", "bin", "Debug", "net8.0", ProxyDllName); + if (_fileSystem.FileExists(devPath)) + { + return devPath; + } + devPath = Path.Combine(parentDir, "XrmMockup.DataverseProxy", "bin", "Release", "net8.0", ProxyDllName); + if (_fileSystem.FileExists(devPath)) + { + return devPath; + } + parentDir = _fileSystem.GetParentDirectory(parentDir); + } + } + + throw new FileNotFoundException( + string.Format("Could not find XrmMockup Dataverse Proxy DLL ({0}). " + + "Configure OnlineEnvironment.proxyPath in XrmMockupSettings to specify the path explicitly.", + ProxyDllName)); + } + + /// + /// Attempts to find the proxy DLL in the NuGet packages directory. + /// Uses the assembly's informational version to locate the version-specific path. + /// + /// Path to the proxy DLL, or null if not found. + internal string FindInNuGetPackages() + { + // Get the version from the current assembly to find matching NuGet package + var informationalVersion = _fileSystem.GetAssemblyInformationalVersion(); + + // Strip any metadata suffix (e.g., "+abc123" or "-preview.1+abc123") + if (!string.IsNullOrEmpty(informationalVersion)) + { + var plusIndex = informationalVersion.IndexOf('+'); + if (plusIndex > 0) + { + informationalVersion = informationalVersion.Substring(0, plusIndex); + } + } + + if (string.IsNullOrEmpty(informationalVersion)) + return null; + + // Check NUGET_PACKAGES environment variable first, then fall back to default location + var nugetPackagesBase = _fileSystem.GetEnvironmentVariable("NUGET_PACKAGES"); + if (string.IsNullOrEmpty(nugetPackagesBase)) + { + var userProfile = _fileSystem.GetUserProfilePath(); + nugetPackagesBase = Path.Combine(userProfile, ".nuget", "packages"); + } + + var versionDir = Path.Combine(nugetPackagesBase, "xrmmockup365", informationalVersion); + if (!_fileSystem.DirectoryExists(versionDir)) + return null; + + var toolsDir = Path.Combine(versionDir, "tools", "net8.0"); + if (!_fileSystem.DirectoryExists(toolsDir)) + return null; + + var dllPath = Path.Combine(toolsDir, ProxyDllName); + if (_fileSystem.FileExists(dllPath)) + return dllPath; + + return null; + } + } +} diff --git a/src/XrmMockup365/Online/ProxyOnlineDataService.cs b/src/XrmMockup365/Online/ProxyOnlineDataService.cs new file mode 100644 index 00000000..0f865a92 --- /dev/null +++ b/src/XrmMockup365/Online/ProxyOnlineDataService.cs @@ -0,0 +1,296 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Text.Json; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using XrmMockup.DataverseProxy.Contracts; + +namespace DG.Tools.XrmMockup.Online +{ + /// + /// Implementation of IOnlineDataService that communicates with an out-of-process + /// DataverseProxy via named pipes. + /// + internal class ProxyOnlineDataService : IOnlineDataService + { + private readonly ProxyProcessManager _processManager; + private NamedPipeClientStream _pipeClient; + private bool _disposed; + + public ProxyOnlineDataService(string environmentUrl, string proxyPath = null) + { + EnvironmentUrl = environmentUrl ?? throw new ArgumentNullException(nameof(environmentUrl)); + _processManager = new ProxyProcessManager(environmentUrl, proxyPath); + } + + public string EnvironmentUrl { get; } + + public bool IsConnected + { + get + { + try + { + EnsureConnected(); + return _pipeClient?.IsConnected == true; + } + catch + { + return false; + } + } + } + + public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) + { + if (entityName == null) throw new ArgumentNullException(nameof(entityName)); + if (columnSet == null) throw new ArgumentNullException(nameof(columnSet)); + + var retrieveRequest = new ProxyRetrieveRequest + { + EntityName = entityName, + Id = id, + Columns = columnSet.AllColumns ? null : columnSet.Columns.ToArray() + }; + + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + Payload = JsonSerializer.Serialize(retrieveRequest) + }; + + var response = SendRequest(request); + + if (!response.Success) + { + throw new InvalidOperationException(string.Format("Retrieve failed: {0}", response.ErrorMessage)); + } + + if (response.SerializedData == null) + { + throw new InvalidOperationException("Retrieve returned no data"); + } + + return EntitySerializationHelper.DeserializeEntity(response.SerializedData); + } + + public EntityCollection RetrieveMultiple(QueryExpression query) + { + if (query == null) throw new ArgumentNullException(nameof(query)); + + var serializedQuery = EntitySerializationHelper.SerializeQueryExpression(query); + var retrieveMultipleRequest = new ProxyRetrieveMultipleRequest + { + SerializedQuery = serializedQuery + }; + + var request = new ProxyRequest + { + RequestType = ProxyRequestType.RetrieveMultiple, + Payload = JsonSerializer.Serialize(retrieveMultipleRequest) + }; + + var response = SendRequest(request); + + if (!response.Success) + { + throw new InvalidOperationException(string.Format("RetrieveMultiple failed: {0}", response.ErrorMessage)); + } + + if (response.SerializedData == null) + { + return new EntityCollection(); + } + + return EntitySerializationHelper.DeserializeEntityCollection(response.SerializedData); + } + + private ProxyResponse SendRequest(ProxyRequest request) + { + EnsureConnected(); + + try + { + // Include authentication token in request + request.AuthToken = _processManager.AuthToken; + + var stream = _pipeClient; + + // Serialize request + var requestBytes = JsonSerializer.SerializeToUtf8Bytes(request); + + // Send message length + message + var lengthBytes = BitConverter.GetBytes(requestBytes.Length); + stream.Write(lengthBytes, 0, 4); + stream.Write(requestBytes, 0, requestBytes.Length); + stream.Flush(); + + // Read response length + var responseLengthBytes = new byte[4]; + var bytesRead = stream.Read(responseLengthBytes, 0, 4); + if (bytesRead < 4) + { + throw new IOException("Failed to read response length"); + } + + var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); + if (responseLength <= 0 || responseLength > 100 * 1024 * 1024) + { + throw new IOException(string.Format("Invalid response length: {0}", responseLength)); + } + + // Read response body + var responseBytes = new byte[responseLength]; + var totalRead = 0; + while (totalRead < responseLength) + { + bytesRead = stream.Read(responseBytes, totalRead, responseLength - totalRead); + if (bytesRead == 0) + break; + totalRead += bytesRead; + } + + if (totalRead < responseLength) + { + throw new IOException("Incomplete response received"); + } + + var response = JsonSerializer.Deserialize(responseBytes); + if (response == null) + { + throw new InvalidOperationException("Failed to deserialize proxy response"); + } + return response; + } + catch (IOException) + { + // Communication error - mark proxy as unhealthy and invalidate connection + _processManager.MarkUnhealthy(); + _pipeClient?.Dispose(); + _pipeClient = null; + throw; + } + } + + private void EnsureConnected() + { + if (_pipeClient?.IsConnected == true) + return; + + // Ensure proxy process is running + _processManager.EnsureRunning(); + + // Dispose any existing client before creating new one + _pipeClient?.Dispose(); + _pipeClient = null; + + // Create client in local variable first, only assign to field on complete success + var newClient = new NamedPipeClientStream( + ".", + _processManager.PipeName, + PipeDirection.InOut, + PipeOptions.None); + + try + { + newClient.Connect(timeout: 30000); // 30 second timeout + + // Send a ping to verify connection + var pingRequest = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = _processManager.AuthToken + }; + var pingBytes = JsonSerializer.SerializeToUtf8Bytes(pingRequest); + var lengthBytes = BitConverter.GetBytes(pingBytes.Length); + + newClient.Write(lengthBytes, 0, 4); + newClient.Write(pingBytes, 0, pingBytes.Length); + newClient.Flush(); + + // Read ping response + var responseLengthBytes = new byte[4]; + var bytesRead = newClient.Read(responseLengthBytes, 0, 4); + if (bytesRead < 4) + { + throw new IOException("Failed to read ping response length"); + } + + var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); + var responseBytes = new byte[responseLength]; + var totalRead = 0; + while (totalRead < responseLength) + { + bytesRead = newClient.Read(responseBytes, totalRead, responseLength - totalRead); + if (bytesRead == 0) + break; + totalRead += bytesRead; + } + + if (totalRead < responseLength) + { + throw new IOException("Incomplete ping response received"); + } + + var response = JsonSerializer.Deserialize(responseBytes); + if (response == null) + { + throw new InvalidOperationException("Failed to deserialize ping response"); + } + if (!response.Success) + { + throw new InvalidOperationException(string.Format("Ping failed: {0}", response.ErrorMessage)); + } + + // Only assign to field on complete success + _pipeClient = newClient; + } + catch + { + // Dispose on any failure to prevent resource leak + newClient.Dispose(); + + // Mark proxy as unhealthy so it gets restarted on next attempt + _processManager.MarkUnhealthy(); + + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + try + { + // Send shutdown request + if (_pipeClient?.IsConnected == true) + { + var shutdownRequest = new ProxyRequest + { + RequestType = ProxyRequestType.Shutdown, + AuthToken = _processManager.AuthToken + }; + var shutdownBytes = JsonSerializer.SerializeToUtf8Bytes(shutdownRequest); + var lengthBytes = BitConverter.GetBytes(shutdownBytes.Length); + + _pipeClient.Write(lengthBytes, 0, 4); + _pipeClient.Write(shutdownBytes, 0, shutdownBytes.Length); + _pipeClient.Flush(); + } + } + catch + { + // Ignore errors during shutdown + } + + _pipeClient?.Dispose(); + _processManager.Dispose(); + } + } +} diff --git a/src/XrmMockup365/Online/ProxyProcessManager.cs b/src/XrmMockup365/Online/ProxyProcessManager.cs new file mode 100644 index 00000000..2b75aee1 --- /dev/null +++ b/src/XrmMockup365/Online/ProxyProcessManager.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Threading; + +namespace DG.Tools.XrmMockup.Online +{ + /// + /// Manages the lifecycle of XrmMockup.DataverseProxy processes. + /// Uses a shared proxy per environment URL to support parallel test execution. + /// + internal class ProxyProcessManager : IDisposable + { + // Static registry of proxy processes by environment URL + private static readonly ConcurrentDictionary _sharedProxies = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private static bool _cleanupRegistered; + private static readonly object _cleanupLock = new object(); + + private static void EnsureCleanupRegistered() + { + if (_cleanupRegistered) + return; + + lock (_cleanupLock) + { + if (_cleanupRegistered) + return; + + // Register cleanup handler for when the process exits + AppDomain.CurrentDomain.ProcessExit += (sender, e) => ShutdownAllProxies(); + + // Also handle domain unload (relevant for test frameworks) + AppDomain.CurrentDomain.DomainUnload += (sender, e) => ShutdownAllProxies(); + + _cleanupRegistered = true; + } + } + + /// + /// Shuts down all running proxy processes. Called automatically on process exit. + /// + public static void ShutdownAllProxies() + { + foreach (var kvp in _sharedProxies) + { + kvp.Value.ForceShutdown(); + } + _sharedProxies.Clear(); + } + + private readonly string _environmentUrl; + private readonly string _explicitProxyPath; + private readonly string _pipeName; + private readonly SharedProxyState _sharedState; + private readonly ProxyDllFinder _dllFinder; + + public ProxyProcessManager(string environmentUrl, string explicitProxyPath = null) + : this(environmentUrl, explicitProxyPath, new ProxyDllFinder()) + { + } + + internal ProxyProcessManager(string environmentUrl, string explicitProxyPath, ProxyDllFinder dllFinder) + { + _environmentUrl = environmentUrl ?? throw new ArgumentNullException(nameof(environmentUrl)); + _explicitProxyPath = explicitProxyPath; + _dllFinder = dllFinder ?? throw new ArgumentNullException(nameof(dllFinder)); + _pipeName = GeneratePipeName(environmentUrl); + + // Ensure cleanup handlers are registered + EnsureCleanupRegistered(); + + // Get or create shared state for this environment URL + _sharedState = _sharedProxies.GetOrAdd(_environmentUrl, _ => new SharedProxyState()); + } + + /// + /// Gets the named pipe name used for communication. + /// + public string PipeName => _pipeName; + + /// + /// Gets the authentication token for the proxy. + /// + public string AuthToken => _sharedState.AuthToken; + + /// + /// Ensures the proxy process is running. + /// Thread-safe for parallel test execution. + /// + public void EnsureRunning() + { + _sharedState.EnsureRunning(_environmentUrl, _pipeName, StartProxyProcess); + } + + /// + /// Marks the proxy as unhealthy, forcing a restart on the next EnsureRunning call. + /// Call this when connection errors indicate the proxy is in a bad state. + /// + public void MarkUnhealthy() + { + _sharedState.MarkUnhealthy(); + } + + private Process StartProxyProcess(string environmentUrl, string pipeName, out string authToken) + { + var proxyPath = _dllFinder.FindProxyDll(_explicitProxyPath); + + // Generate cryptographically secure random token (256 bits = 32 bytes) + var tokenBytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(tokenBytes); + } + authToken = Convert.ToBase64String(tokenBytes); + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = string.Format("\"{0}\" --url \"{1}\" --pipe \"{2}\"", proxyPath, environmentUrl, pipeName), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true + }; + + var process = new Process { StartInfo = startInfo }; + + // Capture stdout and stderr asynchronously to avoid deadlocks and ensure error messages are captured + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (sender, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + process.ErrorDataReceived += (sender, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // Write token to stdin immediately after start (secure - not visible in process listings) + process.StandardInput.WriteLine(authToken); + process.StandardInput.Close(); + + // Wait for proxy to start by polling for pipe availability + var timeout = TimeSpan.FromSeconds(30); + var pollInterval = TimeSpan.FromMilliseconds(100); + var elapsed = TimeSpan.Zero; + + while (elapsed < timeout) + { + if (process.HasExited) + { + throw new InvalidOperationException(FormatProcessError(stderr, stdout)); + } + + try + { + using (var testClient = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.None)) + { + testClient.Connect(500); // 500ms connection timeout + return process; // Pipe is available, proxy is ready + } + } + catch (TimeoutException) + { + // Pipe not ready yet, continue polling + } + catch (IOException) + { + // Pipe not ready yet, continue polling + } + + Thread.Sleep(pollInterval); + elapsed += pollInterval; + } + + // Timeout reached - check one more time if process is still running + if (process.HasExited) + { + throw new InvalidOperationException(FormatProcessError(stderr, stdout)); + } + + throw new TimeoutException("Proxy process did not become available within timeout"); + } + + private static string FormatProcessError(StringBuilder stderr, StringBuilder stdout) + { + var errorText = stderr.ToString().Trim(); + var outputText = stdout.ToString().Trim(); + + if (!string.IsNullOrEmpty(errorText) && !string.IsNullOrEmpty(outputText)) + { + return string.Format("Proxy process exited.\nStderr: {0}\nStdout: {1}", errorText, outputText); + } + if (!string.IsNullOrEmpty(errorText)) + { + return string.Format("Proxy process exited. Error: {0}", errorText); + } + if (!string.IsNullOrEmpty(outputText)) + { + return string.Format("Proxy process exited. Output: {0}", outputText); + } + return "Proxy process exited with no output."; + } + + internal static string GeneratePipeName(string environmentUrl) + { + // Generate a deterministic pipe name based on the environment URL. + // Uses SHA256 to ensure the hash is stable across processes/restarts. + // All XrmMockup instances targeting the same URL share the same proxy process. + using (var sha256 = SHA256.Create()) + { + var bytes = Encoding.UTF8.GetBytes(environmentUrl.ToLowerInvariant()); + var hash = sha256.ComputeHash(bytes); + // Use first 8 bytes as hex (16 chars) for reasonable uniqueness + var hashPrefix = BitConverter.ToString(hash, 0, 8).Replace("-", ""); + return string.Format("XrmMockupProxy_{0}", hashPrefix); + } + } + + public void Dispose() + { + // No-op: Proxy lifecycle is managed at the process level, not per-instance. + // The proxy stays alive for the duration of the test run and is cleaned up + // by ProcessExit/DomainUnload handlers, or restarted if marked unhealthy. + } + } + + /// + /// Delegate for starting proxy process with token output. + /// + internal delegate Process StartProxyDelegate(string environmentUrl, string pipeName, out string authToken); + + /// + /// Thread-safe shared state for a proxy process. + /// Proxy lives for the duration of the test run + /// + internal class SharedProxyState + { + private readonly object _lock = new object(); + private Process _process; + private string _authToken; + private bool _markedUnhealthy; + + /// + /// Gets the authentication token for the proxy. + /// + public string AuthToken + { + get + { + lock (_lock) + { + return _authToken; + } + } + } + + /// + /// Marks the proxy as unhealthy, forcing a restart on next EnsureRunning call. + /// Call this when connection errors indicate the proxy is in a bad state. + /// + public void MarkUnhealthy() + { + lock (_lock) + { + _markedUnhealthy = true; + } + } + + /// + /// Ensures the proxy process is running. Thread-safe. + /// + public void EnsureRunning(string environmentUrl, string pipeName, StartProxyDelegate startProcess) + { + lock (_lock) + { + // Check if process is healthy (running and not marked unhealthy) + if (_process != null && !_process.HasExited && !_markedUnhealthy) + return; + + // Process not running, exited, or marked unhealthy - need to (re)start + if (_process != null) + { + try + { + if (!_process.HasExited) + { + _process.Kill(); + _process.WaitForExit(5000); + } + } + catch { } + + try { _process.Dispose(); } catch { } + _process = null; + } + + _markedUnhealthy = false; + _process = startProcess(environmentUrl, pipeName, out _authToken); + } + } + + /// + /// Shuts down the proxy process. Called during process exit cleanup. + /// + public void ForceShutdown() + { + lock (_lock) + { + if (_process == null) + return; + + try + { + if (!_process.HasExited) + { + _process.Kill(); + _process.WaitForExit(5000); + } + } + catch + { + // Ignore errors during shutdown + } + + try { _process.Dispose(); } catch { } + _process = null; + } + } + } +} diff --git a/src/XrmMockup365/StaticMetadataCache.cs b/src/XrmMockup365/StaticMetadataCache.cs index cb630bd2..7a081a6c 100644 --- a/src/XrmMockup365/StaticMetadataCache.cs +++ b/src/XrmMockup365/StaticMetadataCache.cs @@ -1,7 +1,7 @@ -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Client; +using Microsoft.Xrm.Sdk; using System; using System.Collections.Generic; +using DG.Tools.XrmMockup.Online; namespace DG.Tools.XrmMockup { @@ -13,11 +13,11 @@ public class StaticMetadataCache public Dictionary EntityTypeMap { get; } public EntityReference BaseCurrency { get; } public int BaseCurrencyPrecision { get; } - public OrganizationServiceProxy OnlineProxy { get; } + internal IOnlineDataService OnlineDataService { get; } - public StaticMetadataCache(MetadataSkeleton metadata, List workflows, List securityRoles, - Dictionary entityTypeMap, EntityReference baseCurrency, int baseCurrencyPrecision, - OrganizationServiceProxy onlineProxy) + internal StaticMetadataCache(MetadataSkeleton metadata, List workflows, List securityRoles, + Dictionary entityTypeMap, EntityReference baseCurrency, int baseCurrencyPrecision, + IOnlineDataService onlineDataService) { Metadata = metadata; Workflows = workflows; @@ -25,7 +25,7 @@ public StaticMetadataCache(MetadataSkeleton metadata, List workflows, Li EntityTypeMap = entityTypeMap; BaseCurrency = baseCurrency; BaseCurrencyPrecision = baseCurrencyPrecision; - OnlineProxy = onlineProxy; + OnlineDataService = onlineDataService; } } } diff --git a/src/XrmMockup365/XrmMockup365.csproj b/src/XrmMockup365/XrmMockup365.csproj index a1cddfb6..489de547 100644 --- a/src/XrmMockup365/XrmMockup365.csproj +++ b/src/XrmMockup365/XrmMockup365.csproj @@ -74,5 +74,25 @@ + + + + <_Parameter1>XrmMockup.DataverseProxy.Tests + + + <_Parameter1>XrmMockup365Test + + + + + + + <_DataverseProxyFiles Include="..\XrmMockup.DataverseProxy\bin\$(Configuration)\net8.0\**\*" + Condition="Exists('..\XrmMockup.DataverseProxy\bin\$(Configuration)\net8.0\')" /> + + +
diff --git a/src/XrmMockup365/XrmMockupBase.cs b/src/XrmMockup365/XrmMockupBase.cs index 4551d936..4202d1d5 100644 --- a/src/XrmMockup365/XrmMockupBase.cs +++ b/src/XrmMockup365/XrmMockupBase.cs @@ -285,6 +285,15 @@ public void PopulateWith(params Entity[] entities) { Core.PopulateWith(entities); } + /// + /// Prefills the local database with data from the online service based on the query. + /// Only works when OnlineDataServiceFactory or OnlineEnvironment is configured. + /// + /// The query to execute against the online service. + public void PrefillDBWithOnlineData(QueryExpression query) { + Core.PrefillDBWithOnlineData(query); + } + /// /// Create a new user with a specific businessunit /// diff --git a/src/XrmMockup365/XrmMockupSettings.cs b/src/XrmMockup365/XrmMockupSettings.cs index 69f4d67e..ce990991 100644 --- a/src/XrmMockup365/XrmMockupSettings.cs +++ b/src/XrmMockup365/XrmMockupSettings.cs @@ -1,8 +1,8 @@ -using Microsoft.Xrm.Sdk.Client; -using System; +using System; using System.Collections.Generic; using Microsoft.Xrm.Sdk.Organization; using System.Reflection; +using DG.Tools.XrmMockup.Online; namespace DG.Tools.XrmMockup { @@ -44,7 +44,9 @@ public class XrmMockupSettings public IEnumerable ExceptionFreeRequests { get; set; } /// - /// Environment settings for connection to an online environment for live debugging. + /// Settings for connecting to an online Dataverse environment for live debugging. + /// Uses Azure DefaultAzureCredential for authentication (supports managed identity, + /// Visual Studio credentials, Azure CLI, etc.). /// public Env? OnlineEnvironment { get; set; } @@ -98,15 +100,28 @@ public class XrmMockupSettings /// Default is true. /// public bool EnablePowerFxFields { get; set; } = true; - } + /// + /// Optional factory for creating IOnlineDataService. For testing purposes. + /// If set, this takes precedence over OnlineEnvironment. + /// + internal Func OnlineDataServiceFactory { get; set; } + } + /// + /// Settings for connecting to an online Dataverse environment. + /// public struct Env { - public string uri; - public AuthenticationProviderType providerType; - public string username; - public string password; - public string domain; + /// + /// URL of the Dataverse environment (e.g., https://org.crm.dynamics.com). + /// Uses Azure DefaultAzureCredential for authentication. + /// + public string Url; + + /// + /// Optional path to the proxy DLL. If not specified, auto-discovery is used. + /// + public string ProxyPath; } } \ No newline at end of file diff --git a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs new file mode 100644 index 00000000..792cfb84 --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerPlatform.Dataverse.Client; +using NSubstitute; +using Xunit; + +namespace XrmMockup.DataverseProxy.Tests.Fixtures; + +/// +/// Base class for ProxyServer tests. +/// Provides a mock IOrganizationServiceAsync2 and starts the server on a unique pipe name. +/// +public abstract class ProxyServerTestBase : IAsyncLifetime +{ + protected IOrganizationServiceAsync2 MockOrganizationService { get; private set; } = null!; + protected string PipeName { get; private set; } = null!; + protected string AuthToken { get; } = "test-auth-token-12345"; + protected ProxyServer Server { get; private set; } = null!; + protected CancellationTokenSource ServerCts { get; private set; } = null!; + protected Task ServerTask { get; private set; } = null!; + protected ILogger Logger { get; private set; } = null!; + + public virtual async Task InitializeAsync() + { + // Generate unique pipe name for this test + PipeName = $"XrmMockupTest_{Guid.NewGuid():N}"; + + // Create mock IOrganizationServiceAsync2 + MockOrganizationService = Substitute.For(); + + // Create logger (use NullLogger for quiet tests, or real logger for debugging) + Logger = NullLogger.Instance; + + // Create and start the server using factory pattern + var factory = new TestDataverseServiceFactory(MockOrganizationService); + Server = new ProxyServer(factory, PipeName, AuthToken, Logger); + ServerCts = new CancellationTokenSource(); + ServerTask = Server.RunAsync(ServerCts.Token); + + // Give the server a moment to start accepting connections + await Task.Delay(50); + } + + public virtual async Task DisposeAsync() + { + // Signal server to stop + ServerCts.Cancel(); + + // Wait for server to shut down (with timeout) + try + { + await Task.WhenAny(ServerTask, Task.Delay(2000)); + } + catch (OperationCanceledException) + { + // Expected + } + + ServerCts.Dispose(); + } + + /// + /// Creates a connected TestPipeClient for this test's server. + /// + protected async Task CreateConnectedClientAsync(int timeoutMs = 5000) + { + var client = new TestPipeClient(PipeName); + await client.ConnectAsync(timeoutMs); + return client; + } +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs new file mode 100644 index 00000000..3b7b2e48 --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs @@ -0,0 +1,19 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using XrmMockup.DataverseProxy; + +namespace XrmMockup.DataverseProxy.Tests.Fixtures; + +/// +/// Test factory that wraps a mock IOrganizationServiceAsync2. +/// +public class TestDataverseServiceFactory : IDataverseServiceFactory +{ + private readonly IOrganizationServiceAsync2 _service; + + public TestDataverseServiceFactory(IOrganizationServiceAsync2 service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + public IOrganizationServiceAsync2 CreateService() => _service; +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs new file mode 100644 index 00000000..af6b3430 --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs @@ -0,0 +1,123 @@ +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using XrmMockup.DataverseProxy.Contracts; + +namespace XrmMockup.DataverseProxy.Tests.Fixtures; + +/// +/// Test helper for raw pipe message communication. +/// Allows sending valid/malformed messages for protocol-level testing. +/// +public class TestPipeClient : IDisposable +{ + private readonly NamedPipeClientStream _pipe; + private bool _disposed; + + public TestPipeClient(string pipeName) + { + _pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + } + + public async Task ConnectAsync(int timeoutMs = 5000, CancellationToken cancellationToken = default) + { + await _pipe.ConnectAsync(timeoutMs, cancellationToken); + } + + public bool IsConnected => _pipe.IsConnected; + + /// + /// Sends a properly framed ProxyRequest message. + /// + public async Task SendRequestAsync(ProxyRequest request, CancellationToken cancellationToken = default) + { + var requestBytes = JsonSerializer.SerializeToUtf8Bytes(request); + await SendFramedMessageAsync(requestBytes, cancellationToken); + } + + /// + /// Sends raw bytes with a 4-byte length prefix. + /// + public async Task SendFramedMessageAsync(byte[] messageBytes, CancellationToken cancellationToken = default) + { + var lengthBytes = BitConverter.GetBytes(messageBytes.Length); + await _pipe.WriteAsync(lengthBytes, 0, 4, cancellationToken); + await _pipe.WriteAsync(messageBytes, 0, messageBytes.Length, cancellationToken); + await _pipe.FlushAsync(cancellationToken); + } + + /// + /// Sends a raw 4-byte length prefix without any message body. + /// Useful for testing incomplete message handling. + /// + public async Task SendLengthOnlyAsync(int length, CancellationToken cancellationToken = default) + { + var lengthBytes = BitConverter.GetBytes(length); + await _pipe.WriteAsync(lengthBytes, 0, 4, cancellationToken); + await _pipe.FlushAsync(cancellationToken); + } + + /// + /// Sends raw bytes without any length prefix. + /// + public async Task SendRawBytesAsync(byte[] bytes, CancellationToken cancellationToken = default) + { + await _pipe.WriteAsync(bytes, 0, bytes.Length, cancellationToken); + await _pipe.FlushAsync(cancellationToken); + } + + /// + /// Reads a ProxyResponse from the pipe. + /// + public async Task ReadResponseAsync(CancellationToken cancellationToken = default) + { + // Read length prefix + var lengthBytes = new byte[4]; + var bytesRead = await _pipe.ReadAsync(lengthBytes, 0, 4, cancellationToken); + if (bytesRead < 4) + { + return null; + } + + var messageLength = BitConverter.ToInt32(lengthBytes, 0); + if (messageLength <= 0 || messageLength > 100 * 1024 * 1024) + { + return null; + } + + // Read message body + var messageBytes = new byte[messageLength]; + var totalRead = 0; + while (totalRead < messageLength) + { + bytesRead = await _pipe.ReadAsync(messageBytes, totalRead, messageLength - totalRead, cancellationToken); + if (bytesRead == 0) + break; + totalRead += bytesRead; + } + + if (totalRead < messageLength) + { + return null; + } + + return JsonSerializer.Deserialize(messageBytes); + } + + /// + /// Reads raw bytes from the pipe. + /// + public async Task ReadRawBytesAsync(byte[] buffer, CancellationToken cancellationToken = default) + { + return await _pipe.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _pipe.Dispose(); + } +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs new file mode 100644 index 00000000..0fb5aa89 --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs @@ -0,0 +1,278 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using NSubstitute; +using Xunit; +using XrmMockup.DataverseProxy.Contracts; +using XrmMockup.DataverseProxy.Tests.Fixtures; + +namespace XrmMockup.DataverseProxy.Tests.Integration; + +/// +/// End-to-end integration tests using a mock IOrganizationServiceAsync2 as the backend. +/// Tests the full proxy communication flow. +/// +[Trait("Category", "Integration")] +public class ProxyIntegrationTests : IAsyncLifetime +{ + private IOrganizationServiceAsync2 _mockOrganizationService = null!; + private string _pipeName = null!; + private string _authToken = null!; + private ProxyServer _server = null!; + private CancellationTokenSource _serverCts = null!; + private Task _serverTask = null!; + + public async Task InitializeAsync() + { + _pipeName = $"XrmMockupIntegration_{Guid.NewGuid():N}"; + _authToken = "integration-test-token"; + _mockOrganizationService = Substitute.For(); + var factory = new TestDataverseServiceFactory(_mockOrganizationService); + _server = new ProxyServer(factory, _pipeName, _authToken, NullLogger.Instance); + _serverCts = new CancellationTokenSource(); + _serverTask = _server.RunAsync(_serverCts.Token); + + await Task.Delay(100); // Give server time to start + } + + public async Task DisposeAsync() + { + _serverCts.Cancel(); + try + { + await Task.WhenAny(_serverTask, Task.Delay(2000)); + } + catch (OperationCanceledException) { } + _serverCts.Dispose(); + } + + [Fact] + public async Task Retrieve_ThroughProxy_ReturnsEntity() + { + // Arrange + var entityId = Guid.NewGuid(); + var expectedEntity = new Entity("account", entityId) + { + ["name"] = "Test Account", + ["accountnumber"] = "ACC-001" + }; + + _mockOrganizationService.RetrieveAsync("account", entityId, Arg.Any()) + .Returns(Task.FromResult(expectedEntity)); + + using var client = new TestPipeClient(_pipeName); + await client.ConnectAsync(); + + var retrieveRequest = new ProxyRetrieveRequest + { + EntityName = "account", + Id = entityId, + Columns = new[] { "name", "accountnumber" } + }; + + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + AuthToken = _authToken, + Payload = JsonSerializer.Serialize(retrieveRequest) + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success, $"Expected success but got error: {response.ErrorMessage}"); + Assert.NotNull(response.SerializedData); + + await _mockOrganizationService.Received(1).RetrieveAsync("account", entityId, Arg.Any()); + } + + [Fact] + public async Task RetrieveMultiple_ThroughProxy_ReturnsCollection() + { + // Arrange + var entity1 = new Entity("account", Guid.NewGuid()) { ["name"] = "Account 1" }; + var entity2 = new Entity("account", Guid.NewGuid()) { ["name"] = "Account 2" }; + var expectedCollection = new EntityCollection(new List { entity1, entity2 }); + + _mockOrganizationService.RetrieveMultipleAsync(Arg.Any()) + .Returns(Task.FromResult(expectedCollection)); + + using var client = new TestPipeClient(_pipeName); + await client.ConnectAsync(); + + // Create a QueryExpression and serialize it + var query = new QueryExpression("account") + { + ColumnSet = new ColumnSet("name") + }; + var serializedQuery = EntitySerializationHelper.SerializeQueryExpression(query); + + var retrieveMultipleRequest = new ProxyRetrieveMultipleRequest + { + SerializedQuery = serializedQuery + }; + + var request = new ProxyRequest + { + RequestType = ProxyRequestType.RetrieveMultiple, + AuthToken = _authToken, + Payload = JsonSerializer.Serialize(retrieveMultipleRequest) + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success, $"Expected success but got error: {response.ErrorMessage}"); + Assert.NotNull(response.SerializedData); + + await _mockOrganizationService.Received(1).RetrieveMultipleAsync(Arg.Any()); + } + + [Fact] + public async Task InvalidToken_ThroughProxy_Rejected() + { + // Arrange + using var client = new TestPipeClient(_pipeName); + await client.ConnectAsync(); + + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = "wrong-token" + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Authentication failed", response.ErrorMessage); + } + + [Fact] + public async Task MultipleClients_ConcurrentRequests_AllProcessed() + { + // Arrange + var entityId = Guid.NewGuid(); + var expectedEntity = new Entity("account", entityId) { ["name"] = "Test Account" }; + + _mockOrganizationService.RetrieveAsync("account", entityId, Arg.Any()) + .Returns(Task.FromResult(expectedEntity)); + + var clientCount = 5; + var tasks = new List>(); + + // Act - create multiple clients and send requests concurrently + for (int i = 0; i < clientCount; i++) + { + tasks.Add(Task.Run(async () => + { + using var client = new TestPipeClient(_pipeName); + await client.ConnectAsync(); + + var retrieveRequest = new ProxyRetrieveRequest + { + EntityName = "account", + Id = entityId, + Columns = null // All columns + }; + + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + AuthToken = _authToken, + Payload = JsonSerializer.Serialize(retrieveRequest) + }; + + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + return response?.Success ?? false; + })); + } + + var results = await Task.WhenAll(tasks); + + // Assert - all requests should succeed + Assert.All(results, success => Assert.True(success)); + } + + [Fact] + public async Task Retrieve_ServiceClientThrows_ReturnsErrorResponse() + { + // Arrange + var entityId = Guid.NewGuid(); + _mockOrganizationService.RetrieveAsync("account", entityId, Arg.Any()) + .Returns(x => throw new InvalidOperationException("Service unavailable")); + + using var client = new TestPipeClient(_pipeName); + await client.ConnectAsync(); + + var retrieveRequest = new ProxyRetrieveRequest + { + EntityName = "account", + Id = entityId, + Columns = null + }; + + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + AuthToken = _authToken, + Payload = JsonSerializer.Serialize(retrieveRequest) + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Contains("Service unavailable", response.ErrorMessage); + } + + [Fact] + public async Task FullRoundTrip_PingShutdown() + { + // Arrange + using var client = new TestPipeClient(_pipeName); + await client.ConnectAsync(); + + // Act - send ping + var pingRequest = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = _authToken + }; + await client.SendRequestAsync(pingRequest); + var pingResponse = await client.ReadResponseAsync(); + + // Assert ping + Assert.NotNull(pingResponse); + Assert.True(pingResponse.Success); + + // Act - send shutdown + var shutdownRequest = new ProxyRequest + { + RequestType = ProxyRequestType.Shutdown, + AuthToken = _authToken + }; + await client.SendRequestAsync(shutdownRequest); + var shutdownResponse = await client.ReadResponseAsync(); + + // Assert shutdown + Assert.NotNull(shutdownResponse); + Assert.True(shutdownResponse.Success); + } +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs new file mode 100644 index 00000000..3b41488c --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs @@ -0,0 +1,95 @@ +using Xunit; +using XrmMockup.DataverseProxy.Contracts; +using XrmMockup.DataverseProxy.Tests.Fixtures; + +namespace XrmMockup.DataverseProxy.Tests.Server; + +/// +/// Tests for authentication token validation. +/// +[Trait("Category", "Unit")] +public class AuthenticationTests : ProxyServerTestBase +{ + [Fact] + public async Task ValidToken_ProcessesRequest() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = AuthToken + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success, $"Expected success but got error: {response.ErrorMessage}"); + } + + [Fact] + public async Task InvalidToken_ReturnsAuthFailure() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = "wrong-token" + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Authentication failed", response.ErrorMessage); + } + + [Fact] + public async Task NullToken_ReturnsAuthFailure() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = null + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Authentication failed", response.ErrorMessage); + } + + [Fact] + public async Task EmptyToken_ReturnsAuthFailure() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = "" + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Authentication failed", response.ErrorMessage); + } +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs new file mode 100644 index 00000000..fb8091b9 --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs @@ -0,0 +1,156 @@ +using System.Text; +using System.Text.Json; +using Xunit; +using XrmMockup.DataverseProxy.Contracts; +using XrmMockup.DataverseProxy.Tests.Fixtures; + +namespace XrmMockup.DataverseProxy.Tests.Server; + +/// +/// Tests for the length-prefix message framing protocol. +/// +[Trait("Category", "Unit")] +public class MessageFramingTests : ProxyServerTestBase +{ + [Fact] + public async Task ValidFrame_ProcessedCorrectly() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = AuthToken + }; + var requestBytes = JsonSerializer.SerializeToUtf8Bytes(request); + + // Act - send with proper 4-byte length prefix + await client.SendFramedMessageAsync(requestBytes); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success); + } + + [Fact] + public async Task ZeroLength_HandledGracefully() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + + // Act - send length of 0, server should log warning and continue + await client.SendLengthOnlyAsync(0); + + // Give server time to process + await Task.Delay(100); + + // Send a valid request to verify server is still operational + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = AuthToken + }; + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert - server should still respond to valid request + Assert.NotNull(response); + Assert.True(response.Success); + } + + [Fact] + public async Task NegativeLength_Rejected() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + + // Act - send negative length (-1) + await client.SendRawBytesAsync(BitConverter.GetBytes(-1)); + + // Give server time to process + await Task.Delay(100); + + // Send a valid request to verify server is still operational + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = AuthToken + }; + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert - server should still respond to valid request + Assert.NotNull(response); + Assert.True(response.Success); + } + + [Fact] + public async Task ExcessiveLength_Rejected() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + + // Act - send length > 100MB + var excessiveLength = 101 * 1024 * 1024; + await client.SendLengthOnlyAsync(excessiveLength); + + // Give server time to process + await Task.Delay(100); + + // Send a valid request to verify server is still operational + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = AuthToken + }; + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert - server should still respond to valid request + Assert.NotNull(response); + Assert.True(response.Success); + } + + [Fact] + public async Task IncompleteMessageBody_HandledGracefully() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + + // Act - send length prefix indicating 100 bytes, but only send 10 bytes + var lengthBytes = BitConverter.GetBytes(100); + await client.SendRawBytesAsync(lengthBytes); + await client.SendRawBytesAsync(new byte[10]); // Only 10 bytes instead of 100 + + // Give server time to process the incomplete message + await Task.Delay(200); + + // The connection may be broken at this point, which is acceptable behavior + // The key is that the server doesn't crash + Assert.True(true, "Server handled incomplete message without crashing"); + } + + [Fact] + public async Task MultipleSequentialRequests_AllProcessed() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + + // Act - send multiple requests in sequence + for (int i = 0; i < 5; i++) + { + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = AuthToken + }; + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success, $"Request {i} failed: {response.ErrorMessage}"); + } + } +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs new file mode 100644 index 00000000..d60572d3 --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using Xunit; +using XrmMockup.DataverseProxy.Contracts; +using XrmMockup.DataverseProxy.Tests.Fixtures; + +namespace XrmMockup.DataverseProxy.Tests.Server; + +/// +/// Tests for request processing logic. +/// +[Trait("Category", "Unit")] +public class RequestHandlerTests : ProxyServerTestBase +{ + [Fact] + public async Task Ping_ReturnsSuccess() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = AuthToken + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success); + Assert.Null(response.ErrorMessage); + } + + [Fact] + public async Task Retrieve_EmptyPayload_ReturnsError() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + AuthToken = AuthToken, + Payload = null + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Empty payload", response.ErrorMessage); + } + + [Fact] + public async Task Retrieve_EmptyStringPayload_ReturnsError() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + AuthToken = AuthToken, + Payload = "" + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Empty payload", response.ErrorMessage); + } + + [Fact] + public async Task RetrieveMultiple_EmptyPayload_ReturnsError() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.RetrieveMultiple, + AuthToken = AuthToken, + Payload = null + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Empty payload", response.ErrorMessage); + } + + [Fact] + public async Task RetrieveMultiple_EmptyStringPayload_ReturnsError() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.RetrieveMultiple, + AuthToken = AuthToken, + Payload = "" + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Equal("Empty payload", response.ErrorMessage); + } + + [Fact] + public async Task Shutdown_ReturnsSuccessAndCloses() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Shutdown, + AuthToken = AuthToken + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success); + + // Give server time to close the connection + await Task.Delay(100); + + // Verify the connection is closed by trying to read (should get 0 bytes or exception) + var buffer = new byte[4]; + var bytesRead = await client.ReadRawBytesAsync(buffer); + Assert.Equal(0, bytesRead); + } + + [Fact] + public async Task UnknownRequestType_ReturnsError() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = (ProxyRequestType)99, // Unknown type + AuthToken = AuthToken + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + Assert.Contains("Unknown request type", response.ErrorMessage); + } + + [Fact] + public async Task Retrieve_InvalidPayloadJson_ReturnsError() + { + // Arrange + using var client = await CreateConnectedClientAsync(); + var request = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + AuthToken = AuthToken, + Payload = "not valid json {" + }; + + // Act + await client.SendRequestAsync(request); + var response = await client.ReadResponseAsync(); + + // Assert + Assert.NotNull(response); + Assert.False(response.Success); + // Error message will contain JSON parsing exception details + Assert.NotNull(response.ErrorMessage); + } +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs new file mode 100644 index 00000000..1bd3847d --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs @@ -0,0 +1,205 @@ +using System.Diagnostics; +using System.Reflection; +using Xunit; + +namespace XrmMockup.DataverseProxy.Tests.Startup; + +/// +/// Tests for Program.cs startup and CLI argument handling. +/// These tests verify that the proxy process starts correctly with various arguments. +/// +[Trait("Category", "Startup")] +public class ProgramStartupTests +{ + private static string GetProxyDllPath() + { + // Find the proxy DLL relative to the test assembly + var testAssemblyPath = Assembly.GetExecutingAssembly().Location; + var testDir = Path.GetDirectoryName(testAssemblyPath)!; + + // Navigate from test output to proxy output + // tests/XrmMockup.DataverseProxy.Tests/bin/Debug/net8.0 -> src/XrmMockup.DataverseProxy/bin/Debug/net8.0 + var solutionRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "..")); + var proxyPath = Path.Combine(solutionRoot, "src", "XrmMockup.DataverseProxy", "bin", "Debug", "net8.0", "XrmMockup.DataverseProxy.dll"); + + if (!File.Exists(proxyPath)) + { + // Try Release configuration + proxyPath = Path.Combine(solutionRoot, "src", "XrmMockup.DataverseProxy", "bin", "Release", "net8.0", "XrmMockup.DataverseProxy.dll"); + } + + return proxyPath; + } + + [Fact] + public async Task Startup_WithUrl_PassesUrlToDataverseOptions() + { + // Arrange + var proxyPath = GetProxyDllPath(); + if (!File.Exists(proxyPath)) + { + // Skip if proxy not built + return; + } + + var pipeName = $"XrmMockupStartupTest_{Guid.NewGuid():N}"; + var testUrl = "https://test-org.crm4.dynamics.com"; + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{proxyPath}\" --url \"{testUrl}\" --pipe \"{pipeName}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true + }; + + using var process = new Process { StartInfo = startInfo }; + var stdout = new List(); + var stderr = new List(); + + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.Add(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.Add(e.Data); }; + + // Act + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // Write auth token to stdin + await process.StandardInput.WriteLineAsync("test-auth-token"); + process.StandardInput.Close(); + + // Wait for process to exit (it will fail at authentication, which is expected) + var exited = process.WaitForExit(30000); + if (!exited) + { + process.Kill(); + } + + var allOutput = string.Join("\n", stdout.Concat(stderr)); + + // Assert + // The process should fail at authentication, NOT at "DataverseUrl must be provided" + // This verifies that the URL was correctly passed to DataverseOptions + Assert.DoesNotContain("DataverseUrl must be provided", allOutput); + + // It should show the URL in the startup message (proving the URL was received) + Assert.Contains(testUrl, allOutput); + } + + [Fact] + public async Task Startup_WithoutUrl_AndWithoutMockData_FailsWithMissingUrlError() + { + // Arrange + var proxyPath = GetProxyDllPath(); + if (!File.Exists(proxyPath)) + { + return; + } + + var pipeName = $"XrmMockupStartupTest_{Guid.NewGuid():N}"; + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{proxyPath}\" --pipe \"{pipeName}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true + }; + + using var process = new Process { StartInfo = startInfo }; + var stderr = new List(); + + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.Add(e.Data); }; + + // Act + process.Start(); + process.BeginErrorReadLine(); + + // Write auth token + await process.StandardInput.WriteLineAsync("test-auth-token"); + process.StandardInput.Close(); + + process.WaitForExit(10000); + + var errorOutput = string.Join("\n", stderr); + + // Assert - should fail with clear error about missing URL + Assert.Contains("--url is required", errorOutput); + } + + [Fact] + public async Task Startup_WithMockDataFile_DoesNotRequireUrl() + { + // Arrange + var proxyPath = GetProxyDllPath(); + if (!File.Exists(proxyPath)) + { + return; + } + + var pipeName = $"XrmMockupStartupTest_{Guid.NewGuid():N}"; + var tempFile = Path.GetTempFileName(); + + try + { + // Create minimal mock data file + await File.WriteAllTextAsync(tempFile, "{\"Entities\":[]}"); + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{proxyPath}\" --mock-data-file \"{tempFile}\" --pipe \"{pipeName}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true + }; + + using var process = new Process { StartInfo = startInfo }; + var stdout = new List(); + + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.Add(e.Data); }; + + // Act + process.Start(); + process.BeginOutputReadLine(); + + // Write auth token + await process.StandardInput.WriteLineAsync("test-auth-token"); + process.StandardInput.Close(); + + // Give it time to start + await Task.Delay(2000); + + var isRunning = !process.HasExited; + + // Cleanup + if (!process.HasExited) + { + process.Kill(); + process.WaitForExit(5000); + } + + var output = string.Join("\n", stdout); + + // Assert - should start successfully in mock mode + Assert.True(isRunning || output.Contains("mock mode"), + $"Process should start in mock mode. Output: {output}"); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj b/tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj new file mode 100644 index 00000000..75c660b0 --- /dev/null +++ b/tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + XrmMockup.DataverseProxy.Tests + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/XrmMockup365Test/Online/MockOnlineDataService.cs b/tests/XrmMockup365Test/Online/MockOnlineDataService.cs new file mode 100644 index 00000000..53a0e06f --- /dev/null +++ b/tests/XrmMockup365Test/Online/MockOnlineDataService.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DG.Tools.XrmMockup.Online; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace DG.XrmMockupTest.Online +{ + /// + /// Mock implementation of IOnlineDataService for unit testing XrmMockup's online data integration. + /// Tracks all calls made to verify correct behavior. + /// + internal class MockOnlineDataService : IOnlineDataService + { + private readonly Dictionary<(string LogicalName, Guid Id), Entity> _entities = new Dictionary<(string, Guid), Entity>(); + + /// + /// List of all Retrieve calls made to this service. + /// + public List<(string EntityName, Guid Id, ColumnSet ColumnSet)> RetrieveCalls { get; } = new List<(string, Guid, ColumnSet)>(); + + /// + /// List of all RetrieveMultiple calls made to this service. + /// + public List RetrieveMultipleCalls { get; } = new List(); + + /// + /// Configures the mock to return the specified entity when retrieved. + /// + public void SetupEntity(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + _entities[(entity.LogicalName, entity.Id)] = entity; + } + + /// + /// Configures the mock to return multiple entities. + /// + public void SetupEntities(IEnumerable entities) + { + foreach (var entity in entities) + { + SetupEntity(entity); + } + } + + /// + /// Clears all configured entities. + /// + public void ClearEntities() + { + _entities.Clear(); + } + + /// + /// Clears all recorded calls. + /// + public void ClearCalls() + { + RetrieveCalls.Clear(); + RetrieveMultipleCalls.Clear(); + } + + public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) + { + RetrieveCalls.Add((entityName, id, columnSet)); + + if (_entities.TryGetValue((entityName, id), out var entity)) + { + return CloneEntity(entity, columnSet); + } + + throw new Exception($"Entity {entityName} with id {id} not found in mock online data service"); + } + + public EntityCollection RetrieveMultiple(QueryExpression query) + { + RetrieveMultipleCalls.Add(query); + + var matches = _entities.Values + .Where(e => e.LogicalName == query.EntityName) + .Select(e => CloneEntity(e, query.ColumnSet)) + .ToList(); + + return new EntityCollection(matches) { EntityName = query.EntityName }; + } + + public bool IsConnected => true; + + public void Dispose() + { + // Nothing to dispose + } + + private static Entity CloneEntity(Entity entity, ColumnSet columnSet) + { + var clone = new Entity(entity.LogicalName, entity.Id); + + if (columnSet.AllColumns) + { + foreach (var attr in entity.Attributes) + { + clone[attr.Key] = attr.Value; + } + } + else + { + foreach (var column in columnSet.Columns) + { + if (entity.Contains(column)) + { + clone[column] = entity[column]; + } + } + } + + return clone; + } + } +} diff --git a/tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs b/tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs new file mode 100644 index 00000000..f4d7bf59 --- /dev/null +++ b/tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Linq; +using DG.Tools.XrmMockup; +using DG.XrmFramework.BusinessDomain.ServiceContext; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using Xunit; + +namespace DG.XrmMockupTest.Online +{ + /// + /// Unit tests verifying XrmMockup's integration with IOnlineDataService. + /// Uses mock IOnlineDataService to verify correct behavior without real proxy. + /// Works on both net462 and net8.0 frameworks. + /// + public class OnlineDataServiceUnitTests : IClassFixture + { + private readonly XrmMockupFixture _fixture; + + public OnlineDataServiceUnitTests(XrmMockupFixture fixture) + { + _fixture = fixture; + } + + private (XrmMockup365 crm, MockOnlineDataService mockService) CreateMockupWithOnlineService() + { + var mockService = new MockOnlineDataService(); + var settings = new XrmMockupSettings + { + BasePluginTypes = _fixture.Settings.BasePluginTypes, + BaseCustomApiTypes = _fixture.Settings.BaseCustomApiTypes, + CodeActivityInstanceTypes = _fixture.Settings.CodeActivityInstanceTypes, + EnableProxyTypes = _fixture.Settings.EnableProxyTypes, + IncludeAllWorkflows = _fixture.Settings.IncludeAllWorkflows, + ExceptionFreeRequests = _fixture.Settings.ExceptionFreeRequests, + MetadataDirectoryPath = _fixture.Settings.MetadataDirectoryPath, + IPluginMetadata = _fixture.Settings.IPluginMetadata, + OnlineDataServiceFactory = () => mockService + }; + + var crm = XrmMockup365.GetInstance(settings); + return (crm, mockService); + } + + [Fact] + public void GetDbRow_EntityNotInDb_CallsOnlineServiceRetrieve() + { + // Arrange + var (crm, mockService) = CreateMockupWithOnlineService(); + var service = crm.GetAdminService(); + + var onlineAccountId = Guid.NewGuid(); + var onlineAccount = new Entity(Account.EntityLogicalName, onlineAccountId) + { + ["name"] = "Online Account", + ["accountnumber"] = "ONLINE-001" + }; + mockService.SetupEntity(onlineAccount); + + // Act - Try to retrieve an account that doesn't exist locally + // This should trigger a call to the online service + var retrieved = service.Retrieve(Account.EntityLogicalName, onlineAccountId, new ColumnSet(true)); + + // Assert + Assert.Single(mockService.RetrieveCalls); + Assert.Equal(Account.EntityLogicalName, mockService.RetrieveCalls[0].EntityName); + Assert.Equal(onlineAccountId, mockService.RetrieveCalls[0].Id); + Assert.Equal("Online Account", retrieved.GetAttributeValue("name")); + } + + [Fact] + public void GetDbRow_EntityInDb_DoesNotCallOnlineService() + { + // Arrange + var (crm, mockService) = CreateMockupWithOnlineService(); + var service = crm.GetAdminService(); + + // Create account locally + var localAccount = new Account { Name = "Local Account" }; + localAccount.Id = service.Create(localAccount); + + mockService.ClearCalls(); // Clear any calls from Create + + // Act - Retrieve the locally created account + var retrieved = service.Retrieve(Account.EntityLogicalName, localAccount.Id, new ColumnSet(true)); + + // Assert - No calls to online service since entity exists locally + Assert.Empty(mockService.RetrieveCalls); + Assert.Equal("Local Account", retrieved.GetAttributeValue("name")); + } + + [Fact] + public void GetDbRow_RetrievedEntityAddedToLocalDb() + { + // Arrange + var (crm, mockService) = CreateMockupWithOnlineService(); + var service = crm.GetAdminService(); + + var onlineAccountId = Guid.NewGuid(); + var onlineAccount = new Entity(Account.EntityLogicalName, onlineAccountId) + { + ["name"] = "Online Account", + ["accountnumber"] = "ONLINE-002" + }; + mockService.SetupEntity(onlineAccount); + + // Act - First retrieve fetches from online + service.Retrieve(Account.EntityLogicalName, onlineAccountId, new ColumnSet(true)); + mockService.ClearCalls(); + + // Second retrieve should use local cache + var retrieved = service.Retrieve(Account.EntityLogicalName, onlineAccountId, new ColumnSet(true)); + + // Assert - Second retrieve should not call online service + Assert.Empty(mockService.RetrieveCalls); + Assert.Equal("Online Account", retrieved.GetAttributeValue("name")); + } + + [Fact] + public void PrefillDBWithOnlineData_CallsRetrieveMultiple() + { + // Arrange + var (crm, mockService) = CreateMockupWithOnlineService(); + + var onlineAccount1 = new Entity(Account.EntityLogicalName, Guid.NewGuid()) + { + ["name"] = "Online Account 1" + }; + var onlineAccount2 = new Entity(Account.EntityLogicalName, Guid.NewGuid()) + { + ["name"] = "Online Account 2" + }; + mockService.SetupEntities(new[] { onlineAccount1, onlineAccount2 }); + + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet(true) + }; + + // Act + crm.PrefillDBWithOnlineData(query); + + // Assert + Assert.Single(mockService.RetrieveMultipleCalls); + Assert.Equal(Account.EntityLogicalName, mockService.RetrieveMultipleCalls[0].EntityName); + } + + [Fact] + public void PrefillDBWithOnlineData_AddsEntitiesToLocalDb() + { + // Arrange + var (crm, mockService) = CreateMockupWithOnlineService(); + var service = crm.GetAdminService(); + + var onlineAccount1 = new Entity(Account.EntityLogicalName, Guid.NewGuid()) + { + ["name"] = "Online Account 1" + }; + var onlineAccount2 = new Entity(Account.EntityLogicalName, Guid.NewGuid()) + { + ["name"] = "Online Account 2" + }; + mockService.SetupEntities(new[] { onlineAccount1, onlineAccount2 }); + + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet(true) + }; + + // Act + crm.PrefillDBWithOnlineData(query); + mockService.ClearCalls(); + + // Retrieve entities - should come from local DB, not online + var retrieved1 = service.Retrieve(Account.EntityLogicalName, onlineAccount1.Id, new ColumnSet(true)); + var retrieved2 = service.Retrieve(Account.EntityLogicalName, onlineAccount2.Id, new ColumnSet(true)); + + // Assert - No online calls since entities are now local + Assert.Empty(mockService.RetrieveCalls); + Assert.Equal("Online Account 1", retrieved1.GetAttributeValue("name")); + Assert.Equal("Online Account 2", retrieved2.GetAttributeValue("name")); + } + + [Fact] + public void PrefillDBWithOnlineData_SkipsExistingEntities() + { + // Arrange + var (crm, mockService) = CreateMockupWithOnlineService(); + var service = crm.GetAdminService(); + + // Create a local account first + var localAccount = new Account { Name = "Local Account" }; + localAccount.Id = service.Create(localAccount); + + // Setup online service to return an account with the same ID but different name + var onlineAccount = new Entity(Account.EntityLogicalName, localAccount.Id) + { + ["name"] = "Online Account (should not overwrite)" + }; + mockService.SetupEntity(onlineAccount); + + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet(true) + }; + + // Act + crm.PrefillDBWithOnlineData(query); + + // Retrieve the entity + var retrieved = service.Retrieve(Account.EntityLogicalName, localAccount.Id, new ColumnSet(true)); + + // Assert - Local entity should NOT be overwritten + Assert.Equal("Local Account", retrieved.GetAttributeValue("name")); + } + + [Fact] + public void NoOnlineService_EntityNotFound_ThrowsException() + { + // Arrange - Create mockup without online service + var crm = XrmMockup365.GetInstance(_fixture.Settings); + var service = crm.GetAdminService(); + + var nonExistentId = Guid.NewGuid(); + + // Act & Assert + Assert.Throws(() => + service.Retrieve(Account.EntityLogicalName, nonExistentId, new ColumnSet(true))); + } + } +} diff --git a/tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs b/tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs new file mode 100644 index 00000000..45b44c15 --- /dev/null +++ b/tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs @@ -0,0 +1,129 @@ +using System.Linq; +using Xunit; +using DG.Tools.XrmMockup.Online; + +namespace DG.XrmMockupTest.Online.ProcessManager +{ + /// + /// Tests for deterministic pipe name generation. + /// + [Trait("Category", "Unit")] + public class PipeNameGenerationTests + { + [Fact] + public void SameUrl_ProducesSamePipeName() + { + // Arrange + var url = "https://myorg.crm.dynamics.com"; + + // Act + var name1 = ProxyProcessManager.GeneratePipeName(url); + var name2 = ProxyProcessManager.GeneratePipeName(url); + + // Assert + Assert.Equal(name1, name2); + } + + [Fact] + public void DifferentCasing_ProducesSamePipeName() + { + // Arrange + var url1 = "https://myorg.crm.dynamics.com"; + var url2 = "HTTPS://MYORG.CRM.DYNAMICS.COM"; + var url3 = "Https://MyOrg.Crm.Dynamics.Com"; + + // Act + var name1 = ProxyProcessManager.GeneratePipeName(url1); + var name2 = ProxyProcessManager.GeneratePipeName(url2); + var name3 = ProxyProcessManager.GeneratePipeName(url3); + + // Assert - all should produce the same name (case-insensitive) + Assert.Equal(name1, name2); + Assert.Equal(name1, name3); + } + + [Fact] + public void DifferentUrls_ProduceDifferentNames() + { + // Arrange + var url1 = "https://org1.crm.dynamics.com"; + var url2 = "https://org2.crm.dynamics.com"; + var url3 = "https://org1.crm4.dynamics.com"; // Different region + + // Act + var name1 = ProxyProcessManager.GeneratePipeName(url1); + var name2 = ProxyProcessManager.GeneratePipeName(url2); + var name3 = ProxyProcessManager.GeneratePipeName(url3); + + // Assert + Assert.NotEqual(name1, name2); + Assert.NotEqual(name1, name3); + Assert.NotEqual(name2, name3); + } + + [Fact] + public void PipeName_HasExpectedFormat() + { + // Arrange + var url = "https://myorg.crm.dynamics.com"; + + // Act + var name = ProxyProcessManager.GeneratePipeName(url); + + // Assert - should be "XrmMockupProxy_" + 16 hex chars + Assert.StartsWith("XrmMockupProxy_", name); + Assert.Equal(31, name.Length); // 15 (prefix) + 16 (hex) + + // Verify the hash part is valid hex + var hashPart = name.Substring(15); + Assert.All(hashPart, c => Assert.True( + char.IsDigit(c) || (c >= 'A' && c <= 'F'), + string.Format("Character '{0}' is not a valid hex character", c))); + } + + [Fact] + public void PipeName_IsDeterministic() + { + // Arrange + var url = "https://contoso.crm.dynamics.com"; + + // Act - generate name multiple times + var names = Enumerable.Range(0, 100) + .Select(_ => ProxyProcessManager.GeneratePipeName(url)) + .ToList(); + + // Assert - all should be identical + Assert.All(names, n => Assert.Equal(names[0], n)); + } + + [Fact] + public void PipeName_HandlesSpecialCharacters() + { + // Arrange + var url = "https://my-org_test.crm.dynamics.com/api/data/v9.2?param=value&other=123"; + + // Act + var name = ProxyProcessManager.GeneratePipeName(url); + + // Assert - should still produce valid format + Assert.StartsWith("XrmMockupProxy_", name); + Assert.Equal(31, name.Length); + } + + [Fact] + public void PipeName_HandlesTrailingSlash() + { + // Arrange + var url1 = "https://myorg.crm.dynamics.com"; + var url2 = "https://myorg.crm.dynamics.com/"; + + // Act + var name1 = ProxyProcessManager.GeneratePipeName(url1); + var name2 = ProxyProcessManager.GeneratePipeName(url2); + + // Assert - these will be different (trailing slash is significant) + // This is acceptable behavior - just documenting it + Assert.NotEqual(name1, name2); + } + } +} diff --git a/tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs b/tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs new file mode 100644 index 00000000..fac5b121 --- /dev/null +++ b/tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs @@ -0,0 +1,325 @@ +using System.IO; +using Xunit; +using DG.Tools.XrmMockup.Online; + +namespace DG.XrmMockupTest.Online.ProcessManager +{ + /// + /// Tests for DLL auto-detection logic in ProxyDllFinder. + /// + [Trait("Category", "Unit")] + public class ProxyDllFinderTests + { + private const string ProxyDllName = "XrmMockup.DataverseProxy.dll"; + + [Fact] + public void ExplicitPath_ValidFile_ReturnsPath() + { + // Arrange + var fs = new TestFileSystemHelper(); + var explicitPath = @"C:\custom\path\XrmMockup.DataverseProxy.dll"; + fs.AddFile(explicitPath); + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(explicitPath); + + // Assert + Assert.Equal(explicitPath, result); + } + + [Fact] + public void ExplicitPath_FileNotExists_ThrowsFileNotFoundException() + { + // Arrange + var fs = new TestFileSystemHelper(); + var explicitPath = @"C:\custom\path\XrmMockup.DataverseProxy.dll"; + // Don't add the file + var finder = new ProxyDllFinder(fs); + + // Act & Assert + var ex = Assert.Throws(() => finder.FindProxyDll(explicitPath)); + Assert.Contains(explicitPath, ex.Message); + } + + [Fact] + public void NuGet_FindsVersionSpecificDll() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = "1.2.3"; + fs.UserProfilePath = @"C:\Users\testuser"; + + var expectedPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void NuGet_UsesNuGetPackagesEnvVar() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = "1.2.3"; + fs.SetEnvironmentVariable("NUGET_PACKAGES", @"D:\custom\nuget"); + + var expectedPath = @"D:\custom\nuget\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + fs.AddDirectory(@"D:\custom\nuget\xrmmockup365\1.2.3"); + fs.AddDirectory(@"D:\custom\nuget\xrmmockup365\1.2.3\tools\net8.0"); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void NuGet_FallsBackToUserProfile() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = "1.2.3"; + fs.UserProfilePath = @"C:\Users\testuser"; + // Don't set NUGET_PACKAGES env var + + var expectedPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void NuGet_StripsVersionMetadataSuffix() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = "1.2.3+abc123"; // Version with metadata suffix + fs.UserProfilePath = @"C:\Users\testuser"; + + // Path should use version without the metadata suffix + var expectedPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void NuGet_ReturnsNullIfNoVersion() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = null; // No version + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindInNuGetPackages(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void NuGet_ReturnsNullIfVersionDirMissing() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = "1.2.3"; + fs.UserProfilePath = @"C:\Users\testuser"; + // Don't create the version directory + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindInNuGetPackages(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void AssemblyDir_FindsAdjacentDll() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = null; // Skip NuGet lookup + fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; + + var expectedPath = @"C:\app\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void ToolsSubdir_Searched() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = null; // Skip NuGet lookup + fs.ExecutingAssemblyLocation = @"C:\app\lib\net8.0\XrmMockup365.dll"; + + // Not in same directory, but in tools/net8.0 subdir + var expectedPath = @"C:\app\lib\net8.0\tools\net8.0\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void DevTree_FindsDebugBuild() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = null; // Skip NuGet lookup + fs.ExecutingAssemblyLocation = @"C:\repos\XrmMockup\src\XrmMockup365\bin\Debug\net8.0\XrmMockup365.dll"; + + // Proxy is in sibling project's bin directory + var expectedPath = @"C:\repos\XrmMockup\src\XrmMockup.DataverseProxy\bin\Debug\net8.0\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void DevTree_FindsReleaseBuild() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = null; // Skip NuGet lookup + fs.ExecutingAssemblyLocation = @"C:\repos\XrmMockup\src\XrmMockup365\bin\Release\net8.0\XrmMockup365.dll"; + + // Proxy is in sibling project's bin directory + var expectedPath = @"C:\repos\XrmMockup\src\XrmMockup.DataverseProxy\bin\Release\net8.0\XrmMockup.DataverseProxy.dll"; + fs.AddFile(expectedPath); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void NoProxyFound_ThrowsWithHelpfulMessage() + { + // Arrange + var fs = new TestFileSystemHelper(); + fs.AssemblyInformationalVersion = null; // Skip NuGet lookup + fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; + // Don't add any proxy files + + var finder = new ProxyDllFinder(fs); + + // Act & Assert + var ex = Assert.Throws(() => finder.FindProxyDll()); + Assert.Contains(ProxyDllName, ex.Message); + Assert.Contains("proxyPath", ex.Message); + } + + [Fact] + public void SearchOrder_ExplicitPathTakesPrecedence() + { + // Arrange + var fs = new TestFileSystemHelper(); + var explicitPath = @"C:\custom\XrmMockup.DataverseProxy.dll"; + var nugetPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; + var adjacentPath = @"C:\app\XrmMockup.DataverseProxy.dll"; + + fs.AssemblyInformationalVersion = "1.2.3"; + fs.UserProfilePath = @"C:\Users\testuser"; + fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; + + // Add all possible locations + fs.AddFile(explicitPath); + fs.AddFile(nugetPath); + fs.AddFile(adjacentPath); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(explicitPath); + + // Assert - explicit path should be returned + Assert.Equal(explicitPath, result); + } + + [Fact] + public void SearchOrder_NuGetBeforeAssemblyDir() + { + // Arrange + var fs = new TestFileSystemHelper(); + var nugetPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; + var adjacentPath = @"C:\app\XrmMockup.DataverseProxy.dll"; + + fs.AssemblyInformationalVersion = "1.2.3"; + fs.UserProfilePath = @"C:\Users\testuser"; + fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; + + // Add both locations + fs.AddFile(nugetPath); + fs.AddFile(adjacentPath); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); + fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); + + var finder = new ProxyDllFinder(fs); + + // Act + var result = finder.FindProxyDll(); + + // Assert - NuGet path should be returned (higher priority) + Assert.Equal(nugetPath, result); + } + } +} diff --git a/tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs b/tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs new file mode 100644 index 00000000..9a6a8e08 --- /dev/null +++ b/tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using System.Linq; +using System.Threading; +using Xunit; +using DG.Tools.XrmMockup.Online; + +namespace DG.XrmMockupTest.Online.ProcessManager +{ + /// + /// Tests for SharedProxyState process lifecycle management. + /// + [Trait("Category", "Unit")] + public class SharedProxyStateTests + { + [Fact] + public void EnsureRunning_StartsNewProcess() + { + // Arrange + var state = new SharedProxyState(); + var processStarted = false; + string capturedToken = null; + + Process StartProcess(string url, string pipe, out string token) + { + processStarted = true; + token = "test-token"; + capturedToken = token; + // Return a dummy process (current process) + return Process.GetCurrentProcess(); + } + + // Act + state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); + + // Assert + Assert.True(processStarted); + Assert.Equal("test-token", state.AuthToken); + Assert.Equal("test-token", capturedToken); + } + + [Fact] + public void EnsureRunning_ReusesRunningProcess() + { + // Arrange + var state = new SharedProxyState(); + var startCount = 0; + + Process StartProcess(string url, string pipe, out string token) + { + startCount++; + token = string.Format("token-{0}", startCount); + return Process.GetCurrentProcess(); + } + + // Act - call EnsureRunning twice + state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); + state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); + + // Assert - process should only be started once + Assert.Equal(1, startCount); + Assert.Equal("token-1", state.AuthToken); + } + + [Fact] + public void ConcurrentEnsureRunning_ThreadSafe() + { + // Arrange + var state = new SharedProxyState(); + var startCount = 0; + var lockObj = new object(); + + Process StartProcess(string url, string pipe, out string token) + { + lock (lockObj) + { + startCount++; + } + token = "test-token"; + // Simulate some work + Thread.Sleep(10); + return Process.GetCurrentProcess(); + } + + // Act - start multiple threads calling EnsureRunning concurrently + var threads = Enumerable.Range(0, 10).Select(_ => + { + var t = new Thread(() => + { + state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); + }); + t.Start(); + return t; + }).ToList(); + + foreach (var t in threads) + { + t.Join(); + } + + // Assert - process should only be started once despite concurrent access + Assert.Equal(1, startCount); + } + + [Fact] + public void AuthToken_StoredAndRetrievable() + { + // Arrange + var state = new SharedProxyState(); + var expectedToken = "my-secure-token-12345"; + + Process StartProcess(string url, string pipe, out string token) + { + token = expectedToken; + return Process.GetCurrentProcess(); + } + + // Act + state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); + + // Assert + Assert.Equal(expectedToken, state.AuthToken); + } + + [Fact] + public void AuthToken_NullBeforeProcessStarts() + { + // Arrange + var state = new SharedProxyState(); + + // Act & Assert + Assert.Null(state.AuthToken); + } + } +} diff --git a/tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs b/tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs new file mode 100644 index 00000000..27ac5a1c --- /dev/null +++ b/tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.IO; +using DG.Tools.XrmMockup.Online; + +namespace DG.XrmMockupTest.Online.ProcessManager +{ + /// + /// Mock file system helper for unit testing ProxyDllFinder. + /// Allows control over which files/directories "exist" and environment variables. + /// + public class TestFileSystemHelper : IFileSystemHelper + { + private readonly HashSet _existingFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _existingDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _environmentVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public string ExecutingAssemblyLocation { get; set; } = @"C:\test\XrmMockup365.dll"; + public string AssemblyInformationalVersion { get; set; } = "1.0.0"; + public string UserProfilePath { get; set; } = @"C:\Users\testuser"; + + public void AddFile(string path) + { + _existingFiles.Add(path); + // Also ensure parent directory exists + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + AddDirectory(dir); + } + } + + public void AddDirectory(string path) + { + _existingDirectories.Add(path); + // Also add parent directories + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent) && !_existingDirectories.Contains(parent)) + { + AddDirectory(parent); + } + } + + public void SetEnvironmentVariable(string name, string value) + { + _environmentVariables[name] = value; + } + + public bool FileExists(string path) => _existingFiles.Contains(path); + + public bool DirectoryExists(string path) => _existingDirectories.Contains(path); + + public string GetEnvironmentVariable(string name) + { + string value; + return _environmentVariables.TryGetValue(name, out value) ? value : null; + } + + public string GetExecutingAssemblyLocation() => ExecutingAssemblyLocation; + + public string GetAssemblyInformationalVersion() => AssemblyInformationalVersion; + + public string GetUserProfilePath() => UserProfilePath; + + public string GetParentDirectory(string path) + { + var parent = Path.GetDirectoryName(path); + return parent; + } + } +} diff --git a/tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs b/tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs new file mode 100644 index 00000000..e1f7174c --- /dev/null +++ b/tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs @@ -0,0 +1,456 @@ +using System; +using System.IO; +using System.Text.Json; +using DG.Tools.XrmMockup.Online; +using DG.XrmFramework.BusinessDomain.ServiceContext; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using Xunit; +using XrmMockup.DataverseProxy.Contracts; + +namespace DG.XrmMockupTest.Online +{ + /// + /// Integration tests verifying the actual proxy spin-up and communication. + /// Tests that XrmMockup can locate, start, and communicate with the DataverseProxy. + /// Works on both net462 and net8.0 frameworks (proxy runs via dotnet CLI out-of-process). + /// + public class ProxySpinUpIntegrationTests : IDisposable + { + private readonly string _tempDir; + private readonly string _mockDataFilePath; + + public ProxySpinUpIntegrationTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid())); + Directory.CreateDirectory(_tempDir); + _mockDataFilePath = Path.Combine(_tempDir, "mock-data.json"); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } + catch + { + // Ignore cleanup errors + } + } + + [SkippableFact] + public void ProxyDllFinder_FindsProxyDll() + { + // Arrange + var finder = new ProxyDllFinder(); + + // Act & Assert - Should not throw + // This test verifies that the proxy DLL can be found in the development environment + try + { + var path = finder.FindProxyDll(); + Assert.NotNull(path); + Assert.True(File.Exists(path), string.Format("Proxy DLL should exist at: {0}", path)); + Assert.EndsWith("XrmMockup.DataverseProxy.dll", path); + } + catch (FileNotFoundException ex) + { + // If running in CI or without the proxy built, skip this test + // The proxy DLL may not be available in all build configurations + Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); + } + } + + [Fact] + public void ProxyProcessManager_GeneratesPipeNameDeterministically() + { + // Arrange + var url1 = "https://org1.crm.dynamics.com"; + var url2 = "https://org2.crm.dynamics.com"; + + // Act + var pipeName1a = ProxyProcessManager.GeneratePipeName(url1); + var pipeName1b = ProxyProcessManager.GeneratePipeName(url1); + var pipeName2 = ProxyProcessManager.GeneratePipeName(url2); + + // Assert + Assert.Equal(pipeName1a, pipeName1b); // Same URL should produce same pipe name + Assert.NotEqual(pipeName1a, pipeName2); // Different URLs should produce different pipe names + Assert.StartsWith("XrmMockupProxy_", pipeName1a); + } + + [SkippableFact] + public void ProxyProcessManager_StartsProcess_WithMockData() + { + // Arrange - Create mock data file with a test account + var testAccountId = Guid.NewGuid(); + var testAccount = new Entity(Account.EntityLogicalName, testAccountId); + testAccount["name"] = "Mock Test Account"; + testAccount["accountnumber"] = "MOCK-001"; + + CreateMockDataFile(testAccount); + + // Find the proxy DLL path + var finder = new ProxyDllFinder(); + string proxyPath; + try + { + proxyPath = finder.FindProxyDll(); + } + catch (FileNotFoundException ex) + { + // Skip test if proxy DLL not found + Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); + return; + } + + // Act - Start proxy with mock data + var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); + var authToken = Guid.NewGuid().ToString("N"); + + using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) + { + // Assert - Process should be running + Assert.NotNull(process); + Assert.False(process.HasExited, "Proxy process should still be running"); + + // Cleanup + process.Kill(); + process.WaitForExit(5000); + } + } + + [SkippableFact] + public void ProxyOnlineDataService_ConnectsToProxy_WithMockData() + { + // Arrange - Create mock data file with a test account + var testAccountId = Guid.NewGuid(); + var testAccount = new Entity(Account.EntityLogicalName, testAccountId); + testAccount["name"] = "Proxy Connection Test Account"; + testAccount["accountnumber"] = "PROXY-CONN-001"; + + CreateMockDataFile(testAccount); + + // Find and start proxy + var finder = new ProxyDllFinder(); + string proxyPath; + try + { + proxyPath = finder.FindProxyDll(); + } + catch (FileNotFoundException ex) + { + Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); + return; + } + + var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); + var authToken = Guid.NewGuid().ToString("N"); + + using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) + { + Assert.False(process.HasExited, "Proxy should be running"); + + // Act - Create a direct pipe client to test connection + using (var pipeClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) + { + pipeClient.Connect(10000); + + // Send ping request + var pingRequest = new ProxyRequest + { + RequestType = ProxyRequestType.Ping, + AuthToken = authToken + }; + var pingBytes = JsonSerializer.SerializeToUtf8Bytes(pingRequest); + var lengthBytes = BitConverter.GetBytes(pingBytes.Length); + + pipeClient.Write(lengthBytes, 0, 4); + pipeClient.Write(pingBytes, 0, pingBytes.Length); + pipeClient.Flush(); + + // Read ping response + var responseLengthBytes = new byte[4]; + pipeClient.Read(responseLengthBytes, 0, 4); + var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); + var responseBytes = new byte[responseLength]; + pipeClient.Read(responseBytes, 0, responseLength); + + var response = JsonSerializer.Deserialize(responseBytes); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success, "Ping should succeed"); + } + + // Cleanup + process.Kill(); + process.WaitForExit(5000); + } + } + + [SkippableFact] + public void ProxyOnlineDataService_Retrieve_ReturnsMockData() + { + // Arrange - Create mock data file with a test account + var testAccountId = Guid.NewGuid(); + var testAccount = new Entity(Account.EntityLogicalName, testAccountId); + testAccount["name"] = "Retrieve Test Account"; + testAccount["accountnumber"] = "RETRIEVE-001"; + + CreateMockDataFile(testAccount); + + // Find and start proxy + var finder = new ProxyDllFinder(); + string proxyPath; + try + { + proxyPath = finder.FindProxyDll(); + } + catch (FileNotFoundException ex) + { + Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); + return; + } + + var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); + var authToken = Guid.NewGuid().ToString("N"); + + using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) + { + Assert.False(process.HasExited, "Proxy should be running"); + + // Act - Send retrieve request via pipe + using (var pipeClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) + { + pipeClient.Connect(10000); + + var retrievePayload = new ProxyRetrieveRequest + { + EntityName = Account.EntityLogicalName, + Id = testAccountId, + Columns = null // All columns + }; + + var retrieveRequest = new ProxyRequest + { + RequestType = ProxyRequestType.Retrieve, + AuthToken = authToken, + Payload = JsonSerializer.Serialize(retrievePayload) + }; + + var requestBytes = JsonSerializer.SerializeToUtf8Bytes(retrieveRequest); + var lengthBytes = BitConverter.GetBytes(requestBytes.Length); + + pipeClient.Write(lengthBytes, 0, 4); + pipeClient.Write(requestBytes, 0, requestBytes.Length); + pipeClient.Flush(); + + // Read response + var responseLengthBytes = new byte[4]; + pipeClient.Read(responseLengthBytes, 0, 4); + var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); + var responseBytes = new byte[responseLength]; + var totalRead = 0; + while (totalRead < responseLength) + { + totalRead += pipeClient.Read(responseBytes, totalRead, responseLength - totalRead); + } + + var response = JsonSerializer.Deserialize(responseBytes); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success, response.ErrorMessage); + Assert.NotNull(response.SerializedData); + + var retrievedEntity = EntitySerializationHelper.DeserializeEntity(response.SerializedData); + Assert.Equal(testAccountId, retrievedEntity.Id); + Assert.Equal("Retrieve Test Account", retrievedEntity.GetAttributeValue("name")); + } + + // Cleanup + process.Kill(); + process.WaitForExit(5000); + } + } + + [SkippableFact] + public void ProxyOnlineDataService_RetrieveMultiple_ReturnsMockData() + { + // Arrange - Create mock data file with multiple test accounts + var testAccount1 = new Entity(Account.EntityLogicalName, Guid.NewGuid()); + testAccount1["name"] = "Multi Test Account 1"; + testAccount1["accountnumber"] = "MULTI-001"; + + var testAccount2 = new Entity(Account.EntityLogicalName, Guid.NewGuid()); + testAccount2["name"] = "Multi Test Account 2"; + testAccount2["accountnumber"] = "MULTI-002"; + + CreateMockDataFile(testAccount1, testAccount2); + + // Find and start proxy + var finder = new ProxyDllFinder(); + string proxyPath; + try + { + proxyPath = finder.FindProxyDll(); + } + catch (FileNotFoundException ex) + { + Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); + return; + } + + var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); + var authToken = Guid.NewGuid().ToString("N"); + + using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) + { + Assert.False(process.HasExited, "Proxy should be running"); + + // Act - Send retrieve multiple request via pipe + using (var pipeClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) + { + pipeClient.Connect(10000); + + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet("name", "accountnumber") + }; + + var serializedQuery = EntitySerializationHelper.SerializeQueryExpression(query); + var retrieveMultiplePayload = new ProxyRetrieveMultipleRequest + { + SerializedQuery = serializedQuery + }; + + var retrieveMultipleRequest = new ProxyRequest + { + RequestType = ProxyRequestType.RetrieveMultiple, + AuthToken = authToken, + Payload = JsonSerializer.Serialize(retrieveMultiplePayload) + }; + + var requestBytes = JsonSerializer.SerializeToUtf8Bytes(retrieveMultipleRequest); + var lengthBytes = BitConverter.GetBytes(requestBytes.Length); + + pipeClient.Write(lengthBytes, 0, 4); + pipeClient.Write(requestBytes, 0, requestBytes.Length); + pipeClient.Flush(); + + // Read response + var responseLengthBytes = new byte[4]; + pipeClient.Read(responseLengthBytes, 0, 4); + var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); + var responseBytes = new byte[responseLength]; + var totalRead = 0; + while (totalRead < responseLength) + { + totalRead += pipeClient.Read(responseBytes, totalRead, responseLength - totalRead); + } + + var response = JsonSerializer.Deserialize(responseBytes); + + // Assert + Assert.NotNull(response); + Assert.True(response.Success, response.ErrorMessage); + Assert.NotNull(response.SerializedData); + + var collection = EntitySerializationHelper.DeserializeEntityCollection(response.SerializedData); + Assert.Equal(2, collection.Entities.Count); + } + + // Cleanup + process.Kill(); + process.WaitForExit(5000); + } + } + + private void CreateMockDataFile(params Entity[] entities) + { + var mockData = new MockDataFile + { + Entities = new System.Collections.Generic.List() + }; + + foreach (var entity in entities) + { + mockData.Entities.Add(EntitySerializationHelper.SerializeEntity(entity)); + } + + var json = JsonSerializer.Serialize(mockData); + File.WriteAllText(_mockDataFilePath, json); + } + + private System.Diagnostics.Process StartProxyWithMockData(string proxyPath, string pipeName, string authToken) + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "dotnet", + // Token is passed via stdin for security - not visible in process listings + Arguments = string.Format("\"{0}\" --mock-data-file \"{1}\" --pipe \"{2}\"", proxyPath, _mockDataFilePath, pipeName), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true + }; + + var process = new System.Diagnostics.Process { StartInfo = startInfo }; + process.Start(); + + // Write token to stdin immediately after start (secure - not visible in process listings) + process.StandardInput.WriteLine(authToken); + process.StandardInput.Close(); + + // Wait for proxy to start by polling for pipe availability + var timeout = TimeSpan.FromSeconds(30); + var pollInterval = TimeSpan.FromMilliseconds(100); + var elapsed = TimeSpan.Zero; + + while (elapsed < timeout) + { + if (process.HasExited) + { + var error = process.StandardError.ReadToEnd(); + var output = process.StandardOutput.ReadToEnd(); + throw new InvalidOperationException(string.Format("Proxy process exited. Exit code: {0}, Error: {1}, Output: {2}", process.ExitCode, error, output)); + } + + try + { + using (var testClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) + { + testClient.Connect(500); + return process; // Pipe is available, proxy is ready + } + } + catch (TimeoutException) + { + // Pipe not ready yet, continue polling + } + catch (IOException) + { + // Pipe not ready yet, continue polling + } + + System.Threading.Thread.Sleep(pollInterval); + elapsed += pollInterval; + } + + // Timeout reached + if (process.HasExited) + { + var error = process.StandardError.ReadToEnd(); + throw new InvalidOperationException(string.Format("Proxy process exited. Error: {0}", error)); + } + + throw new TimeoutException("Proxy process did not become available within timeout"); + } + } +} diff --git a/tests/XrmMockup365Test/TestSettings.cs b/tests/XrmMockup365Test/TestSettings.cs index 01e15fa5..e3f513e1 100644 --- a/tests/XrmMockup365Test/TestSettings.cs +++ b/tests/XrmMockup365Test/TestSettings.cs @@ -1,6 +1,5 @@ using System; using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Query; using DG.XrmFramework.BusinessDomain.ServiceContext; using Xunit; using Xunit.Sdk; @@ -32,34 +31,5 @@ public void TestNoExceptionRequest() orgAdminUIService.Execute(req); } } - - [Fact(Skip = "Using real data")] - public void TestRealDataRetrieve() - { - var acc = new Account(new Guid("9155CF31-BA6A-E611-80E0-C4346BAC0E68")) - { - Name = "babuasd" - }; - orgRealDataService.Update(acc); - var retrieved = orgRealDataService.Retrieve(Account.EntityLogicalName, acc.Id, new ColumnSet(true)).ToEntity(); - Assert.Equal(acc.Name, retrieved.Name); - Assert.Equal("12321123312", retrieved.AccountNumber); - } - - [Fact(Skip = "Using real data")] - public void TestRealDataRetrieveMultiple() - { - var query = new QueryExpression(Account.EntityLogicalName) - { - ColumnSet = new ColumnSet(true), - PageInfo = new PagingInfo() - { - Count = 1000, - PageNumber = 1 - } - }; - var res = orgRealDataService.RetrieveMultiple(query); - Assert.True(res.Entities.Count > 0); - } } } diff --git a/tests/XrmMockup365Test/UnitTestBase.cs b/tests/XrmMockup365Test/UnitTestBase.cs index 3f7aeaab..00ce43a7 100644 --- a/tests/XrmMockup365Test/UnitTestBase.cs +++ b/tests/XrmMockup365Test/UnitTestBase.cs @@ -18,7 +18,6 @@ public abstract class ServiceWrapper public IOrganizationServiceAsync2 orgAdminUIService { get; protected set; } public IOrganizationServiceAsync2 orgAdminService { get; protected set; } public IOrganizationServiceAsync2 orgGodService { get; protected set; } - public IOrganizationServiceAsync2 orgRealDataService { get; protected set; } public IOrganizationServiceAsync2 testUser1Service { get; protected set; } public IOrganizationServiceAsync2 testUser2Service { get; protected set; } @@ -28,7 +27,6 @@ public abstract class ServiceWrapper public IOrganizationService orgAdminUIService { get; protected set; } public IOrganizationService orgAdminService { get; protected set; } public IOrganizationService orgGodService { get; protected set; } - public IOrganizationService orgRealDataService { get; protected set; } public IOrganizationService testUser1Service { get; protected set; } public IOrganizationService testUser2Service { get; protected set; } @@ -97,8 +95,6 @@ public UnitTestBase(XrmMockupFixture fixture) orgAdminUIService = crm.GetAdminService(new MockupServiceSettings(true, false, MockupServiceSettings.Role.UI)); orgGodService = crm.GetAdminService(new MockupServiceSettings(false, true, MockupServiceSettings.Role.SDK)); orgAdminService = crm.GetAdminService(); - // Skip real data service - it causes online connection issues and isn't needed for most tests - orgRealDataService = null; //create an admin user to run our impersonating user plugins as var adminUser = new Entity("systemuser") { Id = Guid.Parse("3b961284-cd7a-4fa3-af7e-89802e88dd5c") }; diff --git a/tests/XrmMockup365Test/XrmMockup365Test.csproj b/tests/XrmMockup365Test/XrmMockup365Test.csproj index 9d4903e9..04127f2e 100644 --- a/tests/XrmMockup365Test/XrmMockup365Test.csproj +++ b/tests/XrmMockup365Test/XrmMockup365Test.csproj @@ -43,17 +43,19 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/XrmMockup365Test/XrmmockupFixture.cs b/tests/XrmMockup365Test/XrmmockupFixture.cs index 7b94cad4..322427e4 100644 --- a/tests/XrmMockup365Test/XrmmockupFixture.cs +++ b/tests/XrmMockup365Test/XrmmockupFixture.cs @@ -2,20 +2,17 @@ using DG.Tools.XrmMockup; using Microsoft.Xrm.Sdk; using XrmPluginCore; -using Microsoft.Xrm.Sdk.Client; using System; using TestPluginAssembly365.Plugins.LegacyDaxif; using TestPluginAssembly365.Plugins.ServiceBased; public class XrmMockupFixture : IDisposable { - // Shared settings instances to ensure metadata cache hits + // Shared settings instance to ensure metadata cache hits private static XrmMockupSettings _sharedSettings; - private static XrmMockupSettings _sharedRealDataSettings; private static readonly object _settingsLock = new object(); - + public XrmMockupSettings Settings => _sharedSettings; - public XrmMockupSettings RealDataSettings => _sharedRealDataSettings; public XrmMockupFixture() { @@ -34,31 +31,6 @@ public XrmMockupFixture() MetadataDirectoryPath = GetMetadataPath(), IPluginMetadata = metaPlugins }; - - try - { - _sharedRealDataSettings = new XrmMockupSettings - { - BasePluginTypes = _sharedSettings.BasePluginTypes, - CodeActivityInstanceTypes = _sharedSettings.CodeActivityInstanceTypes, - EnableProxyTypes = _sharedSettings.EnableProxyTypes, - IncludeAllWorkflows = _sharedSettings.IncludeAllWorkflows, - ExceptionFreeRequests = _sharedSettings.ExceptionFreeRequests, - MetadataDirectoryPath = GetMetadataPath(), - OnlineEnvironment = new Env - { - providerType = AuthenticationProviderType.OnlineFederation, - uri = "https://exampleURL/XRMServices/2011/Organization.svc", - username = "exampleUser", - password = "examplePass" - } - }; - } - catch - { - // ignore - set to null - _sharedRealDataSettings = null; - } } } } diff --git a/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs b/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs index bb8b88c5..0824f350 100644 --- a/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs +++ b/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs @@ -1,7 +1,6 @@ using DG.Some.Namespace; using DG.Tools.XrmMockup; using XrmPluginCore; -using Microsoft.Xrm.Sdk.Client; using System; public class XrmMockupFixtureNoProxyTypes : IDisposable @@ -43,10 +42,7 @@ public XrmMockupFixtureNoProxyTypes() MetadataDirectoryPath = GetMetadataPath(), OnlineEnvironment = new Env { - providerType = AuthenticationProviderType.OnlineFederation, - uri = "https://exampleURL/XRMServices/2011/Organization.svc", - username = "exampleUser", - password = "examplePass" + Url = "https://example.crm.dynamics.com" } }; } From 9c6799316e8831d2b0fd87dab6b6b669a67f499e Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Fri, 30 Jan 2026 13:11:36 +0100 Subject: [PATCH 2/5] Remove DataverseProxy and switch to direct online access Removed all out-of-process DataverseProxy infrastructure, including server, client, contracts, and related tests. Online data fetching now uses direct connection via DataverseConnection and ServiceClient, with no proxy process or named pipe communication. Cleaned up all proxy-specific code, settings, and abstractions. Updated project dependencies and documentation accordingly. --- XrmMockup.slnx | 5 +- scripts/Pack-Local.ps1 | 8 +- .../EntitySerializationHelper.cs | 192 -------- .../MockDataFile.cs | 19 - .../ProxyContracts.cs | 95 ---- ...mMockup.DataverseProxy.Contracts.projitems | 16 - .../XrmMockup.DataverseProxy.Contracts.shproj | 13 - .../DataverseServiceFactory.cs | 18 - .../IDataverseServiceFactory.cs | 15 - .../MockDataService.cs | 149 ------ .../MockDataServiceFactory.cs | 30 -- src/XrmMockup.DataverseProxy/Program.cs | 130 ----- src/XrmMockup.DataverseProxy/ProxyServer.cs | 278 ----------- .../XrmMockup.DataverseProxy.csproj | 33 -- src/XrmMockup365/Core.cs | 45 +- src/XrmMockup365/Database/XrmDb.cs | 23 + src/XrmMockup365/FormulaFieldEvaluator.cs | 3 +- .../Internal/CoreInitializationData.cs | 4 + .../Online/DefaultFileSystemHelper.cs | 34 -- src/XrmMockup365/Online/IFileSystemHelper.cs | 16 - src/XrmMockup365/Online/IOnlineDataService.cs | 2 + src/XrmMockup365/Online/OnlineDataService.cs | 63 +++ src/XrmMockup365/Online/ProxyDllFinder.cs | 141 ------ .../Online/ProxyOnlineDataService.cs | 296 ------------ .../Online/ProxyProcessManager.cs | 337 ------------- .../RetrieveMultipleRequestHandler.cs | 2 + src/XrmMockup365/StaticMetadataCache.cs | 15 + src/XrmMockup365/XrmMockup.cs | 15 +- src/XrmMockup365/XrmMockup365.csproj | 18 +- src/XrmMockup365/XrmMockupBase.cs | 2 + src/XrmMockup365/XrmMockupSettings.cs | 17 +- .../Fixtures/ProxyServerTestBase.cs | 71 --- .../Fixtures/TestDataverseServiceFactory.cs | 19 - .../Fixtures/TestPipeClient.cs | 123 ----- .../Integration/ProxyIntegrationTests.cs | 278 ----------- .../Server/AuthenticationTests.cs | 95 ---- .../Server/MessageFramingTests.cs | 156 ------ .../Server/RequestHandlerTests.cs | 194 -------- .../Startup/ProgramStartupTests.cs | 205 -------- .../XrmMockup.DataverseProxy.Tests.csproj | 27 -- .../Online/MockOnlineDataService.cs | 2 + .../Online/OnlineDataServiceUnitTests.cs | 4 +- .../ProcessManager/PipeNameGenerationTests.cs | 129 ----- .../ProcessManager/ProxyDllFinderTests.cs | 325 ------------- .../ProcessManager/SharedProxyStateTests.cs | 134 ----- .../ProcessManager/TestFileSystemHelper.cs | 71 --- .../Online/ProxySpinUpIntegrationTests.cs | 456 ------------------ .../UnitTestBaseNoProxyTypes.cs | 7 +- tests/XrmMockup365Test/UnitTestBaseNoReset.cs | 7 +- .../XrmmockupFixtureNoProxyTypes.cs | 24 - 50 files changed, 182 insertions(+), 4179 deletions(-) delete mode 100644 src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs delete mode 100644 src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs delete mode 100644 src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs delete mode 100644 src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems delete mode 100644 src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj delete mode 100644 src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs delete mode 100644 src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs delete mode 100644 src/XrmMockup.DataverseProxy/MockDataService.cs delete mode 100644 src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs delete mode 100644 src/XrmMockup.DataverseProxy/Program.cs delete mode 100644 src/XrmMockup.DataverseProxy/ProxyServer.cs delete mode 100644 src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj delete mode 100644 src/XrmMockup365/Online/DefaultFileSystemHelper.cs delete mode 100644 src/XrmMockup365/Online/IFileSystemHelper.cs create mode 100644 src/XrmMockup365/Online/OnlineDataService.cs delete mode 100644 src/XrmMockup365/Online/ProxyDllFinder.cs delete mode 100644 src/XrmMockup365/Online/ProxyOnlineDataService.cs delete mode 100644 src/XrmMockup365/Online/ProxyProcessManager.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs delete mode 100644 tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj delete mode 100644 tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs delete mode 100644 tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs delete mode 100644 tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs delete mode 100644 tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs delete mode 100644 tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs diff --git a/XrmMockup.slnx b/XrmMockup.slnx index fcb37276..d88d98c5 100644 --- a/XrmMockup.slnx +++ b/XrmMockup.slnx @@ -25,6 +25,8 @@ + + @@ -33,12 +35,9 @@ - - - diff --git a/scripts/Pack-Local.ps1 b/scripts/Pack-Local.ps1 index 7ebaa44d..4f336378 100644 --- a/scripts/Pack-Local.ps1 +++ b/scripts/Pack-Local.ps1 @@ -1,3 +1,7 @@ +param( + [string]$Output = "./nupkg" +) + # Local pack script for XrmMockup packages # Sets versions from changelogs and creates NuGet packages locally @@ -13,5 +17,5 @@ dotnet build --configuration Release # Pack specific projects (not the entire solution to avoid legacy project errors) -dotnet pack ./src/XrmMockup365/XrmMockup365.csproj --configuration Release --no-build --output ./nupkg -dotnet pack ./src/MetadataGen/MetadataGenerator.Tool/MetadataGenerator.Tool.csproj --configuration Release --no-build --output ./nupkg +dotnet pack ./src/XrmMockup365/XrmMockup365.csproj --configuration Release --no-build --output $Output +dotnet pack ./src/MetadataGen/MetadataGenerator.Tool/MetadataGenerator.Tool.csproj --configuration Release --no-build --output $Output diff --git a/src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs b/src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs deleted file mode 100644 index 8b79d425..00000000 --- a/src/XrmMockup.DataverseProxy.Contracts/EntitySerializationHelper.cs +++ /dev/null @@ -1,192 +0,0 @@ -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP -#nullable enable -#endif -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.Serialization; -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Query; - -namespace XrmMockup.DataverseProxy.Contracts -{ - /// - /// Helper class for serializing and deserializing CRM SDK types - /// using DataContractSerializer. - /// - internal static class EntitySerializationHelper - { - private static readonly DataContractSerializer EntitySerializer = new DataContractSerializer( - typeof(Entity), - GetKnownTypes()); - - private static readonly DataContractSerializer EntityCollectionSerializer = new DataContractSerializer( - typeof(EntityCollection), - GetKnownTypes()); - - private static readonly DataContractSerializer QueryExpressionSerializer = new DataContractSerializer( - typeof(QueryExpression), - GetKnownTypes()); - - private static IEnumerable GetKnownTypes() - { - return new[] - { - typeof(Entity), - typeof(EntityCollection), - typeof(EntityReference), - typeof(OptionSetValue), - typeof(Money), - typeof(AliasedValue), - typeof(QueryExpression), - typeof(ColumnSet), - typeof(ConditionExpression), - typeof(FilterExpression), - typeof(LinkEntity), - typeof(OrderExpression), - typeof(PagingInfo) - }; - } - - /// - /// Serializes an Entity to a byte array. - /// Converts derived types (early-bound entities) to base Entity to ensure serialization works. - /// - public static byte[] SerializeEntity(Entity entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - // Convert to base Entity type to ensure serialization works for early-bound types - var baseEntity = ToBaseEntity(entity); - - using (var ms = new MemoryStream()) - { - EntitySerializer.WriteObject(ms, baseEntity); - return ms.ToArray(); - } - } - - /// - /// Converts an entity (potentially early-bound) to base Entity type.
- /// We need to do this because DataContractSerializer has issues with derived types. - ///
- private static Entity ToBaseEntity(Entity entity) - { - if (entity.GetType() == typeof(Entity)) - return entity; - - var baseEntity = new Entity(entity.LogicalName, entity.Id); - - if (entity.Attributes != null && entity.Attributes.Count > 0) - baseEntity.Attributes.AddRange(entity.Attributes); - - if (entity.FormattedValues != null && entity.FormattedValues.Count > 0) - baseEntity.FormattedValues.AddRange(entity.FormattedValues); - - if (entity.RelatedEntities != null && entity.RelatedEntities.Count > 0) - baseEntity.RelatedEntities.AddRange(entity.RelatedEntities); - - if (entity.KeyAttributes != null && entity.KeyAttributes.Count > 0) - baseEntity.KeyAttributes.AddRange(entity.KeyAttributes); - - baseEntity.RowVersion = entity.RowVersion; - - return baseEntity; - } - - /// - /// Deserializes an Entity from a byte array. - /// - public static Entity DeserializeEntity(byte[] data) - { - if (data == null || data.Length == 0) - throw new ArgumentException("Data cannot be null or empty", nameof(data)); - - using (var ms = new MemoryStream(data)) - { - var result = EntitySerializer.ReadObject(ms); - return result as Entity - ?? throw new InvalidOperationException("Deserialization returned null or unexpected type"); - } - } - - /// - /// Serializes an EntityCollection to a byte array. - /// Converts derived types (early-bound entities) to base Entity to ensure serialization works. - /// - public static byte[] SerializeEntityCollection(EntityCollection collection) - { - if (collection == null) - throw new ArgumentNullException(nameof(collection)); - - // Convert all entities to base Entity type - var baseCollection = new EntityCollection - { - EntityName = collection.EntityName, - MoreRecords = collection.MoreRecords, - PagingCookie = collection.PagingCookie, - TotalRecordCount = collection.TotalRecordCount, - TotalRecordCountLimitExceeded = collection.TotalRecordCountLimitExceeded - }; - - foreach (var entity in collection.Entities) - { - baseCollection.Entities.Add(ToBaseEntity(entity)); - } - - using (var ms = new MemoryStream()) - { - EntityCollectionSerializer.WriteObject(ms, baseCollection); - return ms.ToArray(); - } - } - - /// - /// Deserializes an EntityCollection from a byte array. - /// - public static EntityCollection DeserializeEntityCollection(byte[] data) - { - if (data == null || data.Length == 0) - throw new ArgumentException("Data cannot be null or empty", nameof(data)); - - using (var ms = new MemoryStream(data)) - { - var result = EntityCollectionSerializer.ReadObject(ms); - return result as EntityCollection - ?? throw new InvalidOperationException("Deserialization returned null or unexpected type"); - } - } - - /// - /// Serializes a QueryExpression to a byte array. - /// - public static byte[] SerializeQueryExpression(QueryExpression query) - { - if (query == null) - throw new ArgumentNullException(nameof(query)); - - using (var ms = new MemoryStream()) - { - QueryExpressionSerializer.WriteObject(ms, query); - return ms.ToArray(); - } - } - - /// - /// Deserializes a QueryExpression from a byte array. - /// - public static QueryExpression DeserializeQueryExpression(byte[] data) - { - if (data == null || data.Length == 0) - throw new ArgumentException("Data cannot be null or empty", nameof(data)); - - using (var ms = new MemoryStream(data)) - { - var result = QueryExpressionSerializer.ReadObject(ms); - return result as QueryExpression - ?? throw new InvalidOperationException("Deserialization returned null or unexpected type"); - } - } - } -} diff --git a/src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs b/src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs deleted file mode 100644 index 1378154b..00000000 --- a/src/XrmMockup.DataverseProxy.Contracts/MockDataFile.cs +++ /dev/null @@ -1,19 +0,0 @@ -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP -#nullable enable -#endif -using System.Collections.Generic; - -namespace XrmMockup.DataverseProxy.Contracts -{ - /// - /// JSON schema for mock data file used in testing. - /// - internal class MockDataFile - { - /// - /// List of serialized entities to use as mock data. - /// Each entity is serialized using DataContractSerializer as a byte array. - /// - public List Entities { get; set; } = new List(); - } -} diff --git a/src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs b/src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs deleted file mode 100644 index 7e29caf6..00000000 --- a/src/XrmMockup.DataverseProxy.Contracts/ProxyContracts.cs +++ /dev/null @@ -1,95 +0,0 @@ -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP -#nullable enable -#endif -using System; - -namespace XrmMockup.DataverseProxy.Contracts -{ - /// - /// Types of requests that can be sent to the proxy. - /// - public enum ProxyRequestType : byte - { - Ping = 0, - Retrieve = 1, - RetrieveMultiple = 2, - Shutdown = 3 - } - - /// - /// Base request envelope for proxy communication. - /// - public class ProxyRequest - { - public ProxyRequestType RequestType { get; set; } - - /// - /// JSON-serialized payload for the specific request type. - /// -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP - public string? Payload { get; set; } -#else - public string Payload { get; set; } -#endif - - /// - /// Authentication token. Must match the token passed to the proxy at startup. - /// -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP - public string? AuthToken { get; set; } -#else - public string AuthToken { get; set; } -#endif - } - - /// - /// Request to retrieve a single entity by ID. - /// - public class ProxyRetrieveRequest - { - public string EntityName { get; set; } = string.Empty; - public Guid Id { get; set; } - - /// - /// Column names to retrieve. Null means all columns. - /// -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP - public string[]? Columns { get; set; } -#else - public string[] Columns { get; set; } -#endif - } - - /// - /// Request to retrieve multiple entities using a QueryExpression. - /// - public class ProxyRetrieveMultipleRequest - { - /// - /// QueryExpression serialized using DataContractSerializer. - /// - public byte[] SerializedQuery { get; set; } = Array.Empty(); - } - - /// - /// Response from the proxy. - /// - public class ProxyResponse - { - public bool Success { get; set; } -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP - public string? ErrorMessage { get; set; } -#else - public string ErrorMessage { get; set; } -#endif - - /// - /// Serialized Entity or EntityCollection using DataContractSerializer. - /// -#if DATAVERSE_SERVICE_CLIENT || NETCOREAPP - public byte[]? SerializedData { get; set; } -#else - public byte[] SerializedData { get; set; } -#endif - } -} diff --git a/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems b/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems deleted file mode 100644 index b4dc0ab4..00000000 --- a/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.projitems +++ /dev/null @@ -1,16 +0,0 @@ - - - - $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - true - a1b2c3d4-e5f6-7890-abcd-ef1234567890 - - - XrmMockup.DataverseProxy.Contracts - - - - - - - diff --git a/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj b/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj deleted file mode 100644 index 6f203110..00000000 --- a/src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.shproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - 6F508B63-7125-4061-AC30-6C6E3E3C357D - 14.0 - - - - - - - - diff --git a/src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs b/src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs deleted file mode 100644 index 8a8e88d8..00000000 --- a/src/XrmMockup.DataverseProxy/DataverseServiceFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.PowerPlatform.Dataverse.Client; - -namespace XrmMockup.DataverseProxy; - -/// -/// Factory that provides IOrganizationServiceAsync2 from a ServiceClient. -/// -public class DataverseServiceFactory : IDataverseServiceFactory -{ - private readonly ServiceClient _serviceClient; - - public DataverseServiceFactory(ServiceClient serviceClient) - { - _serviceClient = serviceClient ?? throw new ArgumentNullException(nameof(serviceClient)); - } - - public IOrganizationServiceAsync2 CreateService() => _serviceClient; -} diff --git a/src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs b/src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs deleted file mode 100644 index dd6288a3..00000000 --- a/src/XrmMockup.DataverseProxy/IDataverseServiceFactory.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.PowerPlatform.Dataverse.Client; - -namespace XrmMockup.DataverseProxy; - -/// -/// Factory interface for creating IOrganizationServiceAsync2 instances. -/// Enables dependency injection and testing of ProxyServer. -/// -public interface IDataverseServiceFactory -{ - /// - /// Creates or returns an IOrganizationServiceAsync2 instance for Dataverse operations. - /// - IOrganizationServiceAsync2 CreateService(); -} diff --git a/src/XrmMockup.DataverseProxy/MockDataService.cs b/src/XrmMockup.DataverseProxy/MockDataService.cs deleted file mode 100644 index 30dee8fe..00000000 --- a/src/XrmMockup.DataverseProxy/MockDataService.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.PowerPlatform.Dataverse.Client; -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Query; - -namespace XrmMockup.DataverseProxy; - -/// -/// Mock implementation of IOrganizationServiceAsync2 backed by in-memory data. -/// Used for testing proxy communication without connecting to Dataverse. -/// -internal class MockDataService : IOrganizationServiceAsync2 -{ - private readonly Dictionary<(string LogicalName, Guid Id), Entity> _entities = new(); - - public MockDataService(IEnumerable entities) - { - foreach (var entity in entities) - { - _entities[(entity.LogicalName, entity.Id)] = entity; - } - } - - public Task RetrieveAsync(string entityName, Guid id, ColumnSet columnSet, CancellationToken cancellationToken = default) - { - if (_entities.TryGetValue((entityName, id), out var entity)) - { - return Task.FromResult(ApplyColumnSet(entity, columnSet)); - } - - throw new Exception($"Entity {entityName} with id {id} not found"); - } - - public Task RetrieveMultipleAsync(QueryExpression query, CancellationToken cancellationToken = default) - { - var matches = _entities.Values - .Where(e => e.LogicalName == query.EntityName) - .Where(e => MatchesCriteria(e, query.Criteria)) - .Select(e => ApplyColumnSet(e, query.ColumnSet)) - .ToList(); - - return Task.FromResult(new EntityCollection(matches) { EntityName = query.EntityName }); - } - - private static Entity ApplyColumnSet(Entity entity, ColumnSet columnSet) - { - if (columnSet.AllColumns) - { - return CloneEntity(entity); - } - - var result = new Entity(entity.LogicalName, entity.Id); - foreach (var column in columnSet.Columns) - { - if (entity.Contains(column)) - { - result[column] = entity[column]; - } - } - return result; - } - - private static Entity CloneEntity(Entity entity) - { - var clone = new Entity(entity.LogicalName, entity.Id); - foreach (var attr in entity.Attributes) - { - clone[attr.Key] = attr.Value; - } - return clone; - } - - private static bool MatchesCriteria(Entity entity, FilterExpression filter) - { - if (filter == null || filter.Conditions.Count == 0 && filter.Filters.Count == 0) - { - return true; - } - - var conditionResults = filter.Conditions.Select(c => MatchesCondition(entity, c)); - var filterResults = filter.Filters.Select(f => MatchesCriteria(entity, f)); - var allResults = conditionResults.Concat(filterResults).ToList(); - - return filter.FilterOperator == LogicalOperator.And - ? allResults.All(r => r) - : allResults.Any(r => r); - } - - private static bool MatchesCondition(Entity entity, ConditionExpression condition) - { - var attributeName = condition.AttributeName; - var entityValue = entity.Contains(attributeName) ? entity[attributeName] : null; - - return condition.Operator switch - { - ConditionOperator.Equal => Equals(entityValue, condition.Values.FirstOrDefault()), - ConditionOperator.NotEqual => !Equals(entityValue, condition.Values.FirstOrDefault()), - ConditionOperator.Like => entityValue is string s && condition.Values.FirstOrDefault() is string pattern - && s.Contains(pattern.Replace("%", "")), - ConditionOperator.BeginsWith => entityValue is string s2 && condition.Values.FirstOrDefault() is string prefix - && s2.StartsWith(prefix, StringComparison.OrdinalIgnoreCase), - ConditionOperator.Null => entityValue == null, - ConditionOperator.NotNull => entityValue != null, - _ => true // Default to matching for unsupported operators - }; - } - - // Synchronous methods - delegate to async versions - public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) - => RetrieveAsync(entityName, id, columnSet).GetAwaiter().GetResult(); - - public EntityCollection RetrieveMultiple(QueryBase query) - => RetrieveMultipleAsync((QueryExpression)query).GetAwaiter().GetResult(); - - public Task RetrieveMultipleAsync(QueryBase query, CancellationToken cancellationToken = default) - => RetrieveMultipleAsync((QueryExpression)query, cancellationToken); - - // Not implemented - not needed for testing - public Guid Create(Entity entity) => throw new NotImplementedException(); - public Task CreateAsync(Entity entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task CreateAsync(Entity entity) => throw new NotImplementedException(); - public Task CreateAndReturnAsync(Entity entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public void Update(Entity entity) => throw new NotImplementedException(); - public Task UpdateAsync(Entity entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task UpdateAsync(Entity entity) => throw new NotImplementedException(); - public void Delete(string entityName, Guid id) => throw new NotImplementedException(); - public Task DeleteAsync(string entityName, Guid id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task DeleteAsync(string entityName, Guid id) => throw new NotImplementedException(); - public void Associate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); - public Task AssociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task AssociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); - public void Disassociate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); - public Task DisassociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task DisassociateAsync(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotImplementedException(); - public OrganizationResponse Execute(OrganizationRequest request) => throw new NotImplementedException(); - public Task ExecuteAsync(OrganizationRequest request, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task ExecuteAsync(OrganizationRequest request) => throw new NotImplementedException(); - - // IOrganizationServiceAsync methods without CancellationToken - Task IOrganizationServiceAsync.RetrieveAsync(string entityName, Guid id, ColumnSet columnSet) - => RetrieveAsync(entityName, id, columnSet, CancellationToken.None); - - Task IOrganizationServiceAsync.RetrieveMultipleAsync(QueryBase query) - => RetrieveMultipleAsync(query, CancellationToken.None); -} diff --git a/src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs b/src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs deleted file mode 100644 index b8cae699..00000000 --- a/src/XrmMockup.DataverseProxy/MockDataServiceFactory.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.IO; -using System.Linq; -using System.Text.Json; -using Microsoft.PowerPlatform.Dataverse.Client; -using XrmMockup.DataverseProxy.Contracts; - -namespace XrmMockup.DataverseProxy; - -/// -/// Factory that creates MockDataService instances from a JSON data file. -/// Used for testing proxy communication without connecting to Dataverse. -/// -internal class MockDataServiceFactory : IDataverseServiceFactory -{ - private readonly MockDataService _service; - - public MockDataServiceFactory(string dataFilePath) - { - var json = File.ReadAllText(dataFilePath); - var data = JsonSerializer.Deserialize(json); - - var entities = data?.Entities? - .Select(EntitySerializationHelper.DeserializeEntity) - .ToList() ?? []; - - _service = new MockDataService(entities); - } - - public IOrganizationServiceAsync2 CreateService() => _service; -} diff --git a/src/XrmMockup.DataverseProxy/Program.cs b/src/XrmMockup.DataverseProxy/Program.cs deleted file mode 100644 index e6a2653e..00000000 --- a/src/XrmMockup.DataverseProxy/Program.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.CommandLine; -using DataverseConnection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.PowerPlatform.Dataverse.Client; -using XrmMockup.DataverseProxy; - -// Define CLI options -var urlOption = new Option("--url", "-u") -{ - Description = "Dataverse environment URL (e.g., https://org.crm.dynamics.com)", - Arity = ArgumentArity.ExactlyOne -}; - -var pipeOption = new Option("--pipe", "-p") -{ - Description = "Named pipe name for IPC communication", - Arity = ArgumentArity.ExactlyOne -}; - -var mockDataFileOption = new Option("--mock-data-file", "-m") -{ - Description = "Path to JSON file containing mock data. When set, the proxy loads entities from this file instead of connecting to Dataverse. Used for testing.", - Arity = ArgumentArity.ZeroOrOne -}; - -// Build root command -var rootCommand = new RootCommand("XrmMockup Dataverse Proxy - Out-of-process bridge for online data fetching") -{ - urlOption, - pipeOption, - mockDataFileOption -}; - -rootCommand.SetAction(async (parseResult, cancellationToken) => -{ - var url = parseResult.GetValue(urlOption); - var pipeName = parseResult.GetValue(pipeOption); - var mockDataFile = parseResult.GetValue(mockDataFileOption); - - if (string.IsNullOrEmpty(pipeName)) - { - Console.Error.WriteLine("Error: --pipe is required"); - return 1; - } - - // Read auth token from stdin (with timeout) - more secure than command line args - string? authToken; - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) - { - try - { - authToken = await Console.In.ReadLineAsync(cts.Token); - } - catch (OperationCanceledException) - { - Console.Error.WriteLine("Error: Timeout waiting for auth token on stdin"); - return 1; - } - } - - if (string.IsNullOrEmpty(authToken)) - { - Console.Error.WriteLine("Error: Auth token is required (pass via stdin)"); - return 1; - } - - // Set up DI - var services = new ServiceCollection(); - - services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); - - // Check if running in mock mode or real Dataverse mode - var useMockData = !string.IsNullOrEmpty(mockDataFile); - - if (!useMockData && string.IsNullOrEmpty(url)) - { - Console.Error.WriteLine("Error: --url is required unless --mock-data-file is specified"); - return 1; - } - - if (!useMockData) - { - // Configure DataverseConnection with the URL passed via command line - services.AddDataverse(options => options.DataverseUrl = url!); - } - - await using var serviceProvider = services.BuildServiceProvider(); - - var logger = serviceProvider.GetRequiredService>(); - - try - { - IDataverseServiceFactory serviceFactory; - - if (useMockData) - { - logger.LogInformation("Starting XrmMockup Dataverse Proxy in mock mode with data file: {MockDataFile}", mockDataFile); - serviceFactory = new MockDataServiceFactory(mockDataFile); - } - else - { - logger.LogInformation("Starting XrmMockup Dataverse Proxy for {Url}", url); - var serviceClient = serviceProvider.GetRequiredService(); - - // Verify connection - var whoAmI = serviceClient.Execute(new Microsoft.Crm.Sdk.Messages.WhoAmIRequest()); - logger.LogInformation("Connected to Dataverse as user {UserId}", ((Microsoft.Crm.Sdk.Messages.WhoAmIResponse)whoAmI).UserId); - - serviceFactory = new DataverseServiceFactory(serviceClient); - } - - // Start the proxy server - var proxyServer = new ProxyServer(serviceFactory, pipeName, authToken, serviceProvider.GetRequiredService>()); - await proxyServer.RunAsync(cancellationToken); - - return 0; - } - catch (Exception ex) - { - logger.LogError(ex, "Proxy server failed"); - return 1; - } -}); - -return await rootCommand.Parse(args).InvokeAsync(); diff --git a/src/XrmMockup.DataverseProxy/ProxyServer.cs b/src/XrmMockup.DataverseProxy/ProxyServer.cs deleted file mode 100644 index 9a612ace..00000000 --- a/src/XrmMockup.DataverseProxy/ProxyServer.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System.IO.Pipes; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.PowerPlatform.Dataverse.Client; -using Microsoft.Xrm.Sdk.Query; -using XrmMockup.DataverseProxy.Contracts; - -namespace XrmMockup.DataverseProxy; - -/// -/// Named pipe server that handles proxy requests from XrmMockup clients. -/// Supports multiple concurrent client connections for parallel test execution. -/// -/// -/// Creates a new ProxyServer with a service factory. -/// -public class ProxyServer(IDataverseServiceFactory serviceFactory, string pipeName, string authToken, ILogger logger) -{ - private readonly IDataverseServiceFactory _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); - private readonly string _pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName)); - private readonly string _authToken = authToken ?? throw new ArgumentNullException(nameof(authToken)); - private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly List _activeClients = []; - private readonly object _clientsLock = new(); - - /// - /// Runs the proxy server, accepting connections and processing requests. - /// Handles multiple clients concurrently for parallel test execution. - /// - public async Task RunAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Starting proxy server on pipe: {PipeName}", _pipeName); - - while (!cancellationToken.IsCancellationRequested) - { - try - { - var pipeServer = new NamedPipeServerStream( - _pipeName, - PipeDirection.InOut, - NamedPipeServerStream.MaxAllowedServerInstances, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous); - - _logger.LogDebug("Waiting for client connection..."); - await pipeServer.WaitForConnectionAsync(cancellationToken); - _logger.LogDebug("Client connected, spawning handler task"); - - // Spawn a task to handle this client, allowing the main loop to accept more connections - var clientTask = HandleClientAsync(pipeServer, cancellationToken); - TrackClientTask(clientTask); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - _logger.LogInformation("Proxy server shutting down, waiting for active clients..."); - await WaitForActiveClientsAsync(); - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error accepting client connection"); - } - } - } - - private void TrackClientTask(Task clientTask) - { - lock (_clientsLock) - { - // Remove completed tasks - _activeClients.RemoveAll(t => t.IsCompleted); - _activeClients.Add(clientTask); - } - } - - private async Task WaitForActiveClientsAsync() - { - Task[] tasksToWait; - lock (_clientsLock) - { - tasksToWait = [.. _activeClients]; - } - - if (tasksToWait.Length > 0) - { - _logger.LogDebug("Waiting for {Count} active client(s) to complete", tasksToWait.Length); - await Task.WhenAll(tasksToWait); - } - } - - private async Task HandleClientAsync(NamedPipeServerStream pipeServer, CancellationToken cancellationToken) - { - try - { - while (pipeServer.IsConnected && !cancellationToken.IsCancellationRequested) - { - // Read message length (4 bytes, little-endian) - var lengthBuffer = new byte[4]; - var bytesRead = await pipeServer.ReadAsync(lengthBuffer.AsMemory(0, 4), cancellationToken); - if (bytesRead == 0) - { - _logger.LogDebug("Client disconnected"); - break; - } - - if (bytesRead < 4) - { - _logger.LogWarning("Incomplete message length received"); - continue; - } - - var messageLength = BitConverter.ToInt32(lengthBuffer, 0); - if (messageLength <= 0 || messageLength > 100 * 1024 * 1024) // Max 100MB - { - _logger.LogWarning("Invalid message length: {Length}", messageLength); - continue; - } - - // Read message body - var messageBuffer = new byte[messageLength]; - var totalRead = 0; - while (totalRead < messageLength) - { - bytesRead = await pipeServer.ReadAsync(messageBuffer.AsMemory(totalRead, messageLength - totalRead), cancellationToken); - if (bytesRead == 0) - break; - totalRead += bytesRead; - } - - if (totalRead < messageLength) - { - _logger.LogWarning("Incomplete message received"); - continue; - } - - // Deserialize and process request - var request = JsonSerializer.Deserialize(messageBuffer); - if (request is null) - { - _logger.LogWarning("Failed to deserialize request"); - continue; - } - var response = await ProcessRequestAsync(request); - - // Serialize and send response - var responseBytes = JsonSerializer.SerializeToUtf8Bytes(response); - var responseLength = BitConverter.GetBytes(responseBytes.Length); - await pipeServer.WriteAsync(responseLength.AsMemory(0, 4), cancellationToken); - await pipeServer.WriteAsync(responseBytes, cancellationToken); - await pipeServer.FlushAsync(cancellationToken); - - // Handle shutdown request - note: with multiple clients, only this client's connection closes - if (request.RequestType == ProxyRequestType.Shutdown) - { - _logger.LogDebug("Client requested disconnect"); - break; - } - } - } - catch (IOException) - { - _logger.LogDebug("Pipe broken, client disconnected"); - } - catch (OperationCanceledException) - { - _logger.LogDebug("Client handler cancelled"); - } - finally - { - // Always dispose the pipe when client handling is done - await pipeServer.DisposeAsync(); - } - } - - private async Task ProcessRequestAsync(ProxyRequest request) - { - // Validate authentication token using constant-time comparison to prevent timing attacks - if (!ValidateAuthToken(request.AuthToken)) - { - _logger.LogWarning("Invalid authentication token received"); - return new ProxyResponse { Success = false, ErrorMessage = "Authentication failed" }; - } - - try - { - return request.RequestType switch - { - ProxyRequestType.Ping => new ProxyResponse { Success = true }, - ProxyRequestType.Retrieve => await HandleRetrieveAsync(request.Payload), - ProxyRequestType.RetrieveMultiple => await HandleRetrieveMultipleAsync(request.Payload), - ProxyRequestType.Shutdown => new ProxyResponse { Success = true }, - _ => new ProxyResponse { Success = false, ErrorMessage = $"Unknown request type: {request.RequestType}" } - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing {RequestType} request", request.RequestType); - return new ProxyResponse - { - Success = false, - ErrorMessage = ex.Message - }; - } - } - - /// - /// Validates the authentication token using constant-time comparison to prevent timing attacks. - /// - private bool ValidateAuthToken(string? token) - { - if (token == null) - return false; - - var expectedBytes = Encoding.UTF8.GetBytes(_authToken); - var actualBytes = Encoding.UTF8.GetBytes(token); - - // Use FixedTimeEquals for constant-time comparison - return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); - } - - private async Task HandleRetrieveAsync(string payload) - { - if (string.IsNullOrEmpty(payload)) - { - return new ProxyResponse { Success = false, ErrorMessage = "Empty payload" }; - } - - var retrieveRequest = JsonSerializer.Deserialize(payload); - if (retrieveRequest is null) - { - return new ProxyResponse { Success = false, ErrorMessage = "Invalid retrieve request payload" }; - } - _logger.LogDebug("Retrieve: {EntityName} {Id}", retrieveRequest.EntityName, retrieveRequest.Id); - - var columnSet = retrieveRequest.Columns == null - ? new ColumnSet(true) - : new ColumnSet(retrieveRequest.Columns); - - var service = _serviceFactory.CreateService(); - var entity = await service.RetrieveAsync(retrieveRequest.EntityName, retrieveRequest.Id, columnSet); - var serializedEntity = EntitySerializationHelper.SerializeEntity(entity); - - return new ProxyResponse - { - Success = true, - SerializedData = serializedEntity - }; - } - - private async Task HandleRetrieveMultipleAsync(string payload) - { - if (string.IsNullOrEmpty(payload)) - { - return new ProxyResponse { Success = false, ErrorMessage = "Empty payload" }; - } - - var retrieveMultipleRequest = JsonSerializer.Deserialize(payload); - if (retrieveMultipleRequest is null) - { - return new ProxyResponse { Success = false, ErrorMessage = "Invalid retrieve multiple request payload" }; - } - - var queryExpression = EntitySerializationHelper.DeserializeQueryExpression(retrieveMultipleRequest.SerializedQuery); - _logger.LogDebug("RetrieveMultiple: {EntityName}", queryExpression.EntityName); - - var service = _serviceFactory.CreateService(); - var entityCollection = await service.RetrieveMultipleAsync(queryExpression); - var serializedCollection = EntitySerializationHelper.SerializeEntityCollection(entityCollection); - - return new ProxyResponse - { - Success = true, - SerializedData = serializedCollection - }; - } -} diff --git a/src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj b/src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj deleted file mode 100644 index 4feee238..00000000 --- a/src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - Exe - false - net8.0 - enable - enable - XrmMockup Dataverse Proxy - Out-of-process bridge for online data fetching - - - - - - - - - - - - - - - - - <_Parameter1>XrmMockup.DataverseProxy.Tests - - - <_Parameter1>XrmMockup365Test - - - - diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index 3b74f87a..44c72234 100644 --- a/src/XrmMockup365/Core.cs +++ b/src/XrmMockup365/Core.cs @@ -1,7 +1,9 @@ using DG.Tools.XrmMockup.Database; using DG.Tools.XrmMockup.Internal; using DG.Tools.XrmMockup.Serialization; +#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; +#endif using XrmPluginCore.Enums; using Microsoft.Crm.Sdk.Messages; using Microsoft.Xrm.Sdk; @@ -59,7 +61,9 @@ internal class Core : IXrmMockupExtension private XrmDb db; private Dictionary snapshots; private Dictionary entityTypeMap = new Dictionary(); +#if DATAVERSE_SERVICE_CLIENT private IOnlineDataService OnlineDataService; +#endif private int baseCurrencyPrecision; private FormulaFieldEvaluator FormulaFieldEvaluator { get; set; } private List systemAttributeNames; @@ -79,7 +83,9 @@ public Core(XrmMockupSettings Settings, MetadataSkeleton metadata, List SecurityRoles = SecurityRoles, BaseCurrency = metadata.BaseOrganization.GetAttributeValue("basecurrencyid"), BaseCurrencyPrecision = metadata.BaseOrganization.GetAttributeValue("pricingdecimalprecision"), +#if DATAVERSE_SERVICE_CLIENT OnlineDataService = null, +#endif EntityTypeMap = new Dictionary() }; @@ -99,7 +105,9 @@ public Core(XrmMockupSettings Settings, StaticMetadataCache staticCache) SecurityRoles = staticCache.SecurityRoles, BaseCurrency = staticCache.BaseCurrency, BaseCurrencyPrecision = staticCache.BaseCurrencyPrecision, +#if DATAVERSE_SERVICE_CLIENT OnlineDataService = staticCache.OnlineDataService, +#endif EntityTypeMap = staticCache.EntityTypeMap }; @@ -116,10 +124,13 @@ private void InitializeCore(CoreInitializationData initData) metadata = initData.Metadata; BaseCurrency = initData.BaseCurrency; baseCurrencyPrecision = initData.BaseCurrencyPrecision; +#if DATAVERSE_SERVICE_CLIENT OnlineDataService = initData.OnlineDataService; - entityTypeMap = initData.EntityTypeMap; - db = new XrmDb(initData.Metadata.EntityMetadata, initData.OnlineDataService); +#else + db = new XrmDb(initData.Metadata.EntityMetadata); +#endif + entityTypeMap = initData.EntityTypeMap; EnsureFileAttachmentMetadata(); FileBlockStore = new FileBlockStore(); snapshots = new Dictionary(); @@ -185,7 +196,9 @@ public static StaticMetadataCache BuildStaticMetadataCache(XrmMockupSettings set var baseCurrency = metadata.BaseOrganization.GetAttributeValue("basecurrencyid"); var baseCurrencyPrecision = metadata.BaseOrganization.GetAttributeValue("pricingdecimalprecision"); +#if DATAVERSE_SERVICE_CLIENT var onlineDataService = BuildOnlineDataService(settings); +#endif var entityTypeMap = new Dictionary(); // Build entity type map for proxy types if enabled @@ -197,10 +210,16 @@ public static StaticMetadataCache BuildStaticMetadataCache(XrmMockupSettings set // Note: IPluginMetadata is handled per-instance in the Core constructor // to avoid modifying the shared cache +#if DATAVERSE_SERVICE_CLIENT return new StaticMetadataCache(metadata, workflows, securityRoles, entityTypeMap, baseCurrency, baseCurrencyPrecision, onlineDataService); +#else + return new StaticMetadataCache(metadata, workflows, securityRoles, entityTypeMap, + baseCurrency, baseCurrencyPrecision); +#endif } +#if DATAVERSE_SERVICE_CLIENT private static IOnlineDataService BuildOnlineDataService(XrmMockupSettings settings) { // Allow injection for testing @@ -212,11 +231,12 @@ private static IOnlineDataService BuildOnlineDataService(XrmMockupSettings setti if (settings.OnlineEnvironment.HasValue) { var env = settings.OnlineEnvironment.Value; - return new ProxyOnlineDataService(env.Url, env.ProxyPath); + return new OnlineDataService(env.Url); } return null; } +#endif private static void BuildEntityTypeMap(XrmMockupSettings settings, Dictionary entityTypeMap) { @@ -425,19 +445,6 @@ internal void EnableProxyTypes(Assembly assembly) } } - private IOnlineDataService GetOnlineDataService() - { - if (OnlineDataService == null) - { - if (settings.OnlineEnvironment.HasValue) - { - var env = settings.OnlineEnvironment.Value; - OnlineDataService = new ProxyOnlineDataService(env.Url, env.ProxyPath); - } - } - return OnlineDataService; - } - internal IOrganizationService GetWorkflowService() { return ServiceFactory.CreateOrganizationService(null, @@ -1113,6 +1120,7 @@ internal void PopulateWith(Entity[] entities) } } +#if DATAVERSE_SERVICE_CLIENT /// /// Prefills the local database with data from the online service based on the query. /// @@ -1120,6 +1128,7 @@ internal void PrefillDBWithOnlineData(QueryExpression query) { db.PrefillDBWithOnlineData(query); } +#endif internal Dictionary> GetPrivilege(Guid principleId) { @@ -1356,7 +1365,11 @@ internal void ResetEnvironment() workflowManager.ResetWorkflows(settings.IncludeAllWorkflows); pluginManager.ResetPlugins(); +#if DATAVERSE_SERVICE_CLIENT this.db = new XrmDb(metadata.EntityMetadata, OnlineDataService); +#else + this.db = new XrmDb(metadata.EntityMetadata); +#endif EnsureFileAttachmentMetadata(); this.RequestHandlers = GetRequestHandlers(db); InitializeDB(); diff --git a/src/XrmMockup365/Database/XrmDb.cs b/src/XrmMockup365/Database/XrmDb.cs index 546d18fa..83c45f04 100644 --- a/src/XrmMockup365/Database/XrmDb.cs +++ b/src/XrmMockup365/Database/XrmDb.cs @@ -9,7 +9,9 @@ using System.Threading; using DG.Tools.XrmMockup.Serialization; using DG.Tools.XrmMockup.Internal; +#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; +#endif namespace DG.Tools.XrmMockup.Database { @@ -17,14 +19,23 @@ internal class XrmDb { // Using ConcurrentDictionary for thread-safe table access in parallel test scenarios private ConcurrentDictionary TableDict = new ConcurrentDictionary(); private readonly Dictionary EntityMetadata; +#if DATAVERSE_SERVICE_CLIENT private readonly IOnlineDataService OnlineDataService; +#endif private int sequence; +#if DATAVERSE_SERVICE_CLIENT public XrmDb(Dictionary entityMetadata, IOnlineDataService onlineDataService) { this.EntityMetadata = entityMetadata; this.OnlineDataService = onlineDataService; sequence = 0; } +#else + public XrmDb(Dictionary entityMetadata) { + this.EntityMetadata = entityMetadata; + sequence = 0; + } +#endif public DbTable this[string tableName] { get { @@ -111,6 +122,7 @@ internal void RegisterEntityMetadata(EntityMetadata entityMetadata) EntityMetadata[entityMetadata.LogicalName] = entityMetadata; } +#if DATAVERSE_SERVICE_CLIENT internal void PrefillDBWithOnlineData(QueryExpression queryExpr) { if (OnlineDataService != null) @@ -125,6 +137,7 @@ internal void PrefillDBWithOnlineData(QueryExpression queryExpr) } } } +#endif internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = true) { @@ -133,6 +146,7 @@ internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = tru if (reference?.Id != Guid.Empty) { currentDbRow = this[reference.LogicalName][reference.Id]; +#if DATAVERSE_SERVICE_CLIENT if (currentDbRow == null && OnlineDataService != null) { if (!withReferenceCheck) @@ -146,6 +160,7 @@ internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = tru currentDbRow = this[reference.LogicalName][reference.Id]; } } +#endif if (currentDbRow == null) { throw new FaultException($"The record of type '{reference.LogicalName}' with id '{reference.Id}' " + @@ -263,7 +278,11 @@ internal Entity GetEntityOrNull(EntityReference reference) public XrmDb Clone() { var clonedTables = this.TableDict.ToDictionary(x => x.Key, x => x.Value.Clone()); +#if DATAVERSE_SERVICE_CLIENT var clonedDB = new XrmDb(this.EntityMetadata, this.OnlineDataService) +#else + var clonedDB = new XrmDb(this.EntityMetadata) +#endif { TableDict = new ConcurrentDictionary(clonedTables) }; @@ -281,7 +300,11 @@ public DbDTO ToSerializableDTO() public static XrmDb RestoreSerializableDTO(XrmDb current, DbDTO model) { var clonedTables = model.Tables.ToDictionary(x => x.Key, x => DbTable.RestoreSerializableDTO(new DbTable(current.EntityMetadata[x.Key]), x.Value)); +#if DATAVERSE_SERVICE_CLIENT var clonedDB = new XrmDb(current.EntityMetadata, current.OnlineDataService) +#else + var clonedDB = new XrmDb(current.EntityMetadata) +#endif { TableDict = new ConcurrentDictionary(clonedTables) }; diff --git a/src/XrmMockup365/FormulaFieldEvaluator.cs b/src/XrmMockup365/FormulaFieldEvaluator.cs index a6781a8d..11fb08dd 100644 --- a/src/XrmMockup365/FormulaFieldEvaluator.cs +++ b/src/XrmMockup365/FormulaFieldEvaluator.cs @@ -7,13 +7,14 @@ using System.Globalization; using System.Threading; using System.Threading.Tasks; +using PowerFxDataverseConnection = Microsoft.PowerFx.Dataverse.DataverseConnection; namespace DG.Tools.XrmMockup { internal class FormulaFieldEvaluator { private readonly IOrganizationService _organizationService; - private readonly DataverseConnection _dataverseConnection; + private readonly PowerFxDataverseConnection _dataverseConnection; public FormulaFieldEvaluator(IOrganizationServiceFactory serviceFactory) { diff --git a/src/XrmMockup365/Internal/CoreInitializationData.cs b/src/XrmMockup365/Internal/CoreInitializationData.cs index df78bbb9..8f293b1a 100644 --- a/src/XrmMockup365/Internal/CoreInitializationData.cs +++ b/src/XrmMockup365/Internal/CoreInitializationData.cs @@ -1,7 +1,9 @@ using Microsoft.Xrm.Sdk; using System; using System.Collections.Generic; +#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; +#endif namespace DG.Tools.XrmMockup.Internal { @@ -16,7 +18,9 @@ internal class CoreInitializationData public List SecurityRoles { get; set; } public EntityReference BaseCurrency { get; set; } public int BaseCurrencyPrecision { get; set; } +#if DATAVERSE_SERVICE_CLIENT public IOnlineDataService OnlineDataService { get; set; } +#endif public Dictionary EntityTypeMap { get; set; } } } diff --git a/src/XrmMockup365/Online/DefaultFileSystemHelper.cs b/src/XrmMockup365/Online/DefaultFileSystemHelper.cs deleted file mode 100644 index d0e2bb99..00000000 --- a/src/XrmMockup365/Online/DefaultFileSystemHelper.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.IO; -using System.Reflection; - -namespace DG.Tools.XrmMockup.Online -{ - /// - /// Default implementation of IFileSystemHelper using real file system operations. - /// - internal class DefaultFileSystemHelper : IFileSystemHelper - { - public bool FileExists(string path) => File.Exists(path); - - public bool DirectoryExists(string path) => Directory.Exists(path); - - public string GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); - - public string GetExecutingAssemblyLocation() => Assembly.GetExecutingAssembly().Location; - - public string GetAssemblyInformationalVersion() - { - var assembly = Assembly.GetExecutingAssembly(); - return assembly.GetCustomAttribute()?.InformationalVersion; - } - - public string GetUserProfilePath() => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - - public string GetParentDirectory(string path) - { - var parent = Directory.GetParent(path); - return parent?.FullName; - } - } -} diff --git a/src/XrmMockup365/Online/IFileSystemHelper.cs b/src/XrmMockup365/Online/IFileSystemHelper.cs deleted file mode 100644 index a683e808..00000000 --- a/src/XrmMockup365/Online/IFileSystemHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace DG.Tools.XrmMockup.Online -{ - /// - /// Abstraction for file system operations to enable testing. - /// - internal interface IFileSystemHelper - { - bool FileExists(string path); - bool DirectoryExists(string path); - string GetEnvironmentVariable(string name); - string GetExecutingAssemblyLocation(); - string GetAssemblyInformationalVersion(); - string GetUserProfilePath(); - string GetParentDirectory(string path); - } -} diff --git a/src/XrmMockup365/Online/IOnlineDataService.cs b/src/XrmMockup365/Online/IOnlineDataService.cs index 21c31a11..a194a1da 100644 --- a/src/XrmMockup365/Online/IOnlineDataService.cs +++ b/src/XrmMockup365/Online/IOnlineDataService.cs @@ -1,3 +1,4 @@ +#if DATAVERSE_SERVICE_CLIENT using System; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; @@ -25,3 +26,4 @@ internal interface IOnlineDataService : IDisposable bool IsConnected { get; } } } +#endif diff --git a/src/XrmMockup365/Online/OnlineDataService.cs b/src/XrmMockup365/Online/OnlineDataService.cs new file mode 100644 index 00000000..e25b1927 --- /dev/null +++ b/src/XrmMockup365/Online/OnlineDataService.cs @@ -0,0 +1,63 @@ +#if DATAVERSE_SERVICE_CLIENT +using System; +using DataverseConnection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace DG.Tools.XrmMockup.Online +{ + /// + /// In-process implementation for connecting to a live Dataverse environment. + /// Used for testing with real data when OnlineEnvironment is configured. + /// Uses Azure DefaultAzureCredential for authentication (supports managed identity, + /// Visual Studio credentials, Azure CLI, etc.). + /// + internal class OnlineDataService : IOnlineDataService + { + private readonly ServiceProvider _serviceProvider; + private readonly ServiceClient _serviceClient; + private bool _disposed; + + public OnlineDataService(string environmentUrl) + { + if (string.IsNullOrWhiteSpace(environmentUrl)) + throw new ArgumentNullException(nameof(environmentUrl)); + + // Use DataverseConnection for authentication + var services = new ServiceCollection(); + services.AddDataverse(options => options.DataverseUrl = environmentUrl); + _serviceProvider = services.BuildServiceProvider(); + _serviceClient = _serviceProvider.GetRequiredService(); + } + + public bool IsConnected => _serviceClient?.IsReady == true; + + public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) + { + if (entityName == null) throw new ArgumentNullException(nameof(entityName)); + if (columnSet == null) throw new ArgumentNullException(nameof(columnSet)); + + return _serviceClient.Retrieve(entityName, id, columnSet); + } + + public EntityCollection RetrieveMultiple(QueryExpression query) + { + if (query == null) throw new ArgumentNullException(nameof(query)); + + return _serviceClient.RetrieveMultiple(query); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _serviceClient?.Dispose(); + _serviceProvider?.Dispose(); + } + } +} +#endif diff --git a/src/XrmMockup365/Online/ProxyDllFinder.cs b/src/XrmMockup365/Online/ProxyDllFinder.cs deleted file mode 100644 index 81c331f9..00000000 --- a/src/XrmMockup365/Online/ProxyDllFinder.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.IO; - -namespace DG.Tools.XrmMockup.Online -{ - /// - /// Finds the XrmMockup.DataverseProxy.dll using various search strategies. - /// - internal class ProxyDllFinder - { - internal const string ProxyDllName = "XrmMockup.DataverseProxy.dll"; - - private readonly IFileSystemHelper _fileSystem; - - public ProxyDllFinder() : this(new DefaultFileSystemHelper()) - { - } - - public ProxyDllFinder(IFileSystemHelper fileSystem) - { - _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); - } - - /// - /// Finds the proxy DLL using the search order: - /// 1. Explicit path (if provided) - /// 2. NuGet packages directory (version-specific) - /// 3. Same directory as XrmMockup365.dll - /// 4. tools/net8.0 subdirectory - /// 5. Parent directories (for development builds) - /// - /// Optional explicit path to the proxy DLL. - /// Full path to the proxy DLL. - /// Thrown if the proxy DLL cannot be found. - public string FindProxyDll(string explicitProxyPath = null) - { - // 1. Use explicit path if provided - if (!string.IsNullOrEmpty(explicitProxyPath)) - { - if (!_fileSystem.FileExists(explicitProxyPath)) - throw new FileNotFoundException(string.Format("Proxy DLL not found at: {0}", explicitProxyPath)); - return explicitProxyPath; - } - - // 2. Check NuGet packages directory - var nugetPath = FindInNuGetPackages(); - if (nugetPath != null) - { - return nugetPath; - } - - // 3. Check relative to XrmMockup365.dll (for development) - var assemblyLocation = _fileSystem.GetExecutingAssemblyLocation(); - var assemblyDir = Path.GetDirectoryName(assemblyLocation); - if (assemblyDir != null) - { - // Try dll in same directory - var dllPath = Path.Combine(assemblyDir, ProxyDllName); - if (_fileSystem.FileExists(dllPath)) - { - return dllPath; - } - - // Try in tools/net8.0 subdirectory (NuGet package structure) - var toolsDllPath = Path.Combine(assemblyDir, "tools", "net8.0", ProxyDllName); - if (_fileSystem.FileExists(toolsDllPath)) - { - return toolsDllPath; - } - - // Try in parent directories (for development) - var parentDir = _fileSystem.GetParentDirectory(assemblyDir); - while (parentDir != null) - { - var devPath = Path.Combine(parentDir, "XrmMockup.DataverseProxy", "bin", "Debug", "net8.0", ProxyDllName); - if (_fileSystem.FileExists(devPath)) - { - return devPath; - } - devPath = Path.Combine(parentDir, "XrmMockup.DataverseProxy", "bin", "Release", "net8.0", ProxyDllName); - if (_fileSystem.FileExists(devPath)) - { - return devPath; - } - parentDir = _fileSystem.GetParentDirectory(parentDir); - } - } - - throw new FileNotFoundException( - string.Format("Could not find XrmMockup Dataverse Proxy DLL ({0}). " + - "Configure OnlineEnvironment.proxyPath in XrmMockupSettings to specify the path explicitly.", - ProxyDllName)); - } - - /// - /// Attempts to find the proxy DLL in the NuGet packages directory. - /// Uses the assembly's informational version to locate the version-specific path. - /// - /// Path to the proxy DLL, or null if not found. - internal string FindInNuGetPackages() - { - // Get the version from the current assembly to find matching NuGet package - var informationalVersion = _fileSystem.GetAssemblyInformationalVersion(); - - // Strip any metadata suffix (e.g., "+abc123" or "-preview.1+abc123") - if (!string.IsNullOrEmpty(informationalVersion)) - { - var plusIndex = informationalVersion.IndexOf('+'); - if (plusIndex > 0) - { - informationalVersion = informationalVersion.Substring(0, plusIndex); - } - } - - if (string.IsNullOrEmpty(informationalVersion)) - return null; - - // Check NUGET_PACKAGES environment variable first, then fall back to default location - var nugetPackagesBase = _fileSystem.GetEnvironmentVariable("NUGET_PACKAGES"); - if (string.IsNullOrEmpty(nugetPackagesBase)) - { - var userProfile = _fileSystem.GetUserProfilePath(); - nugetPackagesBase = Path.Combine(userProfile, ".nuget", "packages"); - } - - var versionDir = Path.Combine(nugetPackagesBase, "xrmmockup365", informationalVersion); - if (!_fileSystem.DirectoryExists(versionDir)) - return null; - - var toolsDir = Path.Combine(versionDir, "tools", "net8.0"); - if (!_fileSystem.DirectoryExists(toolsDir)) - return null; - - var dllPath = Path.Combine(toolsDir, ProxyDllName); - if (_fileSystem.FileExists(dllPath)) - return dllPath; - - return null; - } - } -} diff --git a/src/XrmMockup365/Online/ProxyOnlineDataService.cs b/src/XrmMockup365/Online/ProxyOnlineDataService.cs deleted file mode 100644 index 0f865a92..00000000 --- a/src/XrmMockup365/Online/ProxyOnlineDataService.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System; -using System.IO; -using System.IO.Pipes; -using System.Linq; -using System.Text.Json; -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Query; -using XrmMockup.DataverseProxy.Contracts; - -namespace DG.Tools.XrmMockup.Online -{ - /// - /// Implementation of IOnlineDataService that communicates with an out-of-process - /// DataverseProxy via named pipes. - /// - internal class ProxyOnlineDataService : IOnlineDataService - { - private readonly ProxyProcessManager _processManager; - private NamedPipeClientStream _pipeClient; - private bool _disposed; - - public ProxyOnlineDataService(string environmentUrl, string proxyPath = null) - { - EnvironmentUrl = environmentUrl ?? throw new ArgumentNullException(nameof(environmentUrl)); - _processManager = new ProxyProcessManager(environmentUrl, proxyPath); - } - - public string EnvironmentUrl { get; } - - public bool IsConnected - { - get - { - try - { - EnsureConnected(); - return _pipeClient?.IsConnected == true; - } - catch - { - return false; - } - } - } - - public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) - { - if (entityName == null) throw new ArgumentNullException(nameof(entityName)); - if (columnSet == null) throw new ArgumentNullException(nameof(columnSet)); - - var retrieveRequest = new ProxyRetrieveRequest - { - EntityName = entityName, - Id = id, - Columns = columnSet.AllColumns ? null : columnSet.Columns.ToArray() - }; - - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - Payload = JsonSerializer.Serialize(retrieveRequest) - }; - - var response = SendRequest(request); - - if (!response.Success) - { - throw new InvalidOperationException(string.Format("Retrieve failed: {0}", response.ErrorMessage)); - } - - if (response.SerializedData == null) - { - throw new InvalidOperationException("Retrieve returned no data"); - } - - return EntitySerializationHelper.DeserializeEntity(response.SerializedData); - } - - public EntityCollection RetrieveMultiple(QueryExpression query) - { - if (query == null) throw new ArgumentNullException(nameof(query)); - - var serializedQuery = EntitySerializationHelper.SerializeQueryExpression(query); - var retrieveMultipleRequest = new ProxyRetrieveMultipleRequest - { - SerializedQuery = serializedQuery - }; - - var request = new ProxyRequest - { - RequestType = ProxyRequestType.RetrieveMultiple, - Payload = JsonSerializer.Serialize(retrieveMultipleRequest) - }; - - var response = SendRequest(request); - - if (!response.Success) - { - throw new InvalidOperationException(string.Format("RetrieveMultiple failed: {0}", response.ErrorMessage)); - } - - if (response.SerializedData == null) - { - return new EntityCollection(); - } - - return EntitySerializationHelper.DeserializeEntityCollection(response.SerializedData); - } - - private ProxyResponse SendRequest(ProxyRequest request) - { - EnsureConnected(); - - try - { - // Include authentication token in request - request.AuthToken = _processManager.AuthToken; - - var stream = _pipeClient; - - // Serialize request - var requestBytes = JsonSerializer.SerializeToUtf8Bytes(request); - - // Send message length + message - var lengthBytes = BitConverter.GetBytes(requestBytes.Length); - stream.Write(lengthBytes, 0, 4); - stream.Write(requestBytes, 0, requestBytes.Length); - stream.Flush(); - - // Read response length - var responseLengthBytes = new byte[4]; - var bytesRead = stream.Read(responseLengthBytes, 0, 4); - if (bytesRead < 4) - { - throw new IOException("Failed to read response length"); - } - - var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); - if (responseLength <= 0 || responseLength > 100 * 1024 * 1024) - { - throw new IOException(string.Format("Invalid response length: {0}", responseLength)); - } - - // Read response body - var responseBytes = new byte[responseLength]; - var totalRead = 0; - while (totalRead < responseLength) - { - bytesRead = stream.Read(responseBytes, totalRead, responseLength - totalRead); - if (bytesRead == 0) - break; - totalRead += bytesRead; - } - - if (totalRead < responseLength) - { - throw new IOException("Incomplete response received"); - } - - var response = JsonSerializer.Deserialize(responseBytes); - if (response == null) - { - throw new InvalidOperationException("Failed to deserialize proxy response"); - } - return response; - } - catch (IOException) - { - // Communication error - mark proxy as unhealthy and invalidate connection - _processManager.MarkUnhealthy(); - _pipeClient?.Dispose(); - _pipeClient = null; - throw; - } - } - - private void EnsureConnected() - { - if (_pipeClient?.IsConnected == true) - return; - - // Ensure proxy process is running - _processManager.EnsureRunning(); - - // Dispose any existing client before creating new one - _pipeClient?.Dispose(); - _pipeClient = null; - - // Create client in local variable first, only assign to field on complete success - var newClient = new NamedPipeClientStream( - ".", - _processManager.PipeName, - PipeDirection.InOut, - PipeOptions.None); - - try - { - newClient.Connect(timeout: 30000); // 30 second timeout - - // Send a ping to verify connection - var pingRequest = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = _processManager.AuthToken - }; - var pingBytes = JsonSerializer.SerializeToUtf8Bytes(pingRequest); - var lengthBytes = BitConverter.GetBytes(pingBytes.Length); - - newClient.Write(lengthBytes, 0, 4); - newClient.Write(pingBytes, 0, pingBytes.Length); - newClient.Flush(); - - // Read ping response - var responseLengthBytes = new byte[4]; - var bytesRead = newClient.Read(responseLengthBytes, 0, 4); - if (bytesRead < 4) - { - throw new IOException("Failed to read ping response length"); - } - - var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); - var responseBytes = new byte[responseLength]; - var totalRead = 0; - while (totalRead < responseLength) - { - bytesRead = newClient.Read(responseBytes, totalRead, responseLength - totalRead); - if (bytesRead == 0) - break; - totalRead += bytesRead; - } - - if (totalRead < responseLength) - { - throw new IOException("Incomplete ping response received"); - } - - var response = JsonSerializer.Deserialize(responseBytes); - if (response == null) - { - throw new InvalidOperationException("Failed to deserialize ping response"); - } - if (!response.Success) - { - throw new InvalidOperationException(string.Format("Ping failed: {0}", response.ErrorMessage)); - } - - // Only assign to field on complete success - _pipeClient = newClient; - } - catch - { - // Dispose on any failure to prevent resource leak - newClient.Dispose(); - - // Mark proxy as unhealthy so it gets restarted on next attempt - _processManager.MarkUnhealthy(); - - throw; - } - } - - public void Dispose() - { - if (_disposed) - return; - - _disposed = true; - - try - { - // Send shutdown request - if (_pipeClient?.IsConnected == true) - { - var shutdownRequest = new ProxyRequest - { - RequestType = ProxyRequestType.Shutdown, - AuthToken = _processManager.AuthToken - }; - var shutdownBytes = JsonSerializer.SerializeToUtf8Bytes(shutdownRequest); - var lengthBytes = BitConverter.GetBytes(shutdownBytes.Length); - - _pipeClient.Write(lengthBytes, 0, 4); - _pipeClient.Write(shutdownBytes, 0, shutdownBytes.Length); - _pipeClient.Flush(); - } - } - catch - { - // Ignore errors during shutdown - } - - _pipeClient?.Dispose(); - _processManager.Dispose(); - } - } -} diff --git a/src/XrmMockup365/Online/ProxyProcessManager.cs b/src/XrmMockup365/Online/ProxyProcessManager.cs deleted file mode 100644 index 2b75aee1..00000000 --- a/src/XrmMockup365/Online/ProxyProcessManager.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.IO; -using System.IO.Pipes; -using System.Security.Cryptography; -using System.Text; -using System.Threading; - -namespace DG.Tools.XrmMockup.Online -{ - /// - /// Manages the lifecycle of XrmMockup.DataverseProxy processes. - /// Uses a shared proxy per environment URL to support parallel test execution. - /// - internal class ProxyProcessManager : IDisposable - { - // Static registry of proxy processes by environment URL - private static readonly ConcurrentDictionary _sharedProxies = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private static bool _cleanupRegistered; - private static readonly object _cleanupLock = new object(); - - private static void EnsureCleanupRegistered() - { - if (_cleanupRegistered) - return; - - lock (_cleanupLock) - { - if (_cleanupRegistered) - return; - - // Register cleanup handler for when the process exits - AppDomain.CurrentDomain.ProcessExit += (sender, e) => ShutdownAllProxies(); - - // Also handle domain unload (relevant for test frameworks) - AppDomain.CurrentDomain.DomainUnload += (sender, e) => ShutdownAllProxies(); - - _cleanupRegistered = true; - } - } - - /// - /// Shuts down all running proxy processes. Called automatically on process exit. - /// - public static void ShutdownAllProxies() - { - foreach (var kvp in _sharedProxies) - { - kvp.Value.ForceShutdown(); - } - _sharedProxies.Clear(); - } - - private readonly string _environmentUrl; - private readonly string _explicitProxyPath; - private readonly string _pipeName; - private readonly SharedProxyState _sharedState; - private readonly ProxyDllFinder _dllFinder; - - public ProxyProcessManager(string environmentUrl, string explicitProxyPath = null) - : this(environmentUrl, explicitProxyPath, new ProxyDllFinder()) - { - } - - internal ProxyProcessManager(string environmentUrl, string explicitProxyPath, ProxyDllFinder dllFinder) - { - _environmentUrl = environmentUrl ?? throw new ArgumentNullException(nameof(environmentUrl)); - _explicitProxyPath = explicitProxyPath; - _dllFinder = dllFinder ?? throw new ArgumentNullException(nameof(dllFinder)); - _pipeName = GeneratePipeName(environmentUrl); - - // Ensure cleanup handlers are registered - EnsureCleanupRegistered(); - - // Get or create shared state for this environment URL - _sharedState = _sharedProxies.GetOrAdd(_environmentUrl, _ => new SharedProxyState()); - } - - /// - /// Gets the named pipe name used for communication. - /// - public string PipeName => _pipeName; - - /// - /// Gets the authentication token for the proxy. - /// - public string AuthToken => _sharedState.AuthToken; - - /// - /// Ensures the proxy process is running. - /// Thread-safe for parallel test execution. - /// - public void EnsureRunning() - { - _sharedState.EnsureRunning(_environmentUrl, _pipeName, StartProxyProcess); - } - - /// - /// Marks the proxy as unhealthy, forcing a restart on the next EnsureRunning call. - /// Call this when connection errors indicate the proxy is in a bad state. - /// - public void MarkUnhealthy() - { - _sharedState.MarkUnhealthy(); - } - - private Process StartProxyProcess(string environmentUrl, string pipeName, out string authToken) - { - var proxyPath = _dllFinder.FindProxyDll(_explicitProxyPath); - - // Generate cryptographically secure random token (256 bits = 32 bytes) - var tokenBytes = new byte[32]; - using (var rng = RandomNumberGenerator.Create()) - { - rng.GetBytes(tokenBytes); - } - authToken = Convert.ToBase64String(tokenBytes); - - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = string.Format("\"{0}\" --url \"{1}\" --pipe \"{2}\"", proxyPath, environmentUrl, pipeName), - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - RedirectStandardInput = true - }; - - var process = new Process { StartInfo = startInfo }; - - // Capture stdout and stderr asynchronously to avoid deadlocks and ensure error messages are captured - var stdout = new StringBuilder(); - var stderr = new StringBuilder(); - process.OutputDataReceived += (sender, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; - process.ErrorDataReceived += (sender, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - // Write token to stdin immediately after start (secure - not visible in process listings) - process.StandardInput.WriteLine(authToken); - process.StandardInput.Close(); - - // Wait for proxy to start by polling for pipe availability - var timeout = TimeSpan.FromSeconds(30); - var pollInterval = TimeSpan.FromMilliseconds(100); - var elapsed = TimeSpan.Zero; - - while (elapsed < timeout) - { - if (process.HasExited) - { - throw new InvalidOperationException(FormatProcessError(stderr, stdout)); - } - - try - { - using (var testClient = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.None)) - { - testClient.Connect(500); // 500ms connection timeout - return process; // Pipe is available, proxy is ready - } - } - catch (TimeoutException) - { - // Pipe not ready yet, continue polling - } - catch (IOException) - { - // Pipe not ready yet, continue polling - } - - Thread.Sleep(pollInterval); - elapsed += pollInterval; - } - - // Timeout reached - check one more time if process is still running - if (process.HasExited) - { - throw new InvalidOperationException(FormatProcessError(stderr, stdout)); - } - - throw new TimeoutException("Proxy process did not become available within timeout"); - } - - private static string FormatProcessError(StringBuilder stderr, StringBuilder stdout) - { - var errorText = stderr.ToString().Trim(); - var outputText = stdout.ToString().Trim(); - - if (!string.IsNullOrEmpty(errorText) && !string.IsNullOrEmpty(outputText)) - { - return string.Format("Proxy process exited.\nStderr: {0}\nStdout: {1}", errorText, outputText); - } - if (!string.IsNullOrEmpty(errorText)) - { - return string.Format("Proxy process exited. Error: {0}", errorText); - } - if (!string.IsNullOrEmpty(outputText)) - { - return string.Format("Proxy process exited. Output: {0}", outputText); - } - return "Proxy process exited with no output."; - } - - internal static string GeneratePipeName(string environmentUrl) - { - // Generate a deterministic pipe name based on the environment URL. - // Uses SHA256 to ensure the hash is stable across processes/restarts. - // All XrmMockup instances targeting the same URL share the same proxy process. - using (var sha256 = SHA256.Create()) - { - var bytes = Encoding.UTF8.GetBytes(environmentUrl.ToLowerInvariant()); - var hash = sha256.ComputeHash(bytes); - // Use first 8 bytes as hex (16 chars) for reasonable uniqueness - var hashPrefix = BitConverter.ToString(hash, 0, 8).Replace("-", ""); - return string.Format("XrmMockupProxy_{0}", hashPrefix); - } - } - - public void Dispose() - { - // No-op: Proxy lifecycle is managed at the process level, not per-instance. - // The proxy stays alive for the duration of the test run and is cleaned up - // by ProcessExit/DomainUnload handlers, or restarted if marked unhealthy. - } - } - - /// - /// Delegate for starting proxy process with token output. - /// - internal delegate Process StartProxyDelegate(string environmentUrl, string pipeName, out string authToken); - - /// - /// Thread-safe shared state for a proxy process. - /// Proxy lives for the duration of the test run - /// - internal class SharedProxyState - { - private readonly object _lock = new object(); - private Process _process; - private string _authToken; - private bool _markedUnhealthy; - - /// - /// Gets the authentication token for the proxy. - /// - public string AuthToken - { - get - { - lock (_lock) - { - return _authToken; - } - } - } - - /// - /// Marks the proxy as unhealthy, forcing a restart on next EnsureRunning call. - /// Call this when connection errors indicate the proxy is in a bad state. - /// - public void MarkUnhealthy() - { - lock (_lock) - { - _markedUnhealthy = true; - } - } - - /// - /// Ensures the proxy process is running. Thread-safe. - /// - public void EnsureRunning(string environmentUrl, string pipeName, StartProxyDelegate startProcess) - { - lock (_lock) - { - // Check if process is healthy (running and not marked unhealthy) - if (_process != null && !_process.HasExited && !_markedUnhealthy) - return; - - // Process not running, exited, or marked unhealthy - need to (re)start - if (_process != null) - { - try - { - if (!_process.HasExited) - { - _process.Kill(); - _process.WaitForExit(5000); - } - } - catch { } - - try { _process.Dispose(); } catch { } - _process = null; - } - - _markedUnhealthy = false; - _process = startProcess(environmentUrl, pipeName, out _authToken); - } - } - - /// - /// Shuts down the proxy process. Called during process exit cleanup. - /// - public void ForceShutdown() - { - lock (_lock) - { - if (_process == null) - return; - - try - { - if (!_process.HasExited) - { - _process.Kill(); - _process.WaitForExit(5000); - } - } - catch - { - // Ignore errors during shutdown - } - - try { _process.Dispose(); } catch { } - _process = null; - } - } - } -} diff --git a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs index dc7c1797..eaac14dd 100644 --- a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs +++ b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs @@ -40,7 +40,9 @@ internal override OrganizationResponse Execute(OrganizationRequest orgRequest, E FillAliasIfEmpty(queryExpr); +#if DATAVERSE_SERVICE_CLIENT db.PrefillDBWithOnlineData(queryExpr); +#endif // Create a snapshot for thread-safe enumeration during calculated field execution var rows = db.GetDBEntityRows(queryExpr.EntityName).ToList(); diff --git a/src/XrmMockup365/StaticMetadataCache.cs b/src/XrmMockup365/StaticMetadataCache.cs index 7a081a6c..d1197875 100644 --- a/src/XrmMockup365/StaticMetadataCache.cs +++ b/src/XrmMockup365/StaticMetadataCache.cs @@ -1,7 +1,9 @@ using Microsoft.Xrm.Sdk; using System; using System.Collections.Generic; +#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; +#endif namespace DG.Tools.XrmMockup { @@ -13,6 +15,7 @@ public class StaticMetadataCache public Dictionary EntityTypeMap { get; } public EntityReference BaseCurrency { get; } public int BaseCurrencyPrecision { get; } +#if DATAVERSE_SERVICE_CLIENT internal IOnlineDataService OnlineDataService { get; } internal StaticMetadataCache(MetadataSkeleton metadata, List workflows, List securityRoles, @@ -27,5 +30,17 @@ internal StaticMetadataCache(MetadataSkeleton metadata, List workflows, BaseCurrencyPrecision = baseCurrencyPrecision; OnlineDataService = onlineDataService; } +#else + internal StaticMetadataCache(MetadataSkeleton metadata, List workflows, List securityRoles, + Dictionary entityTypeMap, EntityReference baseCurrency, int baseCurrencyPrecision) + { + Metadata = metadata; + Workflows = workflows; + SecurityRoles = securityRoles; + EntityTypeMap = entityTypeMap; + BaseCurrency = baseCurrency; + BaseCurrencyPrecision = baseCurrencyPrecision; + } +#endif } } diff --git a/src/XrmMockup365/XrmMockup.cs b/src/XrmMockup365/XrmMockup.cs index aefe32c4..8ad483a2 100644 --- a/src/XrmMockup365/XrmMockup.cs +++ b/src/XrmMockup365/XrmMockup.cs @@ -72,15 +72,26 @@ public static XrmMockup365 GetInstance(XrmMockup365 xrmMockup, XrmMockupSettings else { // Create a new cache entry using the existing instance's data +#if DATAVERSE_SERVICE_CLIENT cache = new StaticMetadataCache( - xrmMockup.Metadata, - xrmMockup.Workflows, + xrmMockup.Metadata, + xrmMockup.Workflows, xrmMockup.SecurityRoles, new Dictionary(), // Will be rebuilt if needed xrmMockup.BaseCurrency, 0, // Will be retrieved from metadata null // Will be rebuilt if needed ); +#else + cache = new StaticMetadataCache( + xrmMockup.Metadata, + xrmMockup.Workflows, + xrmMockup.SecurityRoles, + new Dictionary(), // Will be rebuilt if needed + xrmMockup.BaseCurrency, + 0 // Will be retrieved from metadata + ); +#endif metadataCache[effectiveSettings] = cache; } } diff --git a/src/XrmMockup365/XrmMockup365.csproj b/src/XrmMockup365/XrmMockup365.csproj index 489de547..bd9b6b38 100644 --- a/src/XrmMockup365/XrmMockup365.csproj +++ b/src/XrmMockup365/XrmMockup365.csproj @@ -66,33 +66,19 @@
- + + - - - <_Parameter1>XrmMockup.DataverseProxy.Tests - <_Parameter1>XrmMockup365Test - - - - - <_DataverseProxyFiles Include="..\XrmMockup.DataverseProxy\bin\$(Configuration)\net8.0\**\*" - Condition="Exists('..\XrmMockup.DataverseProxy\bin\$(Configuration)\net8.0\')" /> - - -
diff --git a/src/XrmMockup365/XrmMockupBase.cs b/src/XrmMockup365/XrmMockupBase.cs index 4202d1d5..13f01bc8 100644 --- a/src/XrmMockup365/XrmMockupBase.cs +++ b/src/XrmMockup365/XrmMockupBase.cs @@ -285,6 +285,7 @@ public void PopulateWith(params Entity[] entities) { Core.PopulateWith(entities); } +#if DATAVERSE_SERVICE_CLIENT /// /// Prefills the local database with data from the online service based on the query. /// Only works when OnlineDataServiceFactory or OnlineEnvironment is configured. @@ -293,6 +294,7 @@ public void PopulateWith(params Entity[] entities) { public void PrefillDBWithOnlineData(QueryExpression query) { Core.PrefillDBWithOnlineData(query); } +#endif /// /// Create a new user with a specific businessunit diff --git a/src/XrmMockup365/XrmMockupSettings.cs b/src/XrmMockup365/XrmMockupSettings.cs index ce990991..78a61e6b 100644 --- a/src/XrmMockup365/XrmMockupSettings.cs +++ b/src/XrmMockup365/XrmMockupSettings.cs @@ -1,8 +1,10 @@ -using System; +using System; using System.Collections.Generic; using Microsoft.Xrm.Sdk.Organization; using System.Reflection; +#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; +#endif namespace DG.Tools.XrmMockup { @@ -43,12 +45,14 @@ public class XrmMockupSettings /// public IEnumerable ExceptionFreeRequests { get; set; } +#if DATAVERSE_SERVICE_CLIENT /// /// Settings for connecting to an online Dataverse environment for live debugging. /// Uses Azure DefaultAzureCredential for authentication (supports managed identity, /// Visual Studio credentials, Azure CLI, etc.). /// public Env? OnlineEnvironment { get; set; } +#endif /// /// Overwrites the path to the directory containing metadata files. Default is '../../Metadata/'. @@ -101,13 +105,16 @@ public class XrmMockupSettings /// public bool EnablePowerFxFields { get; set; } = true; +#if DATAVERSE_SERVICE_CLIENT /// /// Optional factory for creating IOnlineDataService. For testing purposes. /// If set, this takes precedence over OnlineEnvironment. /// internal Func OnlineDataServiceFactory { get; set; } +#endif } +#if DATAVERSE_SERVICE_CLIENT /// /// Settings for connecting to an online Dataverse environment. /// @@ -118,10 +125,6 @@ public struct Env /// Uses Azure DefaultAzureCredential for authentication. /// public string Url; - - /// - /// Optional path to the proxy DLL. If not specified, auto-discovery is used. - /// - public string ProxyPath; } -} \ No newline at end of file +#endif +} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs deleted file mode 100644 index 792cfb84..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/ProxyServerTestBase.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.PowerPlatform.Dataverse.Client; -using NSubstitute; -using Xunit; - -namespace XrmMockup.DataverseProxy.Tests.Fixtures; - -/// -/// Base class for ProxyServer tests. -/// Provides a mock IOrganizationServiceAsync2 and starts the server on a unique pipe name. -/// -public abstract class ProxyServerTestBase : IAsyncLifetime -{ - protected IOrganizationServiceAsync2 MockOrganizationService { get; private set; } = null!; - protected string PipeName { get; private set; } = null!; - protected string AuthToken { get; } = "test-auth-token-12345"; - protected ProxyServer Server { get; private set; } = null!; - protected CancellationTokenSource ServerCts { get; private set; } = null!; - protected Task ServerTask { get; private set; } = null!; - protected ILogger Logger { get; private set; } = null!; - - public virtual async Task InitializeAsync() - { - // Generate unique pipe name for this test - PipeName = $"XrmMockupTest_{Guid.NewGuid():N}"; - - // Create mock IOrganizationServiceAsync2 - MockOrganizationService = Substitute.For(); - - // Create logger (use NullLogger for quiet tests, or real logger for debugging) - Logger = NullLogger.Instance; - - // Create and start the server using factory pattern - var factory = new TestDataverseServiceFactory(MockOrganizationService); - Server = new ProxyServer(factory, PipeName, AuthToken, Logger); - ServerCts = new CancellationTokenSource(); - ServerTask = Server.RunAsync(ServerCts.Token); - - // Give the server a moment to start accepting connections - await Task.Delay(50); - } - - public virtual async Task DisposeAsync() - { - // Signal server to stop - ServerCts.Cancel(); - - // Wait for server to shut down (with timeout) - try - { - await Task.WhenAny(ServerTask, Task.Delay(2000)); - } - catch (OperationCanceledException) - { - // Expected - } - - ServerCts.Dispose(); - } - - /// - /// Creates a connected TestPipeClient for this test's server. - /// - protected async Task CreateConnectedClientAsync(int timeoutMs = 5000) - { - var client = new TestPipeClient(PipeName); - await client.ConnectAsync(timeoutMs); - return client; - } -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs deleted file mode 100644 index 3b7b2e48..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestDataverseServiceFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.PowerPlatform.Dataverse.Client; -using XrmMockup.DataverseProxy; - -namespace XrmMockup.DataverseProxy.Tests.Fixtures; - -/// -/// Test factory that wraps a mock IOrganizationServiceAsync2. -/// -public class TestDataverseServiceFactory : IDataverseServiceFactory -{ - private readonly IOrganizationServiceAsync2 _service; - - public TestDataverseServiceFactory(IOrganizationServiceAsync2 service) - { - _service = service ?? throw new ArgumentNullException(nameof(service)); - } - - public IOrganizationServiceAsync2 CreateService() => _service; -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs b/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs deleted file mode 100644 index af6b3430..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Fixtures/TestPipeClient.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.IO.Pipes; -using System.Text; -using System.Text.Json; -using XrmMockup.DataverseProxy.Contracts; - -namespace XrmMockup.DataverseProxy.Tests.Fixtures; - -/// -/// Test helper for raw pipe message communication. -/// Allows sending valid/malformed messages for protocol-level testing. -/// -public class TestPipeClient : IDisposable -{ - private readonly NamedPipeClientStream _pipe; - private bool _disposed; - - public TestPipeClient(string pipeName) - { - _pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); - } - - public async Task ConnectAsync(int timeoutMs = 5000, CancellationToken cancellationToken = default) - { - await _pipe.ConnectAsync(timeoutMs, cancellationToken); - } - - public bool IsConnected => _pipe.IsConnected; - - /// - /// Sends a properly framed ProxyRequest message. - /// - public async Task SendRequestAsync(ProxyRequest request, CancellationToken cancellationToken = default) - { - var requestBytes = JsonSerializer.SerializeToUtf8Bytes(request); - await SendFramedMessageAsync(requestBytes, cancellationToken); - } - - /// - /// Sends raw bytes with a 4-byte length prefix. - /// - public async Task SendFramedMessageAsync(byte[] messageBytes, CancellationToken cancellationToken = default) - { - var lengthBytes = BitConverter.GetBytes(messageBytes.Length); - await _pipe.WriteAsync(lengthBytes, 0, 4, cancellationToken); - await _pipe.WriteAsync(messageBytes, 0, messageBytes.Length, cancellationToken); - await _pipe.FlushAsync(cancellationToken); - } - - /// - /// Sends a raw 4-byte length prefix without any message body. - /// Useful for testing incomplete message handling. - /// - public async Task SendLengthOnlyAsync(int length, CancellationToken cancellationToken = default) - { - var lengthBytes = BitConverter.GetBytes(length); - await _pipe.WriteAsync(lengthBytes, 0, 4, cancellationToken); - await _pipe.FlushAsync(cancellationToken); - } - - /// - /// Sends raw bytes without any length prefix. - /// - public async Task SendRawBytesAsync(byte[] bytes, CancellationToken cancellationToken = default) - { - await _pipe.WriteAsync(bytes, 0, bytes.Length, cancellationToken); - await _pipe.FlushAsync(cancellationToken); - } - - /// - /// Reads a ProxyResponse from the pipe. - /// - public async Task ReadResponseAsync(CancellationToken cancellationToken = default) - { - // Read length prefix - var lengthBytes = new byte[4]; - var bytesRead = await _pipe.ReadAsync(lengthBytes, 0, 4, cancellationToken); - if (bytesRead < 4) - { - return null; - } - - var messageLength = BitConverter.ToInt32(lengthBytes, 0); - if (messageLength <= 0 || messageLength > 100 * 1024 * 1024) - { - return null; - } - - // Read message body - var messageBytes = new byte[messageLength]; - var totalRead = 0; - while (totalRead < messageLength) - { - bytesRead = await _pipe.ReadAsync(messageBytes, totalRead, messageLength - totalRead, cancellationToken); - if (bytesRead == 0) - break; - totalRead += bytesRead; - } - - if (totalRead < messageLength) - { - return null; - } - - return JsonSerializer.Deserialize(messageBytes); - } - - /// - /// Reads raw bytes from the pipe. - /// - public async Task ReadRawBytesAsync(byte[] buffer, CancellationToken cancellationToken = default) - { - return await _pipe.ReadAsync(buffer, 0, buffer.Length, cancellationToken); - } - - public void Dispose() - { - if (_disposed) - return; - - _disposed = true; - _pipe.Dispose(); - } -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs deleted file mode 100644 index 0fb5aa89..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Integration/ProxyIntegrationTests.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.PowerPlatform.Dataverse.Client; -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Query; -using NSubstitute; -using Xunit; -using XrmMockup.DataverseProxy.Contracts; -using XrmMockup.DataverseProxy.Tests.Fixtures; - -namespace XrmMockup.DataverseProxy.Tests.Integration; - -/// -/// End-to-end integration tests using a mock IOrganizationServiceAsync2 as the backend. -/// Tests the full proxy communication flow. -/// -[Trait("Category", "Integration")] -public class ProxyIntegrationTests : IAsyncLifetime -{ - private IOrganizationServiceAsync2 _mockOrganizationService = null!; - private string _pipeName = null!; - private string _authToken = null!; - private ProxyServer _server = null!; - private CancellationTokenSource _serverCts = null!; - private Task _serverTask = null!; - - public async Task InitializeAsync() - { - _pipeName = $"XrmMockupIntegration_{Guid.NewGuid():N}"; - _authToken = "integration-test-token"; - _mockOrganizationService = Substitute.For(); - var factory = new TestDataverseServiceFactory(_mockOrganizationService); - _server = new ProxyServer(factory, _pipeName, _authToken, NullLogger.Instance); - _serverCts = new CancellationTokenSource(); - _serverTask = _server.RunAsync(_serverCts.Token); - - await Task.Delay(100); // Give server time to start - } - - public async Task DisposeAsync() - { - _serverCts.Cancel(); - try - { - await Task.WhenAny(_serverTask, Task.Delay(2000)); - } - catch (OperationCanceledException) { } - _serverCts.Dispose(); - } - - [Fact] - public async Task Retrieve_ThroughProxy_ReturnsEntity() - { - // Arrange - var entityId = Guid.NewGuid(); - var expectedEntity = new Entity("account", entityId) - { - ["name"] = "Test Account", - ["accountnumber"] = "ACC-001" - }; - - _mockOrganizationService.RetrieveAsync("account", entityId, Arg.Any()) - .Returns(Task.FromResult(expectedEntity)); - - using var client = new TestPipeClient(_pipeName); - await client.ConnectAsync(); - - var retrieveRequest = new ProxyRetrieveRequest - { - EntityName = "account", - Id = entityId, - Columns = new[] { "name", "accountnumber" } - }; - - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - AuthToken = _authToken, - Payload = JsonSerializer.Serialize(retrieveRequest) - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success, $"Expected success but got error: {response.ErrorMessage}"); - Assert.NotNull(response.SerializedData); - - await _mockOrganizationService.Received(1).RetrieveAsync("account", entityId, Arg.Any()); - } - - [Fact] - public async Task RetrieveMultiple_ThroughProxy_ReturnsCollection() - { - // Arrange - var entity1 = new Entity("account", Guid.NewGuid()) { ["name"] = "Account 1" }; - var entity2 = new Entity("account", Guid.NewGuid()) { ["name"] = "Account 2" }; - var expectedCollection = new EntityCollection(new List { entity1, entity2 }); - - _mockOrganizationService.RetrieveMultipleAsync(Arg.Any()) - .Returns(Task.FromResult(expectedCollection)); - - using var client = new TestPipeClient(_pipeName); - await client.ConnectAsync(); - - // Create a QueryExpression and serialize it - var query = new QueryExpression("account") - { - ColumnSet = new ColumnSet("name") - }; - var serializedQuery = EntitySerializationHelper.SerializeQueryExpression(query); - - var retrieveMultipleRequest = new ProxyRetrieveMultipleRequest - { - SerializedQuery = serializedQuery - }; - - var request = new ProxyRequest - { - RequestType = ProxyRequestType.RetrieveMultiple, - AuthToken = _authToken, - Payload = JsonSerializer.Serialize(retrieveMultipleRequest) - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success, $"Expected success but got error: {response.ErrorMessage}"); - Assert.NotNull(response.SerializedData); - - await _mockOrganizationService.Received(1).RetrieveMultipleAsync(Arg.Any()); - } - - [Fact] - public async Task InvalidToken_ThroughProxy_Rejected() - { - // Arrange - using var client = new TestPipeClient(_pipeName); - await client.ConnectAsync(); - - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = "wrong-token" - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Authentication failed", response.ErrorMessage); - } - - [Fact] - public async Task MultipleClients_ConcurrentRequests_AllProcessed() - { - // Arrange - var entityId = Guid.NewGuid(); - var expectedEntity = new Entity("account", entityId) { ["name"] = "Test Account" }; - - _mockOrganizationService.RetrieveAsync("account", entityId, Arg.Any()) - .Returns(Task.FromResult(expectedEntity)); - - var clientCount = 5; - var tasks = new List>(); - - // Act - create multiple clients and send requests concurrently - for (int i = 0; i < clientCount; i++) - { - tasks.Add(Task.Run(async () => - { - using var client = new TestPipeClient(_pipeName); - await client.ConnectAsync(); - - var retrieveRequest = new ProxyRetrieveRequest - { - EntityName = "account", - Id = entityId, - Columns = null // All columns - }; - - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - AuthToken = _authToken, - Payload = JsonSerializer.Serialize(retrieveRequest) - }; - - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - return response?.Success ?? false; - })); - } - - var results = await Task.WhenAll(tasks); - - // Assert - all requests should succeed - Assert.All(results, success => Assert.True(success)); - } - - [Fact] - public async Task Retrieve_ServiceClientThrows_ReturnsErrorResponse() - { - // Arrange - var entityId = Guid.NewGuid(); - _mockOrganizationService.RetrieveAsync("account", entityId, Arg.Any()) - .Returns(x => throw new InvalidOperationException("Service unavailable")); - - using var client = new TestPipeClient(_pipeName); - await client.ConnectAsync(); - - var retrieveRequest = new ProxyRetrieveRequest - { - EntityName = "account", - Id = entityId, - Columns = null - }; - - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - AuthToken = _authToken, - Payload = JsonSerializer.Serialize(retrieveRequest) - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Contains("Service unavailable", response.ErrorMessage); - } - - [Fact] - public async Task FullRoundTrip_PingShutdown() - { - // Arrange - using var client = new TestPipeClient(_pipeName); - await client.ConnectAsync(); - - // Act - send ping - var pingRequest = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = _authToken - }; - await client.SendRequestAsync(pingRequest); - var pingResponse = await client.ReadResponseAsync(); - - // Assert ping - Assert.NotNull(pingResponse); - Assert.True(pingResponse.Success); - - // Act - send shutdown - var shutdownRequest = new ProxyRequest - { - RequestType = ProxyRequestType.Shutdown, - AuthToken = _authToken - }; - await client.SendRequestAsync(shutdownRequest); - var shutdownResponse = await client.ReadResponseAsync(); - - // Assert shutdown - Assert.NotNull(shutdownResponse); - Assert.True(shutdownResponse.Success); - } -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs deleted file mode 100644 index 3b41488c..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Server/AuthenticationTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Xunit; -using XrmMockup.DataverseProxy.Contracts; -using XrmMockup.DataverseProxy.Tests.Fixtures; - -namespace XrmMockup.DataverseProxy.Tests.Server; - -/// -/// Tests for authentication token validation. -/// -[Trait("Category", "Unit")] -public class AuthenticationTests : ProxyServerTestBase -{ - [Fact] - public async Task ValidToken_ProcessesRequest() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = AuthToken - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success, $"Expected success but got error: {response.ErrorMessage}"); - } - - [Fact] - public async Task InvalidToken_ReturnsAuthFailure() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = "wrong-token" - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Authentication failed", response.ErrorMessage); - } - - [Fact] - public async Task NullToken_ReturnsAuthFailure() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = null - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Authentication failed", response.ErrorMessage); - } - - [Fact] - public async Task EmptyToken_ReturnsAuthFailure() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = "" - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Authentication failed", response.ErrorMessage); - } -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs deleted file mode 100644 index fb8091b9..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Server/MessageFramingTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Text; -using System.Text.Json; -using Xunit; -using XrmMockup.DataverseProxy.Contracts; -using XrmMockup.DataverseProxy.Tests.Fixtures; - -namespace XrmMockup.DataverseProxy.Tests.Server; - -/// -/// Tests for the length-prefix message framing protocol. -/// -[Trait("Category", "Unit")] -public class MessageFramingTests : ProxyServerTestBase -{ - [Fact] - public async Task ValidFrame_ProcessedCorrectly() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = AuthToken - }; - var requestBytes = JsonSerializer.SerializeToUtf8Bytes(request); - - // Act - send with proper 4-byte length prefix - await client.SendFramedMessageAsync(requestBytes); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success); - } - - [Fact] - public async Task ZeroLength_HandledGracefully() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - - // Act - send length of 0, server should log warning and continue - await client.SendLengthOnlyAsync(0); - - // Give server time to process - await Task.Delay(100); - - // Send a valid request to verify server is still operational - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = AuthToken - }; - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - server should still respond to valid request - Assert.NotNull(response); - Assert.True(response.Success); - } - - [Fact] - public async Task NegativeLength_Rejected() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - - // Act - send negative length (-1) - await client.SendRawBytesAsync(BitConverter.GetBytes(-1)); - - // Give server time to process - await Task.Delay(100); - - // Send a valid request to verify server is still operational - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = AuthToken - }; - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - server should still respond to valid request - Assert.NotNull(response); - Assert.True(response.Success); - } - - [Fact] - public async Task ExcessiveLength_Rejected() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - - // Act - send length > 100MB - var excessiveLength = 101 * 1024 * 1024; - await client.SendLengthOnlyAsync(excessiveLength); - - // Give server time to process - await Task.Delay(100); - - // Send a valid request to verify server is still operational - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = AuthToken - }; - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - server should still respond to valid request - Assert.NotNull(response); - Assert.True(response.Success); - } - - [Fact] - public async Task IncompleteMessageBody_HandledGracefully() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - - // Act - send length prefix indicating 100 bytes, but only send 10 bytes - var lengthBytes = BitConverter.GetBytes(100); - await client.SendRawBytesAsync(lengthBytes); - await client.SendRawBytesAsync(new byte[10]); // Only 10 bytes instead of 100 - - // Give server time to process the incomplete message - await Task.Delay(200); - - // The connection may be broken at this point, which is acceptable behavior - // The key is that the server doesn't crash - Assert.True(true, "Server handled incomplete message without crashing"); - } - - [Fact] - public async Task MultipleSequentialRequests_AllProcessed() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - - // Act - send multiple requests in sequence - for (int i = 0; i < 5; i++) - { - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = AuthToken - }; - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success, $"Request {i} failed: {response.ErrorMessage}"); - } - } -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs deleted file mode 100644 index d60572d3..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Server/RequestHandlerTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System.Text.Json; -using Xunit; -using XrmMockup.DataverseProxy.Contracts; -using XrmMockup.DataverseProxy.Tests.Fixtures; - -namespace XrmMockup.DataverseProxy.Tests.Server; - -/// -/// Tests for request processing logic. -/// -[Trait("Category", "Unit")] -public class RequestHandlerTests : ProxyServerTestBase -{ - [Fact] - public async Task Ping_ReturnsSuccess() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = AuthToken - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success); - Assert.Null(response.ErrorMessage); - } - - [Fact] - public async Task Retrieve_EmptyPayload_ReturnsError() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - AuthToken = AuthToken, - Payload = null - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Empty payload", response.ErrorMessage); - } - - [Fact] - public async Task Retrieve_EmptyStringPayload_ReturnsError() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - AuthToken = AuthToken, - Payload = "" - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Empty payload", response.ErrorMessage); - } - - [Fact] - public async Task RetrieveMultiple_EmptyPayload_ReturnsError() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.RetrieveMultiple, - AuthToken = AuthToken, - Payload = null - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Empty payload", response.ErrorMessage); - } - - [Fact] - public async Task RetrieveMultiple_EmptyStringPayload_ReturnsError() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.RetrieveMultiple, - AuthToken = AuthToken, - Payload = "" - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Equal("Empty payload", response.ErrorMessage); - } - - [Fact] - public async Task Shutdown_ReturnsSuccessAndCloses() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Shutdown, - AuthToken = AuthToken - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success); - - // Give server time to close the connection - await Task.Delay(100); - - // Verify the connection is closed by trying to read (should get 0 bytes or exception) - var buffer = new byte[4]; - var bytesRead = await client.ReadRawBytesAsync(buffer); - Assert.Equal(0, bytesRead); - } - - [Fact] - public async Task UnknownRequestType_ReturnsError() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = (ProxyRequestType)99, // Unknown type - AuthToken = AuthToken - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - Assert.Contains("Unknown request type", response.ErrorMessage); - } - - [Fact] - public async Task Retrieve_InvalidPayloadJson_ReturnsError() - { - // Arrange - using var client = await CreateConnectedClientAsync(); - var request = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - AuthToken = AuthToken, - Payload = "not valid json {" - }; - - // Act - await client.SendRequestAsync(request); - var response = await client.ReadResponseAsync(); - - // Assert - Assert.NotNull(response); - Assert.False(response.Success); - // Error message will contain JSON parsing exception details - Assert.NotNull(response.ErrorMessage); - } -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs b/tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs deleted file mode 100644 index 1bd3847d..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/Startup/ProgramStartupTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System.Diagnostics; -using System.Reflection; -using Xunit; - -namespace XrmMockup.DataverseProxy.Tests.Startup; - -/// -/// Tests for Program.cs startup and CLI argument handling. -/// These tests verify that the proxy process starts correctly with various arguments. -/// -[Trait("Category", "Startup")] -public class ProgramStartupTests -{ - private static string GetProxyDllPath() - { - // Find the proxy DLL relative to the test assembly - var testAssemblyPath = Assembly.GetExecutingAssembly().Location; - var testDir = Path.GetDirectoryName(testAssemblyPath)!; - - // Navigate from test output to proxy output - // tests/XrmMockup.DataverseProxy.Tests/bin/Debug/net8.0 -> src/XrmMockup.DataverseProxy/bin/Debug/net8.0 - var solutionRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "..")); - var proxyPath = Path.Combine(solutionRoot, "src", "XrmMockup.DataverseProxy", "bin", "Debug", "net8.0", "XrmMockup.DataverseProxy.dll"); - - if (!File.Exists(proxyPath)) - { - // Try Release configuration - proxyPath = Path.Combine(solutionRoot, "src", "XrmMockup.DataverseProxy", "bin", "Release", "net8.0", "XrmMockup.DataverseProxy.dll"); - } - - return proxyPath; - } - - [Fact] - public async Task Startup_WithUrl_PassesUrlToDataverseOptions() - { - // Arrange - var proxyPath = GetProxyDllPath(); - if (!File.Exists(proxyPath)) - { - // Skip if proxy not built - return; - } - - var pipeName = $"XrmMockupStartupTest_{Guid.NewGuid():N}"; - var testUrl = "https://test-org.crm4.dynamics.com"; - - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"\"{proxyPath}\" --url \"{testUrl}\" --pipe \"{pipeName}\"", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - RedirectStandardInput = true - }; - - using var process = new Process { StartInfo = startInfo }; - var stdout = new List(); - var stderr = new List(); - - process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.Add(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.Add(e.Data); }; - - // Act - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - // Write auth token to stdin - await process.StandardInput.WriteLineAsync("test-auth-token"); - process.StandardInput.Close(); - - // Wait for process to exit (it will fail at authentication, which is expected) - var exited = process.WaitForExit(30000); - if (!exited) - { - process.Kill(); - } - - var allOutput = string.Join("\n", stdout.Concat(stderr)); - - // Assert - // The process should fail at authentication, NOT at "DataverseUrl must be provided" - // This verifies that the URL was correctly passed to DataverseOptions - Assert.DoesNotContain("DataverseUrl must be provided", allOutput); - - // It should show the URL in the startup message (proving the URL was received) - Assert.Contains(testUrl, allOutput); - } - - [Fact] - public async Task Startup_WithoutUrl_AndWithoutMockData_FailsWithMissingUrlError() - { - // Arrange - var proxyPath = GetProxyDllPath(); - if (!File.Exists(proxyPath)) - { - return; - } - - var pipeName = $"XrmMockupStartupTest_{Guid.NewGuid():N}"; - - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"\"{proxyPath}\" --pipe \"{pipeName}\"", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - RedirectStandardInput = true - }; - - using var process = new Process { StartInfo = startInfo }; - var stderr = new List(); - - process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.Add(e.Data); }; - - // Act - process.Start(); - process.BeginErrorReadLine(); - - // Write auth token - await process.StandardInput.WriteLineAsync("test-auth-token"); - process.StandardInput.Close(); - - process.WaitForExit(10000); - - var errorOutput = string.Join("\n", stderr); - - // Assert - should fail with clear error about missing URL - Assert.Contains("--url is required", errorOutput); - } - - [Fact] - public async Task Startup_WithMockDataFile_DoesNotRequireUrl() - { - // Arrange - var proxyPath = GetProxyDllPath(); - if (!File.Exists(proxyPath)) - { - return; - } - - var pipeName = $"XrmMockupStartupTest_{Guid.NewGuid():N}"; - var tempFile = Path.GetTempFileName(); - - try - { - // Create minimal mock data file - await File.WriteAllTextAsync(tempFile, "{\"Entities\":[]}"); - - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"\"{proxyPath}\" --mock-data-file \"{tempFile}\" --pipe \"{pipeName}\"", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - RedirectStandardInput = true - }; - - using var process = new Process { StartInfo = startInfo }; - var stdout = new List(); - - process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.Add(e.Data); }; - - // Act - process.Start(); - process.BeginOutputReadLine(); - - // Write auth token - await process.StandardInput.WriteLineAsync("test-auth-token"); - process.StandardInput.Close(); - - // Give it time to start - await Task.Delay(2000); - - var isRunning = !process.HasExited; - - // Cleanup - if (!process.HasExited) - { - process.Kill(); - process.WaitForExit(5000); - } - - var output = string.Join("\n", stdout); - - // Assert - should start successfully in mock mode - Assert.True(isRunning || output.Contains("mock mode"), - $"Process should start in mock mode. Output: {output}"); - } - finally - { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } - } - } -} diff --git a/tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj b/tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj deleted file mode 100644 index 75c660b0..00000000 --- a/tests/XrmMockup.DataverseProxy.Tests/XrmMockup.DataverseProxy.Tests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net8.0 - enable - enable - false - XrmMockup.DataverseProxy.Tests - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/tests/XrmMockup365Test/Online/MockOnlineDataService.cs b/tests/XrmMockup365Test/Online/MockOnlineDataService.cs index 53a0e06f..29523ce9 100644 --- a/tests/XrmMockup365Test/Online/MockOnlineDataService.cs +++ b/tests/XrmMockup365Test/Online/MockOnlineDataService.cs @@ -1,3 +1,4 @@ +#if DATAVERSE_SERVICE_CLIENT using System; using System.Collections.Generic; using System.Linq; @@ -119,3 +120,4 @@ private static Entity CloneEntity(Entity entity, ColumnSet columnSet) } } } +#endif diff --git a/tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs b/tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs index f4d7bf59..fa783e0a 100644 --- a/tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs +++ b/tests/XrmMockup365Test/Online/OnlineDataServiceUnitTests.cs @@ -1,3 +1,4 @@ +#if DATAVERSE_SERVICE_CLIENT using System; using System.Linq; using DG.Tools.XrmMockup; @@ -11,7 +12,7 @@ namespace DG.XrmMockupTest.Online /// /// Unit tests verifying XrmMockup's integration with IOnlineDataService. /// Uses mock IOnlineDataService to verify correct behavior without real proxy. - /// Works on both net462 and net8.0 frameworks. + /// Only available on net8.0 (DATAVERSE_SERVICE_CLIENT). /// public class OnlineDataServiceUnitTests : IClassFixture { @@ -229,3 +230,4 @@ public void NoOnlineService_EntityNotFound_ThrowsException() } } } +#endif diff --git a/tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs b/tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs deleted file mode 100644 index 45b44c15..00000000 --- a/tests/XrmMockup365Test/Online/ProcessManager/PipeNameGenerationTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Linq; -using Xunit; -using DG.Tools.XrmMockup.Online; - -namespace DG.XrmMockupTest.Online.ProcessManager -{ - /// - /// Tests for deterministic pipe name generation. - /// - [Trait("Category", "Unit")] - public class PipeNameGenerationTests - { - [Fact] - public void SameUrl_ProducesSamePipeName() - { - // Arrange - var url = "https://myorg.crm.dynamics.com"; - - // Act - var name1 = ProxyProcessManager.GeneratePipeName(url); - var name2 = ProxyProcessManager.GeneratePipeName(url); - - // Assert - Assert.Equal(name1, name2); - } - - [Fact] - public void DifferentCasing_ProducesSamePipeName() - { - // Arrange - var url1 = "https://myorg.crm.dynamics.com"; - var url2 = "HTTPS://MYORG.CRM.DYNAMICS.COM"; - var url3 = "Https://MyOrg.Crm.Dynamics.Com"; - - // Act - var name1 = ProxyProcessManager.GeneratePipeName(url1); - var name2 = ProxyProcessManager.GeneratePipeName(url2); - var name3 = ProxyProcessManager.GeneratePipeName(url3); - - // Assert - all should produce the same name (case-insensitive) - Assert.Equal(name1, name2); - Assert.Equal(name1, name3); - } - - [Fact] - public void DifferentUrls_ProduceDifferentNames() - { - // Arrange - var url1 = "https://org1.crm.dynamics.com"; - var url2 = "https://org2.crm.dynamics.com"; - var url3 = "https://org1.crm4.dynamics.com"; // Different region - - // Act - var name1 = ProxyProcessManager.GeneratePipeName(url1); - var name2 = ProxyProcessManager.GeneratePipeName(url2); - var name3 = ProxyProcessManager.GeneratePipeName(url3); - - // Assert - Assert.NotEqual(name1, name2); - Assert.NotEqual(name1, name3); - Assert.NotEqual(name2, name3); - } - - [Fact] - public void PipeName_HasExpectedFormat() - { - // Arrange - var url = "https://myorg.crm.dynamics.com"; - - // Act - var name = ProxyProcessManager.GeneratePipeName(url); - - // Assert - should be "XrmMockupProxy_" + 16 hex chars - Assert.StartsWith("XrmMockupProxy_", name); - Assert.Equal(31, name.Length); // 15 (prefix) + 16 (hex) - - // Verify the hash part is valid hex - var hashPart = name.Substring(15); - Assert.All(hashPart, c => Assert.True( - char.IsDigit(c) || (c >= 'A' && c <= 'F'), - string.Format("Character '{0}' is not a valid hex character", c))); - } - - [Fact] - public void PipeName_IsDeterministic() - { - // Arrange - var url = "https://contoso.crm.dynamics.com"; - - // Act - generate name multiple times - var names = Enumerable.Range(0, 100) - .Select(_ => ProxyProcessManager.GeneratePipeName(url)) - .ToList(); - - // Assert - all should be identical - Assert.All(names, n => Assert.Equal(names[0], n)); - } - - [Fact] - public void PipeName_HandlesSpecialCharacters() - { - // Arrange - var url = "https://my-org_test.crm.dynamics.com/api/data/v9.2?param=value&other=123"; - - // Act - var name = ProxyProcessManager.GeneratePipeName(url); - - // Assert - should still produce valid format - Assert.StartsWith("XrmMockupProxy_", name); - Assert.Equal(31, name.Length); - } - - [Fact] - public void PipeName_HandlesTrailingSlash() - { - // Arrange - var url1 = "https://myorg.crm.dynamics.com"; - var url2 = "https://myorg.crm.dynamics.com/"; - - // Act - var name1 = ProxyProcessManager.GeneratePipeName(url1); - var name2 = ProxyProcessManager.GeneratePipeName(url2); - - // Assert - these will be different (trailing slash is significant) - // This is acceptable behavior - just documenting it - Assert.NotEqual(name1, name2); - } - } -} diff --git a/tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs b/tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs deleted file mode 100644 index fac5b121..00000000 --- a/tests/XrmMockup365Test/Online/ProcessManager/ProxyDllFinderTests.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System.IO; -using Xunit; -using DG.Tools.XrmMockup.Online; - -namespace DG.XrmMockupTest.Online.ProcessManager -{ - /// - /// Tests for DLL auto-detection logic in ProxyDllFinder. - /// - [Trait("Category", "Unit")] - public class ProxyDllFinderTests - { - private const string ProxyDllName = "XrmMockup.DataverseProxy.dll"; - - [Fact] - public void ExplicitPath_ValidFile_ReturnsPath() - { - // Arrange - var fs = new TestFileSystemHelper(); - var explicitPath = @"C:\custom\path\XrmMockup.DataverseProxy.dll"; - fs.AddFile(explicitPath); - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(explicitPath); - - // Assert - Assert.Equal(explicitPath, result); - } - - [Fact] - public void ExplicitPath_FileNotExists_ThrowsFileNotFoundException() - { - // Arrange - var fs = new TestFileSystemHelper(); - var explicitPath = @"C:\custom\path\XrmMockup.DataverseProxy.dll"; - // Don't add the file - var finder = new ProxyDllFinder(fs); - - // Act & Assert - var ex = Assert.Throws(() => finder.FindProxyDll(explicitPath)); - Assert.Contains(explicitPath, ex.Message); - } - - [Fact] - public void NuGet_FindsVersionSpecificDll() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = "1.2.3"; - fs.UserProfilePath = @"C:\Users\testuser"; - - var expectedPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void NuGet_UsesNuGetPackagesEnvVar() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = "1.2.3"; - fs.SetEnvironmentVariable("NUGET_PACKAGES", @"D:\custom\nuget"); - - var expectedPath = @"D:\custom\nuget\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - fs.AddDirectory(@"D:\custom\nuget\xrmmockup365\1.2.3"); - fs.AddDirectory(@"D:\custom\nuget\xrmmockup365\1.2.3\tools\net8.0"); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void NuGet_FallsBackToUserProfile() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = "1.2.3"; - fs.UserProfilePath = @"C:\Users\testuser"; - // Don't set NUGET_PACKAGES env var - - var expectedPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void NuGet_StripsVersionMetadataSuffix() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = "1.2.3+abc123"; // Version with metadata suffix - fs.UserProfilePath = @"C:\Users\testuser"; - - // Path should use version without the metadata suffix - var expectedPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void NuGet_ReturnsNullIfNoVersion() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = null; // No version - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindInNuGetPackages(); - - // Assert - Assert.Null(result); - } - - [Fact] - public void NuGet_ReturnsNullIfVersionDirMissing() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = "1.2.3"; - fs.UserProfilePath = @"C:\Users\testuser"; - // Don't create the version directory - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindInNuGetPackages(); - - // Assert - Assert.Null(result); - } - - [Fact] - public void AssemblyDir_FindsAdjacentDll() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = null; // Skip NuGet lookup - fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; - - var expectedPath = @"C:\app\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void ToolsSubdir_Searched() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = null; // Skip NuGet lookup - fs.ExecutingAssemblyLocation = @"C:\app\lib\net8.0\XrmMockup365.dll"; - - // Not in same directory, but in tools/net8.0 subdir - var expectedPath = @"C:\app\lib\net8.0\tools\net8.0\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void DevTree_FindsDebugBuild() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = null; // Skip NuGet lookup - fs.ExecutingAssemblyLocation = @"C:\repos\XrmMockup\src\XrmMockup365\bin\Debug\net8.0\XrmMockup365.dll"; - - // Proxy is in sibling project's bin directory - var expectedPath = @"C:\repos\XrmMockup\src\XrmMockup.DataverseProxy\bin\Debug\net8.0\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void DevTree_FindsReleaseBuild() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = null; // Skip NuGet lookup - fs.ExecutingAssemblyLocation = @"C:\repos\XrmMockup\src\XrmMockup365\bin\Release\net8.0\XrmMockup365.dll"; - - // Proxy is in sibling project's bin directory - var expectedPath = @"C:\repos\XrmMockup\src\XrmMockup.DataverseProxy\bin\Release\net8.0\XrmMockup.DataverseProxy.dll"; - fs.AddFile(expectedPath); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - Assert.Equal(expectedPath, result); - } - - [Fact] - public void NoProxyFound_ThrowsWithHelpfulMessage() - { - // Arrange - var fs = new TestFileSystemHelper(); - fs.AssemblyInformationalVersion = null; // Skip NuGet lookup - fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; - // Don't add any proxy files - - var finder = new ProxyDllFinder(fs); - - // Act & Assert - var ex = Assert.Throws(() => finder.FindProxyDll()); - Assert.Contains(ProxyDllName, ex.Message); - Assert.Contains("proxyPath", ex.Message); - } - - [Fact] - public void SearchOrder_ExplicitPathTakesPrecedence() - { - // Arrange - var fs = new TestFileSystemHelper(); - var explicitPath = @"C:\custom\XrmMockup.DataverseProxy.dll"; - var nugetPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; - var adjacentPath = @"C:\app\XrmMockup.DataverseProxy.dll"; - - fs.AssemblyInformationalVersion = "1.2.3"; - fs.UserProfilePath = @"C:\Users\testuser"; - fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; - - // Add all possible locations - fs.AddFile(explicitPath); - fs.AddFile(nugetPath); - fs.AddFile(adjacentPath); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(explicitPath); - - // Assert - explicit path should be returned - Assert.Equal(explicitPath, result); - } - - [Fact] - public void SearchOrder_NuGetBeforeAssemblyDir() - { - // Arrange - var fs = new TestFileSystemHelper(); - var nugetPath = @"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0\XrmMockup.DataverseProxy.dll"; - var adjacentPath = @"C:\app\XrmMockup.DataverseProxy.dll"; - - fs.AssemblyInformationalVersion = "1.2.3"; - fs.UserProfilePath = @"C:\Users\testuser"; - fs.ExecutingAssemblyLocation = @"C:\app\XrmMockup365.dll"; - - // Add both locations - fs.AddFile(nugetPath); - fs.AddFile(adjacentPath); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3"); - fs.AddDirectory(@"C:\Users\testuser\.nuget\packages\xrmmockup365\1.2.3\tools\net8.0"); - - var finder = new ProxyDllFinder(fs); - - // Act - var result = finder.FindProxyDll(); - - // Assert - NuGet path should be returned (higher priority) - Assert.Equal(nugetPath, result); - } - } -} diff --git a/tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs b/tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs deleted file mode 100644 index 9a6a8e08..00000000 --- a/tests/XrmMockup365Test/Online/ProcessManager/SharedProxyStateTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Diagnostics; -using System.Linq; -using System.Threading; -using Xunit; -using DG.Tools.XrmMockup.Online; - -namespace DG.XrmMockupTest.Online.ProcessManager -{ - /// - /// Tests for SharedProxyState process lifecycle management. - /// - [Trait("Category", "Unit")] - public class SharedProxyStateTests - { - [Fact] - public void EnsureRunning_StartsNewProcess() - { - // Arrange - var state = new SharedProxyState(); - var processStarted = false; - string capturedToken = null; - - Process StartProcess(string url, string pipe, out string token) - { - processStarted = true; - token = "test-token"; - capturedToken = token; - // Return a dummy process (current process) - return Process.GetCurrentProcess(); - } - - // Act - state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); - - // Assert - Assert.True(processStarted); - Assert.Equal("test-token", state.AuthToken); - Assert.Equal("test-token", capturedToken); - } - - [Fact] - public void EnsureRunning_ReusesRunningProcess() - { - // Arrange - var state = new SharedProxyState(); - var startCount = 0; - - Process StartProcess(string url, string pipe, out string token) - { - startCount++; - token = string.Format("token-{0}", startCount); - return Process.GetCurrentProcess(); - } - - // Act - call EnsureRunning twice - state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); - state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); - - // Assert - process should only be started once - Assert.Equal(1, startCount); - Assert.Equal("token-1", state.AuthToken); - } - - [Fact] - public void ConcurrentEnsureRunning_ThreadSafe() - { - // Arrange - var state = new SharedProxyState(); - var startCount = 0; - var lockObj = new object(); - - Process StartProcess(string url, string pipe, out string token) - { - lock (lockObj) - { - startCount++; - } - token = "test-token"; - // Simulate some work - Thread.Sleep(10); - return Process.GetCurrentProcess(); - } - - // Act - start multiple threads calling EnsureRunning concurrently - var threads = Enumerable.Range(0, 10).Select(_ => - { - var t = new Thread(() => - { - state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); - }); - t.Start(); - return t; - }).ToList(); - - foreach (var t in threads) - { - t.Join(); - } - - // Assert - process should only be started once despite concurrent access - Assert.Equal(1, startCount); - } - - [Fact] - public void AuthToken_StoredAndRetrievable() - { - // Arrange - var state = new SharedProxyState(); - var expectedToken = "my-secure-token-12345"; - - Process StartProcess(string url, string pipe, out string token) - { - token = expectedToken; - return Process.GetCurrentProcess(); - } - - // Act - state.EnsureRunning("http://test.crm.dynamics.com", "TestPipe", StartProcess); - - // Assert - Assert.Equal(expectedToken, state.AuthToken); - } - - [Fact] - public void AuthToken_NullBeforeProcessStarts() - { - // Arrange - var state = new SharedProxyState(); - - // Act & Assert - Assert.Null(state.AuthToken); - } - } -} diff --git a/tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs b/tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs deleted file mode 100644 index 27ac5a1c..00000000 --- a/tests/XrmMockup365Test/Online/ProcessManager/TestFileSystemHelper.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using DG.Tools.XrmMockup.Online; - -namespace DG.XrmMockupTest.Online.ProcessManager -{ - /// - /// Mock file system helper for unit testing ProxyDllFinder. - /// Allows control over which files/directories "exist" and environment variables. - /// - public class TestFileSystemHelper : IFileSystemHelper - { - private readonly HashSet _existingFiles = new HashSet(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _existingDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _environmentVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); - - public string ExecutingAssemblyLocation { get; set; } = @"C:\test\XrmMockup365.dll"; - public string AssemblyInformationalVersion { get; set; } = "1.0.0"; - public string UserProfilePath { get; set; } = @"C:\Users\testuser"; - - public void AddFile(string path) - { - _existingFiles.Add(path); - // Also ensure parent directory exists - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) - { - AddDirectory(dir); - } - } - - public void AddDirectory(string path) - { - _existingDirectories.Add(path); - // Also add parent directories - var parent = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(parent) && !_existingDirectories.Contains(parent)) - { - AddDirectory(parent); - } - } - - public void SetEnvironmentVariable(string name, string value) - { - _environmentVariables[name] = value; - } - - public bool FileExists(string path) => _existingFiles.Contains(path); - - public bool DirectoryExists(string path) => _existingDirectories.Contains(path); - - public string GetEnvironmentVariable(string name) - { - string value; - return _environmentVariables.TryGetValue(name, out value) ? value : null; - } - - public string GetExecutingAssemblyLocation() => ExecutingAssemblyLocation; - - public string GetAssemblyInformationalVersion() => AssemblyInformationalVersion; - - public string GetUserProfilePath() => UserProfilePath; - - public string GetParentDirectory(string path) - { - var parent = Path.GetDirectoryName(path); - return parent; - } - } -} diff --git a/tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs b/tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs deleted file mode 100644 index e1f7174c..00000000 --- a/tests/XrmMockup365Test/Online/ProxySpinUpIntegrationTests.cs +++ /dev/null @@ -1,456 +0,0 @@ -using System; -using System.IO; -using System.Text.Json; -using DG.Tools.XrmMockup.Online; -using DG.XrmFramework.BusinessDomain.ServiceContext; -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Query; -using Xunit; -using XrmMockup.DataverseProxy.Contracts; - -namespace DG.XrmMockupTest.Online -{ - /// - /// Integration tests verifying the actual proxy spin-up and communication. - /// Tests that XrmMockup can locate, start, and communicate with the DataverseProxy. - /// Works on both net462 and net8.0 frameworks (proxy runs via dotnet CLI out-of-process). - /// - public class ProxySpinUpIntegrationTests : IDisposable - { - private readonly string _tempDir; - private readonly string _mockDataFilePath; - - public ProxySpinUpIntegrationTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid())); - Directory.CreateDirectory(_tempDir); - _mockDataFilePath = Path.Combine(_tempDir, "mock-data.json"); - } - - public void Dispose() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, true); - } - } - catch - { - // Ignore cleanup errors - } - } - - [SkippableFact] - public void ProxyDllFinder_FindsProxyDll() - { - // Arrange - var finder = new ProxyDllFinder(); - - // Act & Assert - Should not throw - // This test verifies that the proxy DLL can be found in the development environment - try - { - var path = finder.FindProxyDll(); - Assert.NotNull(path); - Assert.True(File.Exists(path), string.Format("Proxy DLL should exist at: {0}", path)); - Assert.EndsWith("XrmMockup.DataverseProxy.dll", path); - } - catch (FileNotFoundException ex) - { - // If running in CI or without the proxy built, skip this test - // The proxy DLL may not be available in all build configurations - Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); - } - } - - [Fact] - public void ProxyProcessManager_GeneratesPipeNameDeterministically() - { - // Arrange - var url1 = "https://org1.crm.dynamics.com"; - var url2 = "https://org2.crm.dynamics.com"; - - // Act - var pipeName1a = ProxyProcessManager.GeneratePipeName(url1); - var pipeName1b = ProxyProcessManager.GeneratePipeName(url1); - var pipeName2 = ProxyProcessManager.GeneratePipeName(url2); - - // Assert - Assert.Equal(pipeName1a, pipeName1b); // Same URL should produce same pipe name - Assert.NotEqual(pipeName1a, pipeName2); // Different URLs should produce different pipe names - Assert.StartsWith("XrmMockupProxy_", pipeName1a); - } - - [SkippableFact] - public void ProxyProcessManager_StartsProcess_WithMockData() - { - // Arrange - Create mock data file with a test account - var testAccountId = Guid.NewGuid(); - var testAccount = new Entity(Account.EntityLogicalName, testAccountId); - testAccount["name"] = "Mock Test Account"; - testAccount["accountnumber"] = "MOCK-001"; - - CreateMockDataFile(testAccount); - - // Find the proxy DLL path - var finder = new ProxyDllFinder(); - string proxyPath; - try - { - proxyPath = finder.FindProxyDll(); - } - catch (FileNotFoundException ex) - { - // Skip test if proxy DLL not found - Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); - return; - } - - // Act - Start proxy with mock data - var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); - var authToken = Guid.NewGuid().ToString("N"); - - using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) - { - // Assert - Process should be running - Assert.NotNull(process); - Assert.False(process.HasExited, "Proxy process should still be running"); - - // Cleanup - process.Kill(); - process.WaitForExit(5000); - } - } - - [SkippableFact] - public void ProxyOnlineDataService_ConnectsToProxy_WithMockData() - { - // Arrange - Create mock data file with a test account - var testAccountId = Guid.NewGuid(); - var testAccount = new Entity(Account.EntityLogicalName, testAccountId); - testAccount["name"] = "Proxy Connection Test Account"; - testAccount["accountnumber"] = "PROXY-CONN-001"; - - CreateMockDataFile(testAccount); - - // Find and start proxy - var finder = new ProxyDllFinder(); - string proxyPath; - try - { - proxyPath = finder.FindProxyDll(); - } - catch (FileNotFoundException ex) - { - Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); - return; - } - - var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); - var authToken = Guid.NewGuid().ToString("N"); - - using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) - { - Assert.False(process.HasExited, "Proxy should be running"); - - // Act - Create a direct pipe client to test connection - using (var pipeClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) - { - pipeClient.Connect(10000); - - // Send ping request - var pingRequest = new ProxyRequest - { - RequestType = ProxyRequestType.Ping, - AuthToken = authToken - }; - var pingBytes = JsonSerializer.SerializeToUtf8Bytes(pingRequest); - var lengthBytes = BitConverter.GetBytes(pingBytes.Length); - - pipeClient.Write(lengthBytes, 0, 4); - pipeClient.Write(pingBytes, 0, pingBytes.Length); - pipeClient.Flush(); - - // Read ping response - var responseLengthBytes = new byte[4]; - pipeClient.Read(responseLengthBytes, 0, 4); - var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); - var responseBytes = new byte[responseLength]; - pipeClient.Read(responseBytes, 0, responseLength); - - var response = JsonSerializer.Deserialize(responseBytes); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success, "Ping should succeed"); - } - - // Cleanup - process.Kill(); - process.WaitForExit(5000); - } - } - - [SkippableFact] - public void ProxyOnlineDataService_Retrieve_ReturnsMockData() - { - // Arrange - Create mock data file with a test account - var testAccountId = Guid.NewGuid(); - var testAccount = new Entity(Account.EntityLogicalName, testAccountId); - testAccount["name"] = "Retrieve Test Account"; - testAccount["accountnumber"] = "RETRIEVE-001"; - - CreateMockDataFile(testAccount); - - // Find and start proxy - var finder = new ProxyDllFinder(); - string proxyPath; - try - { - proxyPath = finder.FindProxyDll(); - } - catch (FileNotFoundException ex) - { - Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); - return; - } - - var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); - var authToken = Guid.NewGuid().ToString("N"); - - using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) - { - Assert.False(process.HasExited, "Proxy should be running"); - - // Act - Send retrieve request via pipe - using (var pipeClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) - { - pipeClient.Connect(10000); - - var retrievePayload = new ProxyRetrieveRequest - { - EntityName = Account.EntityLogicalName, - Id = testAccountId, - Columns = null // All columns - }; - - var retrieveRequest = new ProxyRequest - { - RequestType = ProxyRequestType.Retrieve, - AuthToken = authToken, - Payload = JsonSerializer.Serialize(retrievePayload) - }; - - var requestBytes = JsonSerializer.SerializeToUtf8Bytes(retrieveRequest); - var lengthBytes = BitConverter.GetBytes(requestBytes.Length); - - pipeClient.Write(lengthBytes, 0, 4); - pipeClient.Write(requestBytes, 0, requestBytes.Length); - pipeClient.Flush(); - - // Read response - var responseLengthBytes = new byte[4]; - pipeClient.Read(responseLengthBytes, 0, 4); - var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); - var responseBytes = new byte[responseLength]; - var totalRead = 0; - while (totalRead < responseLength) - { - totalRead += pipeClient.Read(responseBytes, totalRead, responseLength - totalRead); - } - - var response = JsonSerializer.Deserialize(responseBytes); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success, response.ErrorMessage); - Assert.NotNull(response.SerializedData); - - var retrievedEntity = EntitySerializationHelper.DeserializeEntity(response.SerializedData); - Assert.Equal(testAccountId, retrievedEntity.Id); - Assert.Equal("Retrieve Test Account", retrievedEntity.GetAttributeValue("name")); - } - - // Cleanup - process.Kill(); - process.WaitForExit(5000); - } - } - - [SkippableFact] - public void ProxyOnlineDataService_RetrieveMultiple_ReturnsMockData() - { - // Arrange - Create mock data file with multiple test accounts - var testAccount1 = new Entity(Account.EntityLogicalName, Guid.NewGuid()); - testAccount1["name"] = "Multi Test Account 1"; - testAccount1["accountnumber"] = "MULTI-001"; - - var testAccount2 = new Entity(Account.EntityLogicalName, Guid.NewGuid()); - testAccount2["name"] = "Multi Test Account 2"; - testAccount2["accountnumber"] = "MULTI-002"; - - CreateMockDataFile(testAccount1, testAccount2); - - // Find and start proxy - var finder = new ProxyDllFinder(); - string proxyPath; - try - { - proxyPath = finder.FindProxyDll(); - } - catch (FileNotFoundException ex) - { - Skip.If(true, string.Format("Proxy DLL not found - test skipped: {0}", ex.Message)); - return; - } - - var pipeName = string.Format("XrmMockupProxyTest_{0:N}", Guid.NewGuid()); - var authToken = Guid.NewGuid().ToString("N"); - - using (var process = StartProxyWithMockData(proxyPath, pipeName, authToken)) - { - Assert.False(process.HasExited, "Proxy should be running"); - - // Act - Send retrieve multiple request via pipe - using (var pipeClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) - { - pipeClient.Connect(10000); - - var query = new QueryExpression(Account.EntityLogicalName) - { - ColumnSet = new ColumnSet("name", "accountnumber") - }; - - var serializedQuery = EntitySerializationHelper.SerializeQueryExpression(query); - var retrieveMultiplePayload = new ProxyRetrieveMultipleRequest - { - SerializedQuery = serializedQuery - }; - - var retrieveMultipleRequest = new ProxyRequest - { - RequestType = ProxyRequestType.RetrieveMultiple, - AuthToken = authToken, - Payload = JsonSerializer.Serialize(retrieveMultiplePayload) - }; - - var requestBytes = JsonSerializer.SerializeToUtf8Bytes(retrieveMultipleRequest); - var lengthBytes = BitConverter.GetBytes(requestBytes.Length); - - pipeClient.Write(lengthBytes, 0, 4); - pipeClient.Write(requestBytes, 0, requestBytes.Length); - pipeClient.Flush(); - - // Read response - var responseLengthBytes = new byte[4]; - pipeClient.Read(responseLengthBytes, 0, 4); - var responseLength = BitConverter.ToInt32(responseLengthBytes, 0); - var responseBytes = new byte[responseLength]; - var totalRead = 0; - while (totalRead < responseLength) - { - totalRead += pipeClient.Read(responseBytes, totalRead, responseLength - totalRead); - } - - var response = JsonSerializer.Deserialize(responseBytes); - - // Assert - Assert.NotNull(response); - Assert.True(response.Success, response.ErrorMessage); - Assert.NotNull(response.SerializedData); - - var collection = EntitySerializationHelper.DeserializeEntityCollection(response.SerializedData); - Assert.Equal(2, collection.Entities.Count); - } - - // Cleanup - process.Kill(); - process.WaitForExit(5000); - } - } - - private void CreateMockDataFile(params Entity[] entities) - { - var mockData = new MockDataFile - { - Entities = new System.Collections.Generic.List() - }; - - foreach (var entity in entities) - { - mockData.Entities.Add(EntitySerializationHelper.SerializeEntity(entity)); - } - - var json = JsonSerializer.Serialize(mockData); - File.WriteAllText(_mockDataFilePath, json); - } - - private System.Diagnostics.Process StartProxyWithMockData(string proxyPath, string pipeName, string authToken) - { - var startInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "dotnet", - // Token is passed via stdin for security - not visible in process listings - Arguments = string.Format("\"{0}\" --mock-data-file \"{1}\" --pipe \"{2}\"", proxyPath, _mockDataFilePath, pipeName), - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - RedirectStandardInput = true - }; - - var process = new System.Diagnostics.Process { StartInfo = startInfo }; - process.Start(); - - // Write token to stdin immediately after start (secure - not visible in process listings) - process.StandardInput.WriteLine(authToken); - process.StandardInput.Close(); - - // Wait for proxy to start by polling for pipe availability - var timeout = TimeSpan.FromSeconds(30); - var pollInterval = TimeSpan.FromMilliseconds(100); - var elapsed = TimeSpan.Zero; - - while (elapsed < timeout) - { - if (process.HasExited) - { - var error = process.StandardError.ReadToEnd(); - var output = process.StandardOutput.ReadToEnd(); - throw new InvalidOperationException(string.Format("Proxy process exited. Exit code: {0}, Error: {1}, Output: {2}", process.ExitCode, error, output)); - } - - try - { - using (var testClient = new System.IO.Pipes.NamedPipeClientStream(".", pipeName, System.IO.Pipes.PipeDirection.InOut)) - { - testClient.Connect(500); - return process; // Pipe is available, proxy is ready - } - } - catch (TimeoutException) - { - // Pipe not ready yet, continue polling - } - catch (IOException) - { - // Pipe not ready yet, continue polling - } - - System.Threading.Thread.Sleep(pollInterval); - elapsed += pollInterval; - } - - // Timeout reached - if (process.HasExited) - { - var error = process.StandardError.ReadToEnd(); - throw new InvalidOperationException(string.Format("Proxy process exited. Error: {0}", error)); - } - - throw new TimeoutException("Proxy process did not become available within timeout"); - } - } -} diff --git a/tests/XrmMockup365Test/UnitTestBaseNoProxyTypes.cs b/tests/XrmMockup365Test/UnitTestBaseNoProxyTypes.cs index dcd58783..3a704e64 100644 --- a/tests/XrmMockup365Test/UnitTestBaseNoProxyTypes.cs +++ b/tests/XrmMockup365Test/UnitTestBaseNoProxyTypes.cs @@ -7,12 +7,9 @@ namespace DG.XrmMockupTest { public class UnitTestBaseNoProxyTypes : IClassFixture { - private static DateTime _startTime { get; set; } - protected IOrganizationService orgAdminUIService; protected IOrganizationService orgAdminService; protected IOrganizationService orgGodService; - protected IOrganizationService orgRealDataService; protected XrmMockup365 crm; @@ -23,8 +20,6 @@ public UnitTestBaseNoProxyTypes(XrmMockupFixtureNoProxyTypes fixture) orgAdminUIService = crm.GetAdminService(new MockupServiceSettings(true, false, MockupServiceSettings.Role.UI)); orgGodService = crm.GetAdminService(new MockupServiceSettings(false, true, MockupServiceSettings.Role.SDK)); orgAdminService = crm.GetAdminService(); - // Skip real data service - it causes online connection issues and isn't needed for most tests - orgRealDataService = null; } public void Dispose() @@ -33,4 +28,4 @@ public void Dispose() // The instance will be garbage collected automatically } } -} \ No newline at end of file +} diff --git a/tests/XrmMockup365Test/UnitTestBaseNoReset.cs b/tests/XrmMockup365Test/UnitTestBaseNoReset.cs index 4e223afd..d89d8094 100644 --- a/tests/XrmMockup365Test/UnitTestBaseNoReset.cs +++ b/tests/XrmMockup365Test/UnitTestBaseNoReset.cs @@ -9,12 +9,9 @@ namespace DG.XrmMockupTest { public class UnitTestBaseNoReset : IClassFixture { - private static DateTime _startTime { get; set; } - protected IOrganizationService orgAdminUIService; protected IOrganizationService orgAdminService; protected IOrganizationService orgGodService; - protected IOrganizationService orgRealDataService; protected XrmMockup365 crm; @@ -25,8 +22,6 @@ public UnitTestBaseNoReset(XrmMockupFixture fixture) orgAdminUIService = crm.GetAdminService(new MockupServiceSettings(true, false, MockupServiceSettings.Role.UI)); orgGodService = crm.GetAdminService(new MockupServiceSettings(false, true, MockupServiceSettings.Role.SDK)); orgAdminService = crm.GetAdminService(); - // Skip real data service - it causes online connection issues and isn't needed for most tests - orgRealDataService = null; //create an admin user to run our impersonating user plugins as @@ -41,4 +36,4 @@ public UnitTestBaseNoReset(XrmMockupFixture fixture) } } } -} \ No newline at end of file +} diff --git a/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs b/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs index 0824f350..1b76b4c4 100644 --- a/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs +++ b/tests/XrmMockup365Test/XrmmockupFixtureNoProxyTypes.cs @@ -7,11 +7,9 @@ public class XrmMockupFixtureNoProxyTypes : IDisposable { // Shared settings instances to ensure metadata cache hits private static XrmMockupSettings _sharedSettings; - private static XrmMockupSettings _sharedRealDataSettings; private static readonly object _settingsLock = new object(); public XrmMockupSettings Settings => _sharedSettings; - public XrmMockupSettings RealDataSettings => _sharedRealDataSettings; public XrmMockupFixtureNoProxyTypes() { @@ -29,28 +27,6 @@ public XrmMockupFixtureNoProxyTypes() MetadataDirectoryPath = GetMetadataPath(), IPluginMetadata = metaPlugins }; - - try - { - _sharedRealDataSettings = new XrmMockupSettings - { - BasePluginTypes = _sharedSettings.BasePluginTypes, - CodeActivityInstanceTypes = _sharedSettings.CodeActivityInstanceTypes, - EnableProxyTypes = _sharedSettings.EnableProxyTypes, - IncludeAllWorkflows = _sharedSettings.IncludeAllWorkflows, - ExceptionFreeRequests = _sharedSettings.ExceptionFreeRequests, - MetadataDirectoryPath = GetMetadataPath(), - OnlineEnvironment = new Env - { - Url = "https://example.crm.dynamics.com" - } - }; - } - catch - { - // ignore - set to null - _sharedRealDataSettings = null; - } } } } From 65bb6d9a86c00228580e5b5af73914fc94d31bff Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Fri, 13 Feb 2026 12:30:33 +0100 Subject: [PATCH 3/5] Fix: The two projects where removed again --- .github/workflows/ci.yml | 8 -------- .github/workflows/release.yml | 8 -------- 2 files changed, 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abcccf2e..9615c5b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,14 +27,6 @@ jobs: shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup365/XrmMockup365.csproj - - name: Set XrmMockup.DataverseProxy.Contracts version from RELEASE_NOTES.md - shell: pwsh - run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.csproj - - - name: Set XrmMockup.DataverseProxy version from RELEASE_NOTES.md - shell: pwsh - run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj - - name: Set MetadataGenerator.Tool version from CHANGELOG.md shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./src/MetadataGen/MetadataGenerator.Tool/CHANGELOG.md -CsprojPath ./src/MetadataGen/MetadataGenerator.Tool/MetadataGenerator.Tool.csproj diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd6af6c4..95e8b1ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,14 +34,6 @@ jobs: shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup365/XrmMockup365.csproj - - name: Set XrmMockup.DataverseProxy.Contracts version from RELEASE_NOTES.md - shell: pwsh - run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy.Contracts/XrmMockup.DataverseProxy.Contracts.csproj - - - name: Set XrmMockup.DataverseProxy version from RELEASE_NOTES.md - shell: pwsh - run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./RELEASE_NOTES.md -CsprojPath ./src/XrmMockup.DataverseProxy/XrmMockup.DataverseProxy.csproj - - name: Set MetadataGenerator.Tool version from CHANGELOG.md shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath ./src/MetadataGen/MetadataGenerator.Tool/CHANGELOG.md -CsprojPath ./src/MetadataGen/MetadataGenerator.Tool/MetadataGenerator.Tool.csproj From 80a00254ee834e26d57a6f98598e023e2ea5d2f9 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Fri, 13 Feb 2026 13:15:54 +0100 Subject: [PATCH 4/5] Remove DATAVERSE_SERVICE_CLIENT preprocessor usage Refactored codebase to always include IOnlineDataService support by removing most #if DATAVERSE_SERVICE_CLIENT directives. Constructors and methods now accept IOnlineDataService parameters, defaulting to null when not provided. Online data features are now guarded by runtime checks instead of compile-time flags. Updated XrmMockup365.GetInstance and StaticMetadataCache usage accordingly. This simplifies maintenance and enables more flexible runtime configuration. --- src/XrmMockup365/Core.cs | 30 ++----------------- src/XrmMockup365/Database/XrmDb.cs | 23 -------------- .../Internal/CoreInitializationData.cs | 4 --- src/XrmMockup365/Online/IOnlineDataService.cs | 2 -- .../RetrieveMultipleRequestHandler.cs | 2 -- src/XrmMockup365/StaticMetadataCache.cs | 15 ---------- src/XrmMockup365/XrmMockup.cs | 11 ------- src/XrmMockup365/XrmMockup365.csproj | 4 +-- src/XrmMockup365/XrmMockupBase.cs | 2 -- 9 files changed, 4 insertions(+), 89 deletions(-) diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index 44c72234..a5b8df16 100644 --- a/src/XrmMockup365/Core.cs +++ b/src/XrmMockup365/Core.cs @@ -1,9 +1,7 @@ using DG.Tools.XrmMockup.Database; using DG.Tools.XrmMockup.Internal; using DG.Tools.XrmMockup.Serialization; -#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; -#endif using XrmPluginCore.Enums; using Microsoft.Crm.Sdk.Messages; using Microsoft.Xrm.Sdk; @@ -61,9 +59,7 @@ internal class Core : IXrmMockupExtension private XrmDb db; private Dictionary snapshots; private Dictionary entityTypeMap = new Dictionary(); -#if DATAVERSE_SERVICE_CLIENT private IOnlineDataService OnlineDataService; -#endif private int baseCurrencyPrecision; private FormulaFieldEvaluator FormulaFieldEvaluator { get; set; } private List systemAttributeNames; @@ -83,9 +79,7 @@ public Core(XrmMockupSettings Settings, MetadataSkeleton metadata, List SecurityRoles = SecurityRoles, BaseCurrency = metadata.BaseOrganization.GetAttributeValue("basecurrencyid"), BaseCurrencyPrecision = metadata.BaseOrganization.GetAttributeValue("pricingdecimalprecision"), -#if DATAVERSE_SERVICE_CLIENT OnlineDataService = null, -#endif EntityTypeMap = new Dictionary() }; @@ -105,9 +99,7 @@ public Core(XrmMockupSettings Settings, StaticMetadataCache staticCache) SecurityRoles = staticCache.SecurityRoles, BaseCurrency = staticCache.BaseCurrency, BaseCurrencyPrecision = staticCache.BaseCurrencyPrecision, -#if DATAVERSE_SERVICE_CLIENT OnlineDataService = staticCache.OnlineDataService, -#endif EntityTypeMap = staticCache.EntityTypeMap }; @@ -124,12 +116,8 @@ private void InitializeCore(CoreInitializationData initData) metadata = initData.Metadata; BaseCurrency = initData.BaseCurrency; baseCurrencyPrecision = initData.BaseCurrencyPrecision; -#if DATAVERSE_SERVICE_CLIENT OnlineDataService = initData.OnlineDataService; db = new XrmDb(initData.Metadata.EntityMetadata, initData.OnlineDataService); -#else - db = new XrmDb(initData.Metadata.EntityMetadata); -#endif entityTypeMap = initData.EntityTypeMap; EnsureFileAttachmentMetadata(); FileBlockStore = new FileBlockStore(); @@ -196,9 +184,7 @@ public static StaticMetadataCache BuildStaticMetadataCache(XrmMockupSettings set var baseCurrency = metadata.BaseOrganization.GetAttributeValue("basecurrencyid"); var baseCurrencyPrecision = metadata.BaseOrganization.GetAttributeValue("pricingdecimalprecision"); -#if DATAVERSE_SERVICE_CLIENT var onlineDataService = BuildOnlineDataService(settings); -#endif var entityTypeMap = new Dictionary(); // Build entity type map for proxy types if enabled @@ -210,18 +196,13 @@ public static StaticMetadataCache BuildStaticMetadataCache(XrmMockupSettings set // Note: IPluginMetadata is handled per-instance in the Core constructor // to avoid modifying the shared cache -#if DATAVERSE_SERVICE_CLIENT return new StaticMetadataCache(metadata, workflows, securityRoles, entityTypeMap, baseCurrency, baseCurrencyPrecision, onlineDataService); -#else - return new StaticMetadataCache(metadata, workflows, securityRoles, entityTypeMap, - baseCurrency, baseCurrencyPrecision); -#endif } -#if DATAVERSE_SERVICE_CLIENT private static IOnlineDataService BuildOnlineDataService(XrmMockupSettings settings) { +#if DATAVERSE_SERVICE_CLIENT // Allow injection for testing if (settings.OnlineDataServiceFactory != null) { @@ -233,10 +214,9 @@ private static IOnlineDataService BuildOnlineDataService(XrmMockupSettings setti var env = settings.OnlineEnvironment.Value; return new OnlineDataService(env.Url); } - +#endif return null; } -#endif private static void BuildEntityTypeMap(XrmMockupSettings settings, Dictionary entityTypeMap) { @@ -1120,7 +1100,6 @@ internal void PopulateWith(Entity[] entities) } } -#if DATAVERSE_SERVICE_CLIENT /// /// Prefills the local database with data from the online service based on the query. /// @@ -1128,7 +1107,6 @@ internal void PrefillDBWithOnlineData(QueryExpression query) { db.PrefillDBWithOnlineData(query); } -#endif internal Dictionary> GetPrivilege(Guid principleId) { @@ -1365,11 +1343,7 @@ internal void ResetEnvironment() workflowManager.ResetWorkflows(settings.IncludeAllWorkflows); pluginManager.ResetPlugins(); -#if DATAVERSE_SERVICE_CLIENT this.db = new XrmDb(metadata.EntityMetadata, OnlineDataService); -#else - this.db = new XrmDb(metadata.EntityMetadata); -#endif EnsureFileAttachmentMetadata(); this.RequestHandlers = GetRequestHandlers(db); InitializeDB(); diff --git a/src/XrmMockup365/Database/XrmDb.cs b/src/XrmMockup365/Database/XrmDb.cs index 83c45f04..546d18fa 100644 --- a/src/XrmMockup365/Database/XrmDb.cs +++ b/src/XrmMockup365/Database/XrmDb.cs @@ -9,9 +9,7 @@ using System.Threading; using DG.Tools.XrmMockup.Serialization; using DG.Tools.XrmMockup.Internal; -#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; -#endif namespace DG.Tools.XrmMockup.Database { @@ -19,23 +17,14 @@ internal class XrmDb { // Using ConcurrentDictionary for thread-safe table access in parallel test scenarios private ConcurrentDictionary TableDict = new ConcurrentDictionary(); private readonly Dictionary EntityMetadata; -#if DATAVERSE_SERVICE_CLIENT private readonly IOnlineDataService OnlineDataService; -#endif private int sequence; -#if DATAVERSE_SERVICE_CLIENT public XrmDb(Dictionary entityMetadata, IOnlineDataService onlineDataService) { this.EntityMetadata = entityMetadata; this.OnlineDataService = onlineDataService; sequence = 0; } -#else - public XrmDb(Dictionary entityMetadata) { - this.EntityMetadata = entityMetadata; - sequence = 0; - } -#endif public DbTable this[string tableName] { get { @@ -122,7 +111,6 @@ internal void RegisterEntityMetadata(EntityMetadata entityMetadata) EntityMetadata[entityMetadata.LogicalName] = entityMetadata; } -#if DATAVERSE_SERVICE_CLIENT internal void PrefillDBWithOnlineData(QueryExpression queryExpr) { if (OnlineDataService != null) @@ -137,7 +125,6 @@ internal void PrefillDBWithOnlineData(QueryExpression queryExpr) } } } -#endif internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = true) { @@ -146,7 +133,6 @@ internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = tru if (reference?.Id != Guid.Empty) { currentDbRow = this[reference.LogicalName][reference.Id]; -#if DATAVERSE_SERVICE_CLIENT if (currentDbRow == null && OnlineDataService != null) { if (!withReferenceCheck) @@ -160,7 +146,6 @@ internal DbRow GetDbRow(EntityReference reference, bool withReferenceCheck = tru currentDbRow = this[reference.LogicalName][reference.Id]; } } -#endif if (currentDbRow == null) { throw new FaultException($"The record of type '{reference.LogicalName}' with id '{reference.Id}' " + @@ -278,11 +263,7 @@ internal Entity GetEntityOrNull(EntityReference reference) public XrmDb Clone() { var clonedTables = this.TableDict.ToDictionary(x => x.Key, x => x.Value.Clone()); -#if DATAVERSE_SERVICE_CLIENT var clonedDB = new XrmDb(this.EntityMetadata, this.OnlineDataService) -#else - var clonedDB = new XrmDb(this.EntityMetadata) -#endif { TableDict = new ConcurrentDictionary(clonedTables) }; @@ -300,11 +281,7 @@ public DbDTO ToSerializableDTO() public static XrmDb RestoreSerializableDTO(XrmDb current, DbDTO model) { var clonedTables = model.Tables.ToDictionary(x => x.Key, x => DbTable.RestoreSerializableDTO(new DbTable(current.EntityMetadata[x.Key]), x.Value)); -#if DATAVERSE_SERVICE_CLIENT var clonedDB = new XrmDb(current.EntityMetadata, current.OnlineDataService) -#else - var clonedDB = new XrmDb(current.EntityMetadata) -#endif { TableDict = new ConcurrentDictionary(clonedTables) }; diff --git a/src/XrmMockup365/Internal/CoreInitializationData.cs b/src/XrmMockup365/Internal/CoreInitializationData.cs index 8f293b1a..df78bbb9 100644 --- a/src/XrmMockup365/Internal/CoreInitializationData.cs +++ b/src/XrmMockup365/Internal/CoreInitializationData.cs @@ -1,9 +1,7 @@ using Microsoft.Xrm.Sdk; using System; using System.Collections.Generic; -#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; -#endif namespace DG.Tools.XrmMockup.Internal { @@ -18,9 +16,7 @@ internal class CoreInitializationData public List SecurityRoles { get; set; } public EntityReference BaseCurrency { get; set; } public int BaseCurrencyPrecision { get; set; } -#if DATAVERSE_SERVICE_CLIENT public IOnlineDataService OnlineDataService { get; set; } -#endif public Dictionary EntityTypeMap { get; set; } } } diff --git a/src/XrmMockup365/Online/IOnlineDataService.cs b/src/XrmMockup365/Online/IOnlineDataService.cs index a194a1da..21c31a11 100644 --- a/src/XrmMockup365/Online/IOnlineDataService.cs +++ b/src/XrmMockup365/Online/IOnlineDataService.cs @@ -1,4 +1,3 @@ -#if DATAVERSE_SERVICE_CLIENT using System; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; @@ -26,4 +25,3 @@ internal interface IOnlineDataService : IDisposable bool IsConnected { get; } } } -#endif diff --git a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs index eaac14dd..dc7c1797 100644 --- a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs +++ b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs @@ -40,9 +40,7 @@ internal override OrganizationResponse Execute(OrganizationRequest orgRequest, E FillAliasIfEmpty(queryExpr); -#if DATAVERSE_SERVICE_CLIENT db.PrefillDBWithOnlineData(queryExpr); -#endif // Create a snapshot for thread-safe enumeration during calculated field execution var rows = db.GetDBEntityRows(queryExpr.EntityName).ToList(); diff --git a/src/XrmMockup365/StaticMetadataCache.cs b/src/XrmMockup365/StaticMetadataCache.cs index d1197875..7a081a6c 100644 --- a/src/XrmMockup365/StaticMetadataCache.cs +++ b/src/XrmMockup365/StaticMetadataCache.cs @@ -1,9 +1,7 @@ using Microsoft.Xrm.Sdk; using System; using System.Collections.Generic; -#if DATAVERSE_SERVICE_CLIENT using DG.Tools.XrmMockup.Online; -#endif namespace DG.Tools.XrmMockup { @@ -15,7 +13,6 @@ public class StaticMetadataCache public Dictionary EntityTypeMap { get; } public EntityReference BaseCurrency { get; } public int BaseCurrencyPrecision { get; } -#if DATAVERSE_SERVICE_CLIENT internal IOnlineDataService OnlineDataService { get; } internal StaticMetadataCache(MetadataSkeleton metadata, List workflows, List securityRoles, @@ -30,17 +27,5 @@ internal StaticMetadataCache(MetadataSkeleton metadata, List workflows, BaseCurrencyPrecision = baseCurrencyPrecision; OnlineDataService = onlineDataService; } -#else - internal StaticMetadataCache(MetadataSkeleton metadata, List workflows, List securityRoles, - Dictionary entityTypeMap, EntityReference baseCurrency, int baseCurrencyPrecision) - { - Metadata = metadata; - Workflows = workflows; - SecurityRoles = securityRoles; - EntityTypeMap = entityTypeMap; - BaseCurrency = baseCurrency; - BaseCurrencyPrecision = baseCurrencyPrecision; - } -#endif } } diff --git a/src/XrmMockup365/XrmMockup.cs b/src/XrmMockup365/XrmMockup.cs index 8ad483a2..f7970bb8 100644 --- a/src/XrmMockup365/XrmMockup.cs +++ b/src/XrmMockup365/XrmMockup.cs @@ -72,7 +72,6 @@ public static XrmMockup365 GetInstance(XrmMockup365 xrmMockup, XrmMockupSettings else { // Create a new cache entry using the existing instance's data -#if DATAVERSE_SERVICE_CLIENT cache = new StaticMetadataCache( xrmMockup.Metadata, xrmMockup.Workflows, @@ -82,16 +81,6 @@ public static XrmMockup365 GetInstance(XrmMockup365 xrmMockup, XrmMockupSettings 0, // Will be retrieved from metadata null // Will be rebuilt if needed ); -#else - cache = new StaticMetadataCache( - xrmMockup.Metadata, - xrmMockup.Workflows, - xrmMockup.SecurityRoles, - new Dictionary(), // Will be rebuilt if needed - xrmMockup.BaseCurrency, - 0 // Will be retrieved from metadata - ); -#endif metadataCache[effectiveSettings] = cache; } } diff --git a/src/XrmMockup365/XrmMockup365.csproj b/src/XrmMockup365/XrmMockup365.csproj index bd9b6b38..ec2c5bfa 100644 --- a/src/XrmMockup365/XrmMockup365.csproj +++ b/src/XrmMockup365/XrmMockup365.csproj @@ -5,7 +5,7 @@ portable XrmMockup365 - 0.0.0 + 1.18.0-rc.6 @@ -81,4 +81,4 @@ -
+
\ No newline at end of file diff --git a/src/XrmMockup365/XrmMockupBase.cs b/src/XrmMockup365/XrmMockupBase.cs index 13f01bc8..4202d1d5 100644 --- a/src/XrmMockup365/XrmMockupBase.cs +++ b/src/XrmMockup365/XrmMockupBase.cs @@ -285,7 +285,6 @@ public void PopulateWith(params Entity[] entities) { Core.PopulateWith(entities); } -#if DATAVERSE_SERVICE_CLIENT /// /// Prefills the local database with data from the online service based on the query. /// Only works when OnlineDataServiceFactory or OnlineEnvironment is configured. @@ -294,7 +293,6 @@ public void PopulateWith(params Entity[] entities) { public void PrefillDBWithOnlineData(QueryExpression query) { Core.PrefillDBWithOnlineData(query); } -#endif /// /// Create a new user with a specific businessunit From a940a9cda7effbc2898b15aa5c062ab933d0cb3d Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Fri, 13 Feb 2026 13:33:53 +0100 Subject: [PATCH 5/5] Consolidate package versions and ensure we don't accidentally downgrade Dataverse Service Client package --- Tools/Tools.csproj | 2 +- Tools/packages.config | 2 +- .../MetadataGenerator.Tool.Tests.csproj | 2 +- src/XrmMockup365/XrmMockup365.csproj | 4 ++-- tests/TestPluginAssembly365/TestPluginAssembly365.csproj | 2 +- tests/XrmMockup365Test/XrmMockup365Test.csproj | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tools/Tools.csproj b/Tools/Tools.csproj index d3e29fe4..ab766e06 100644 --- a/Tools/Tools.csproj +++ b/Tools/Tools.csproj @@ -64,7 +64,7 @@ True - ..\packages\Microsoft.CrmSdk.Workflow.9.0.2.23\lib\net462\Microsoft.Xrm.Sdk.Workflow.dll + ..\packages\Microsoft.CrmSdk.Workflow.9.0.2.60\lib\net462\Microsoft.Xrm.Sdk.Workflow.dll True diff --git a/Tools/packages.config b/Tools/packages.config index c42adad0..135f44eb 100644 --- a/Tools/packages.config +++ b/Tools/packages.config @@ -5,7 +5,7 @@ - + diff --git a/src/MetadataGen/MetadataGenerator.Tool.Tests/MetadataGenerator.Tool.Tests.csproj b/src/MetadataGen/MetadataGenerator.Tool.Tests/MetadataGenerator.Tool.Tests.csproj index a35d53e9..18317d0e 100644 --- a/src/MetadataGen/MetadataGenerator.Tool.Tests/MetadataGenerator.Tool.Tests.csproj +++ b/src/MetadataGen/MetadataGenerator.Tool.Tests/MetadataGenerator.Tool.Tests.csproj @@ -20,7 +20,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/XrmMockup365/XrmMockup365.csproj b/src/XrmMockup365/XrmMockup365.csproj index ec2c5bfa..dd774f77 100644 --- a/src/XrmMockup365/XrmMockup365.csproj +++ b/src/XrmMockup365/XrmMockup365.csproj @@ -62,11 +62,11 @@ - + - + diff --git a/tests/TestPluginAssembly365/TestPluginAssembly365.csproj b/tests/TestPluginAssembly365/TestPluginAssembly365.csproj index 8d8f57c1..f100b07d 100644 --- a/tests/TestPluginAssembly365/TestPluginAssembly365.csproj +++ b/tests/TestPluginAssembly365/TestPluginAssembly365.csproj @@ -27,7 +27,7 @@ - + diff --git a/tests/XrmMockup365Test/XrmMockup365Test.csproj b/tests/XrmMockup365Test/XrmMockup365Test.csproj index 04127f2e..6ec0e892 100644 --- a/tests/XrmMockup365Test/XrmMockup365Test.csproj +++ b/tests/XrmMockup365Test/XrmMockup365Test.csproj @@ -43,11 +43,11 @@ - + - +