Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
environment: test-dataverse
needs: unit-tests
permissions:
contents: read
id-token: write # Required for GitHub OIDC federated authentication

steps:
- uses: actions/checkout@v6
Expand All @@ -62,10 +65,12 @@ jobs:

- name: Run Integration Tests
env:
DATAVERSE_URL: ${{ secrets.DATAVERSE_URL }}
PPDS_TEST_APP_ID: ${{ secrets.PPDS_TEST_APP_ID }}
# Repository variables (non-sensitive)
DATAVERSE_URL: ${{ vars.DATAVERSE_URL }}
PPDS_TEST_APP_ID: ${{ vars.PPDS_TEST_APP_ID }}
PPDS_TEST_TENANT_ID: ${{ vars.PPDS_TEST_TENANT_ID }}
# Repository secrets (sensitive)
PPDS_TEST_CLIENT_SECRET: ${{ secrets.PPDS_TEST_CLIENT_SECRET }}
PPDS_TEST_TENANT_ID: ${{ secrets.PPDS_TEST_TENANT_ID }}
PPDS_TEST_CERT_BASE64: ${{ secrets.PPDS_TEST_CERT_BASE64 }}
PPDS_TEST_CERT_PASSWORD: ${{ secrets.PPDS_TEST_CERT_PASSWORD }}
run: dotnet test --configuration Release --no-build --verbosity normal --filter "Category=Integration"
6 changes: 6 additions & 0 deletions src/PPDS.Auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Integration tests for credential providers** - Live tests for `ClientSecretCredentialProvider`, `CertificateFileCredentialProvider`, and `GitHubFederatedCredentialProvider` ([#55](https://github.com/joshsmithxrm/ppds-sdk/issues/55))
- Manual test procedures documentation for interactive browser and device code authentication


## [1.0.0-beta.3] - 2026-01-02

### Fixed
Expand Down
149 changes: 149 additions & 0 deletions tests/PPDS.LiveTests/Authentication/CertificateAuthenticationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using FluentAssertions;
using PPDS.Auth.Credentials;
using PPDS.LiveTests.Infrastructure;
using Xunit;

namespace PPDS.LiveTests.Authentication;

/// <summary>
/// Integration tests for certificate-based (service principal) authentication.
/// These tests verify that CertificateFileCredentialProvider can successfully
/// authenticate to Dataverse using a certificate file.
/// </summary>
[Trait("Category", "Integration")]
public class CertificateAuthenticationTests : LiveTestBase, IDisposable
{
private readonly LiveTestConfiguration _config;
Comment thread
joshsmithxrm marked this conversation as resolved.
Outdated

public CertificateAuthenticationTests()
{
_config = new LiveTestConfiguration();
}
Comment thread
joshsmithxrm marked this conversation as resolved.
Outdated

[SkipIfNoCertificate]
public async Task CertificateFileCredentialProvider_CreatesWorkingServiceClient()
{
// Arrange
var certPath = _config.GetCertificatePath();
using var provider = new CertificateFileCredentialProvider(
_config.ApplicationId!,
certPath,
_config.CertificatePassword,
_config.TenantId!);

// Act
using var client = await provider.CreateServiceClientAsync(_config.DataverseUrl!);

// Assert
client.Should().NotBeNull();
client.IsReady.Should().BeTrue("ServiceClient should be ready after successful authentication");
}

[SkipIfNoCertificate]
public async Task CertificateFileCredentialProvider_CanExecuteWhoAmI()
{
// Arrange
var certPath = _config.GetCertificatePath();
using var provider = new CertificateFileCredentialProvider(
_config.ApplicationId!,
certPath,
_config.CertificatePassword,
_config.TenantId!);

using var client = await provider.CreateServiceClientAsync(_config.DataverseUrl!);

// Act
var response = (Microsoft.Crm.Sdk.Messages.WhoAmIResponse)client.Execute(
new Microsoft.Crm.Sdk.Messages.WhoAmIRequest());

// Assert
response.Should().NotBeNull();
response.UserId.Should().NotBeEmpty("WhoAmI should return a valid user ID");
response.OrganizationId.Should().NotBeEmpty("WhoAmI should return a valid organization ID");
}

[SkipIfNoCertificate]
public async Task CertificateFileCredentialProvider_SetsIdentityProperty()
Comment thread
joshsmithxrm marked this conversation as resolved.
Outdated
{
// Arrange
var certPath = _config.GetCertificatePath();
using var provider = new CertificateFileCredentialProvider(
_config.ApplicationId!,
certPath,
_config.CertificatePassword,
_config.TenantId!);

// Assert - Identity is set before authentication
provider.Identity.Should().NotBeNullOrWhiteSpace();
provider.Identity.Should().StartWith("app:");
provider.AuthMethod.Should().Be(PPDS.Auth.Profiles.AuthMethod.CertificateFile);
}
Comment thread
joshsmithxrm marked this conversation as resolved.

[SkipIfNoCertificate]
public async Task CertificateFileCredentialProvider_SetsTokenExpirationAfterAuth()
{
// Arrange
var certPath = _config.GetCertificatePath();
using var provider = new CertificateFileCredentialProvider(
_config.ApplicationId!,
certPath,
_config.CertificatePassword,
_config.TenantId!);

// Token expiration is null before authentication
provider.TokenExpiresAt.Should().BeNull();

// Act
using var client = await provider.CreateServiceClientAsync(_config.DataverseUrl!);

// Assert - Token expiration is set after authentication
provider.TokenExpiresAt.Should().NotBeNull();
provider.TokenExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow);
}

[SkipIfNoCertificate]
public void LiveTestConfiguration_CanLoadCertificate()
{
// Arrange & Act
using var cert = _config.LoadCertificate();

// Assert
cert.Should().NotBeNull();
cert.HasPrivateKey.Should().BeTrue("Certificate must have private key for authentication");
cert.Thumbprint.Should().NotBeNullOrWhiteSpace();
}

[Fact]
public void CertificateFileCredentialProvider_ThrowsOnNullArguments()
{
// Act & Assert
var act1 = () => new CertificateFileCredentialProvider(null!, "path", null, "tenant");
var act2 = () => new CertificateFileCredentialProvider("app", null!, null, "tenant");
var act3 = () => new CertificateFileCredentialProvider("app", "path", null, null!);

act1.Should().Throw<ArgumentNullException>().WithParameterName("applicationId");
act2.Should().Throw<ArgumentNullException>().WithParameterName("certificatePath");
act3.Should().Throw<ArgumentNullException>().WithParameterName("tenantId");
}

[Fact]
public async Task CertificateFileCredentialProvider_ThrowsOnMissingFile()
{
// Arrange
using var provider = new CertificateFileCredentialProvider(
"00000000-0000-0000-0000-000000000000",
"/nonexistent/path/cert.pfx",
null,
"00000000-0000-0000-0000-000000000000");

// Act & Assert
var act = () => provider.CreateServiceClientAsync("https://fake.crm.dynamics.com");
await act.Should().ThrowAsync<AuthenticationException>()
Comment thread
joshsmithxrm marked this conversation as resolved.
.WithMessage("*not found*");
}

public void Dispose()
{
_config.Dispose();
}
}
115 changes: 115 additions & 0 deletions tests/PPDS.LiveTests/Authentication/ClientSecretAuthenticationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using FluentAssertions;
using PPDS.Auth.Credentials;
using PPDS.LiveTests.Infrastructure;
using Xunit;

namespace PPDS.LiveTests.Authentication;

/// <summary>
/// Integration tests for client secret (service principal) authentication.
/// These tests verify that ClientSecretCredentialProvider can successfully
/// authenticate to Dataverse using a client ID and secret.
/// </summary>
[Trait("Category", "Integration")]
public class ClientSecretAuthenticationTests : LiveTestBase
Comment thread
joshsmithxrm marked this conversation as resolved.
Outdated
{
[SkipIfNoClientSecret]
public async Task ClientSecretCredentialProvider_CreatesWorkingServiceClient()
{
// Arrange
using var provider = new ClientSecretCredentialProvider(
Configuration.ApplicationId!,
Configuration.ClientSecret!,
Configuration.TenantId!);

// Act
using var client = await provider.CreateServiceClientAsync(Configuration.DataverseUrl!);

// Assert
client.Should().NotBeNull();
client.IsReady.Should().BeTrue("ServiceClient should be ready after successful authentication");
}

[SkipIfNoClientSecret]
public async Task ClientSecretCredentialProvider_CanExecuteWhoAmI()
{
// Arrange
using var provider = new ClientSecretCredentialProvider(
Configuration.ApplicationId!,
Configuration.ClientSecret!,
Configuration.TenantId!);

using var client = await provider.CreateServiceClientAsync(Configuration.DataverseUrl!);

// Act
var response = (Microsoft.Crm.Sdk.Messages.WhoAmIResponse)client.Execute(
new Microsoft.Crm.Sdk.Messages.WhoAmIRequest());

// Assert
response.Should().NotBeNull();
response.UserId.Should().NotBeEmpty("WhoAmI should return a valid user ID");
response.OrganizationId.Should().NotBeEmpty("WhoAmI should return a valid organization ID");
}

[SkipIfNoClientSecret]
public async Task ClientSecretCredentialProvider_SetsIdentityProperty()
Comment thread
joshsmithxrm marked this conversation as resolved.
Outdated
{
// Arrange
using var provider = new ClientSecretCredentialProvider(
Configuration.ApplicationId!,
Configuration.ClientSecret!,
Configuration.TenantId!);

// Assert - Identity is set before authentication
provider.Identity.Should().NotBeNullOrWhiteSpace();
provider.Identity.Should().StartWith("app:");
provider.AuthMethod.Should().Be(PPDS.Auth.Profiles.AuthMethod.ClientSecret);
}
Comment thread
joshsmithxrm marked this conversation as resolved.

[SkipIfNoClientSecret]
public async Task ClientSecretCredentialProvider_SetsTokenExpirationAfterAuth()
{
// Arrange
using var provider = new ClientSecretCredentialProvider(
Configuration.ApplicationId!,
Configuration.ClientSecret!,
Configuration.TenantId!);

// Token expiration is null before authentication
provider.TokenExpiresAt.Should().BeNull();

// Act
using var client = await provider.CreateServiceClientAsync(Configuration.DataverseUrl!);

// Assert - Token expiration is set after authentication
provider.TokenExpiresAt.Should().NotBeNull();
provider.TokenExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow);
}

[Fact]
public void ClientSecretCredentialProvider_ThrowsOnNullArguments()
{
// Act & Assert
var act1 = () => new ClientSecretCredentialProvider(null!, "secret", "tenant");
var act2 = () => new ClientSecretCredentialProvider("app", null!, "tenant");
var act3 = () => new ClientSecretCredentialProvider("app", "secret", null!);

act1.Should().Throw<ArgumentNullException>().WithParameterName("applicationId");
act2.Should().Throw<ArgumentNullException>().WithParameterName("clientSecret");
act3.Should().Throw<ArgumentNullException>().WithParameterName("tenantId");
}

[Fact]
public async Task ClientSecretCredentialProvider_ThrowsOnInvalidCredentials()
{
// Arrange - Use fake credentials that will fail
using var provider = new ClientSecretCredentialProvider(
"00000000-0000-0000-0000-000000000000",
"invalid-secret",
"00000000-0000-0000-0000-000000000000");

// Act & Assert
var act = () => provider.CreateServiceClientAsync("https://fake.crm.dynamics.com");
await act.Should().ThrowAsync<AuthenticationException>();
Comment thread
joshsmithxrm marked this conversation as resolved.
}
}
Loading
Loading