diff --git a/.claude/commands/review-bot-comments.md b/.claude/commands/review-bot-comments.md
index 8a370f537..69a117876 100644
--- a/.claude/commands/review-bot-comments.md
+++ b/.claude/commands/review-bot-comments.md
@@ -21,8 +21,11 @@ For each bot comment, determine verdict and rationale:
|---------|---------|
| **Valid** | Bot is correct, code should be changed |
| **False Positive** | Bot is wrong, explain why |
+| **Duplicate** | Same issue reported by another bot - still needs reply |
| **Unclear** | Need to investigate before deciding |
+**IMPORTANT:** Every comment needs a reply, including duplicates. Track all comment IDs to ensure none are missed.
+
### 3. Present Summary and WAIT FOR APPROVAL
**CRITICAL: Do NOT implement fixes automatically.**
@@ -69,6 +72,7 @@ gh api repos/joshsmithxrm/ppds-sdk/pulls/{pr}/comments \
| Valid (fixed) | `Fixed in {commit_sha} - {brief description}` |
| Declined | `Declining - {reason}` |
| False positive | `False positive - {explanation}` |
+| Duplicate | Same reply as original (reference the fix commit) |
## Common False Positives
@@ -89,6 +93,17 @@ gh api repos/joshsmithxrm/ppds-sdk/pulls/{pr}/comments \
| Resource not disposed | Yes - but verify interface first |
| Generic catch clause | Context-dependent |
+### 6. Verify All Comments Addressed
+
+Before completing, verify every comment has a reply:
+
+```bash
+# Count original comments vs replies
+gh api repos/joshsmithxrm/ppds-sdk/pulls/{pr}/comments --jq "length"
+```
+
+If any comments are missing replies, address them before marking complete.
+
## When to Use
- After opening a PR (before requesting review)
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 7fe6f435a..ceda33a69 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -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
@@ -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"
diff --git a/src/PPDS.Auth/CHANGELOG.md b/src/PPDS.Auth/CHANGELOG.md
index 2d178f240..4001de8d8 100644
--- a/src/PPDS.Auth/CHANGELOG.md
+++ b/src/PPDS.Auth/CHANGELOG.md
@@ -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
diff --git a/tests/PPDS.LiveTests/Authentication/CertificateAuthenticationTests.cs b/tests/PPDS.LiveTests/Authentication/CertificateAuthenticationTests.cs
new file mode 100644
index 000000000..af7902cdb
--- /dev/null
+++ b/tests/PPDS.LiveTests/Authentication/CertificateAuthenticationTests.cs
@@ -0,0 +1,142 @@
+using FluentAssertions;
+using PPDS.Auth.Credentials;
+using PPDS.LiveTests.Infrastructure;
+using Xunit;
+
+namespace PPDS.LiveTests.Authentication;
+
+///
+/// Integration tests for certificate-based (service principal) authentication.
+/// These tests verify that CertificateFileCredentialProvider can successfully
+/// authenticate to Dataverse using a certificate file.
+///
+[Trait("Category", "Integration")]
+public class CertificateAuthenticationTests : LiveTestBase, IDisposable
+{
+ [SkipIfNoCertificate]
+ public async Task CertificateFileCredentialProvider_CreatesWorkingServiceClient()
+ {
+ // Arrange
+ var certPath = Configuration.GetCertificatePath();
+ using var provider = new CertificateFileCredentialProvider(
+ Configuration.ApplicationId!,
+ certPath,
+ Configuration.CertificatePassword,
+ 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");
+ }
+
+ [SkipIfNoCertificate]
+ public async Task CertificateFileCredentialProvider_CanExecuteWhoAmI()
+ {
+ // Arrange
+ var certPath = Configuration.GetCertificatePath();
+ using var provider = new CertificateFileCredentialProvider(
+ Configuration.ApplicationId!,
+ certPath,
+ Configuration.CertificatePassword,
+ 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");
+ }
+
+ [SkipIfNoCertificate]
+ public void CertificateFileCredentialProvider_SetsIdentityProperty()
+ {
+ // Arrange
+ var certPath = Configuration.GetCertificatePath();
+ using var provider = new CertificateFileCredentialProvider(
+ Configuration.ApplicationId!,
+ certPath,
+ Configuration.CertificatePassword,
+ 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.CertificateFile);
+ }
+
+ [SkipIfNoCertificate]
+ public async Task CertificateFileCredentialProvider_SetsTokenExpirationAfterAuth()
+ {
+ // Arrange
+ var certPath = Configuration.GetCertificatePath();
+ using var provider = new CertificateFileCredentialProvider(
+ Configuration.ApplicationId!,
+ certPath,
+ Configuration.CertificatePassword,
+ 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);
+ }
+
+ [SkipIfNoCertificate]
+ public void LiveTestConfiguration_CanLoadCertificate()
+ {
+ // Arrange & Act
+ using var cert = Configuration.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().WithParameterName("applicationId");
+ act2.Should().Throw().WithParameterName("certificatePath");
+ act3.Should().Throw().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()
+ .WithMessage("*not found*");
+ }
+
+ public void Dispose()
+ {
+ Configuration.Dispose();
+ }
+}
diff --git a/tests/PPDS.LiveTests/Authentication/ClientSecretAuthenticationTests.cs b/tests/PPDS.LiveTests/Authentication/ClientSecretAuthenticationTests.cs
new file mode 100644
index 000000000..dbc5f6bde
--- /dev/null
+++ b/tests/PPDS.LiveTests/Authentication/ClientSecretAuthenticationTests.cs
@@ -0,0 +1,120 @@
+using FluentAssertions;
+using PPDS.Auth.Credentials;
+using PPDS.LiveTests.Infrastructure;
+using Xunit;
+
+namespace PPDS.LiveTests.Authentication;
+
+///
+/// Integration tests for client secret (service principal) authentication.
+/// These tests verify that ClientSecretCredentialProvider can successfully
+/// authenticate to Dataverse using a client ID and secret.
+///
+[Trait("Category", "Integration")]
+public class ClientSecretAuthenticationTests : LiveTestBase, IDisposable
+{
+ [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 void ClientSecretCredentialProvider_SetsIdentityProperty()
+ {
+ // 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);
+ }
+
+ [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().WithParameterName("applicationId");
+ act2.Should().Throw().WithParameterName("clientSecret");
+ act3.Should().Throw().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();
+ }
+
+ public void Dispose()
+ {
+ Configuration.Dispose();
+ }
+}
diff --git a/tests/PPDS.LiveTests/Authentication/GitHubFederatedAuthenticationTests.cs b/tests/PPDS.LiveTests/Authentication/GitHubFederatedAuthenticationTests.cs
new file mode 100644
index 000000000..7c742ebc5
--- /dev/null
+++ b/tests/PPDS.LiveTests/Authentication/GitHubFederatedAuthenticationTests.cs
@@ -0,0 +1,117 @@
+using FluentAssertions;
+using PPDS.Auth.Credentials;
+using PPDS.LiveTests.Infrastructure;
+using Xunit;
+
+namespace PPDS.LiveTests.Authentication;
+
+///
+/// Integration tests for GitHub OIDC federated authentication.
+/// These tests only run inside GitHub Actions with:
+/// - 'id-token: write' permission in the workflow
+/// - Federated credential configured in Azure AD App Registration
+///
+[Trait("Category", "Integration")]
+public class GitHubFederatedAuthenticationTests : LiveTestBase, IDisposable
+{
+ [SkipIfNoGitHubOidc]
+ public async Task GitHubFederatedCredentialProvider_CreatesWorkingServiceClient()
+ {
+ // Arrange
+ using var provider = new GitHubFederatedCredentialProvider(
+ Configuration.ApplicationId!,
+ 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");
+ }
+
+ [SkipIfNoGitHubOidc]
+ public async Task GitHubFederatedCredentialProvider_CanExecuteWhoAmI()
+ {
+ // Arrange
+ using var provider = new GitHubFederatedCredentialProvider(
+ Configuration.ApplicationId!,
+ 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");
+ }
+
+ [SkipIfNoGitHubOidc]
+ public async Task GitHubFederatedCredentialProvider_SetsAccessTokenAfterAuth()
+ {
+ // Arrange
+ using var provider = new GitHubFederatedCredentialProvider(
+ Configuration.ApplicationId!,
+ Configuration.TenantId!);
+
+ // Act
+ using var client = await provider.CreateServiceClientAsync(Configuration.DataverseUrl!);
+
+ // Assert - Access token is available after authentication
+ provider.AccessToken.Should().NotBeNullOrWhiteSpace();
+ provider.TokenExpiresAt.Should().NotBeNull();
+ provider.TokenExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow);
+ }
+
+ [SkipIfNoGitHubOidc]
+ public void GitHubFederatedCredentialProvider_SetsIdentityProperty()
+ {
+ // Arrange
+ using var provider = new GitHubFederatedCredentialProvider(
+ Configuration.ApplicationId!,
+ Configuration.TenantId!);
+
+ // Assert
+ provider.Identity.Should().NotBeNullOrWhiteSpace();
+ provider.Identity.Should().StartWith("app:");
+ provider.AuthMethod.Should().Be(PPDS.Auth.Profiles.AuthMethod.GitHubFederated);
+ }
+
+ [Fact]
+ public void GitHubFederatedCredentialProvider_ThrowsOnNullArguments()
+ {
+ // Act & Assert
+ var act1 = () => new GitHubFederatedCredentialProvider(null!, "tenant");
+ var act2 = () => new GitHubFederatedCredentialProvider("app", null!);
+
+ act1.Should().Throw().WithParameterName("applicationId");
+ act2.Should().Throw().WithParameterName("tenantId");
+ }
+
+ [Fact]
+ public void Configuration_DetectsGitHubOidcEnvironment()
+ {
+ // This test verifies the configuration correctly detects OIDC environment
+ var hasOidc = Configuration.HasGitHubOidcCredentials;
+
+ // If we're in GitHub Actions with OIDC configured, this should be true
+ // Otherwise it should be false (and tests using SkipIfNoGitHubOidc will skip)
+ var tokenUrl = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL");
+ var expectedHasOidc = !string.IsNullOrWhiteSpace(tokenUrl) &&
+ !string.IsNullOrWhiteSpace(Configuration.GitHubOidcRequestToken) &&
+ !string.IsNullOrWhiteSpace(Configuration.ApplicationId) &&
+ !string.IsNullOrWhiteSpace(Configuration.TenantId) &&
+ !string.IsNullOrWhiteSpace(Configuration.DataverseUrl);
+
+ hasOidc.Should().Be(expectedHasOidc);
+ }
+
+ public void Dispose()
+ {
+ Configuration.Dispose();
+ }
+}
diff --git a/tests/PPDS.LiveTests/Authentication/MANUAL_TESTING.md b/tests/PPDS.LiveTests/Authentication/MANUAL_TESTING.md
new file mode 100644
index 000000000..3224e6562
--- /dev/null
+++ b/tests/PPDS.LiveTests/Authentication/MANUAL_TESTING.md
@@ -0,0 +1,140 @@
+# Manual Authentication Testing Procedures
+
+This document describes how to manually test interactive and device code authentication methods that cannot be automated in CI.
+
+## Interactive Browser Authentication
+
+The `InteractiveBrowserCredentialProvider` opens a browser window for the user to sign in.
+
+### Test Procedure
+
+1. **Create a test console app or use the CLI:**
+ ```bash
+ ppds auth create --name test-interactive
+ # Opens browser for sign-in
+ ```
+
+2. **Expected behavior:**
+ - Browser opens automatically
+ - User can sign in with their credentials
+ - After successful sign-in, browser shows success message
+ - CLI/app receives token and continues
+
+3. **Verification:**
+ ```bash
+ ppds env list
+ # Should list available environments
+ ```
+
+4. **Edge cases to test:**
+ - [ ] User cancels browser sign-in
+ - [ ] Browser is closed before completing sign-in
+ - [ ] Network disconnection during sign-in
+ - [ ] Multi-factor authentication (MFA) flow
+ - [ ] Conditional Access policies
+
+---
+
+## Device Code Authentication
+
+The `DeviceCodeCredentialProvider` displays a code for the user to enter at https://microsoft.com/devicelogin.
+
+### Test Procedure
+
+1. **Create a profile with device code:**
+ ```bash
+ ppds auth create --name test-device --deviceCode
+ ```
+
+2. **Expected behavior:**
+ - Console displays a message like:
+ ```
+ To sign in, use a web browser to open the page https://microsoft.com/devicelogin
+ and enter the code ABC123XYZ to authenticate.
+ ```
+ - User opens the URL in any browser (can be on different device)
+ - User enters the code and signs in
+ - After successful sign-in, CLI/app receives token
+
+3. **Verification:**
+ ```bash
+ ppds env list
+ # Should list available environments
+ ```
+
+4. **Edge cases to test:**
+ - [ ] Code expires (typically 15 minutes)
+ - [ ] Wrong code entered
+ - [ ] User cancels at device login page
+ - [ ] Network disconnection
+ - [ ] MFA flow via device code
+
+---
+
+## Test Matrix
+
+| Auth Method | Automated CI | Manual Test | Notes |
+|-------------|--------------|-------------|-------|
+| Client Secret | ✅ | - | Fully automated |
+| Certificate | ✅ | - | Fully automated |
+| GitHub OIDC | ✅ | - | Only in GitHub Actions |
+| Interactive Browser | ❌ | ✅ | Requires human interaction |
+| Device Code | ❌ | ✅ | Requires human interaction |
+| Managed Identity | ❌ | ✅ | Only in Azure-hosted environments |
+| Azure DevOps OIDC | ❌ | ✅ | Only in Azure Pipelines |
+
+---
+
+## Troubleshooting
+
+### Interactive Browser Issues
+
+**Browser doesn't open:**
+- Check default browser settings
+- Try with `--forceInteractive` flag
+- Check firewall/proxy blocking localhost
+
+**"AADSTS" errors:**
+- `AADSTS50011`: Reply URL mismatch - check app registration
+- `AADSTS65001`: Consent required - admin must grant consent
+- `AADSTS700016`: App not found - check Application ID
+
+### Device Code Issues
+
+**Code not accepted:**
+- Ensure code is entered exactly (case-sensitive)
+- Check code hasn't expired (15-minute window)
+- Verify correct Microsoft account/tenant
+
+**Polling timeout:**
+- Default is 15 minutes
+- User must complete sign-in within this window
+
+---
+
+## Local Development Setup
+
+For local testing of interactive methods:
+
+1. **App Registration requirements:**
+ - Platform: Mobile and desktop applications
+ - Redirect URI: `http://localhost` (for interactive)
+ - Enable public client flows (for device code)
+
+2. **Environment variables (optional):**
+ ```powershell
+ $env:DATAVERSE_URL = "https://your-org.crm.dynamics.com"
+ ```
+
+3. **Run tests:**
+ ```bash
+ # Interactive test
+ ppds auth create --name local-test
+
+ # Device code test
+ ppds auth create --name local-device --deviceCode
+
+ # Verify
+ ppds env list
+ ppds data export --help
+ ```
diff --git a/tests/PPDS.LiveTests/Infrastructure/LiveTestConfiguration.cs b/tests/PPDS.LiveTests/Infrastructure/LiveTestConfiguration.cs
index fd513406f..b9e8f4d14 100644
--- a/tests/PPDS.LiveTests/Infrastructure/LiveTestConfiguration.cs
+++ b/tests/PPDS.LiveTests/Infrastructure/LiveTestConfiguration.cs
@@ -1,11 +1,16 @@
+using System.Security.Cryptography.X509Certificates;
+
namespace PPDS.LiveTests.Infrastructure;
///
/// Configuration for live Dataverse integration tests.
/// Reads credentials from environment variables.
///
-public sealed class LiveTestConfiguration
+public sealed class LiveTestConfiguration : IDisposable
{
+ private string? _tempCertificatePath;
+ private bool _disposed;
+
///
/// The Dataverse environment URL (e.g., https://org.crm.dynamics.com).
///
@@ -36,6 +41,22 @@ public sealed class LiveTestConfiguration
///
public string? CertificatePassword { get; }
+ ///
+ /// Path to certificate file (used for local testing).
+ /// If not set but CertificateBase64 is available, a temp file will be created.
+ ///
+ public string? CertificatePath { get; }
+
+ ///
+ /// GitHub Actions OIDC token request URL (set automatically by GitHub).
+ ///
+ public string? GitHubOidcTokenUrl { get; }
+
+ ///
+ /// GitHub Actions OIDC token request token (set automatically by GitHub).
+ ///
+ public string? GitHubOidcRequestToken { get; }
+
///
/// Gets a value indicating whether client secret credentials are available.
///
@@ -54,10 +75,21 @@ public sealed class LiveTestConfiguration
!string.IsNullOrWhiteSpace(CertificateBase64) &&
!string.IsNullOrWhiteSpace(TenantId);
+ ///
+ /// Gets a value indicating whether GitHub OIDC federated credentials are available.
+ /// This is true when running inside GitHub Actions with id-token permission.
+ ///
+ public bool HasGitHubOidcCredentials =>
+ !string.IsNullOrWhiteSpace(DataverseUrl) &&
+ !string.IsNullOrWhiteSpace(ApplicationId) &&
+ !string.IsNullOrWhiteSpace(TenantId) &&
+ !string.IsNullOrWhiteSpace(GitHubOidcTokenUrl) &&
+ !string.IsNullOrWhiteSpace(GitHubOidcRequestToken);
+
///
/// Gets a value indicating whether any live test credentials are available.
///
- public bool HasAnyCredentials => HasClientSecretCredentials || HasCertificateCredentials;
+ public bool HasAnyCredentials => HasClientSecretCredentials || HasCertificateCredentials || HasGitHubOidcCredentials;
///
/// Initializes a new instance reading from environment variables.
@@ -70,6 +102,59 @@ public LiveTestConfiguration()
TenantId = Environment.GetEnvironmentVariable("PPDS_TEST_TENANT_ID");
CertificateBase64 = Environment.GetEnvironmentVariable("PPDS_TEST_CERT_BASE64");
CertificatePassword = Environment.GetEnvironmentVariable("PPDS_TEST_CERT_PASSWORD");
+ CertificatePath = Environment.GetEnvironmentVariable("PPDS_TEST_CERT_PATH");
+ GitHubOidcTokenUrl = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL");
+ GitHubOidcRequestToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
+ }
+
+ ///
+ /// Gets the path to the certificate file, creating a temp file from base64 if needed.
+ ///
+ /// Path to the certificate file.
+ /// Thrown when certificate is not available.
+ public string GetCertificatePath()
+ {
+ // Use explicit path if provided
+ if (!string.IsNullOrWhiteSpace(CertificatePath) && File.Exists(CertificatePath))
+ {
+ return CertificatePath;
+ }
+
+ // Decode from base64 if available
+ if (!string.IsNullOrWhiteSpace(CertificateBase64))
+ {
+ if (_tempCertificatePath == null || !File.Exists(_tempCertificatePath))
+ {
+ _tempCertificatePath = Path.Combine(Path.GetTempPath(), $"ppds-test-{Guid.NewGuid():N}.pfx");
+ var bytes = Convert.FromBase64String(CertificateBase64);
+ File.WriteAllBytes(_tempCertificatePath, bytes);
+ }
+
+ return _tempCertificatePath;
+ }
+
+ throw new InvalidOperationException("No certificate available. Set PPDS_TEST_CERT_PATH or PPDS_TEST_CERT_BASE64.");
+ }
+
+ ///
+ /// Loads the certificate from the configured source.
+ ///
+ /// The loaded certificate. Caller is responsible for disposing.
+ ///
+ /// The returned implements .
+ /// Callers should use a using statement or call
+ /// when the certificate is no longer needed.
+ ///
+ public X509Certificate2 LoadCertificate()
+ {
+ var path = GetCertificatePath();
+ var flags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet;
+
+#if NET9_0_OR_GREATER
+ return X509CertificateLoader.LoadPkcs12FromFile(path, CertificatePassword, flags);
+#else
+ return new X509Certificate2(path, CertificatePassword, flags);
+#endif
}
///
@@ -86,16 +171,40 @@ public string GetMissingCredentialsReason()
if (string.IsNullOrWhiteSpace(TenantId))
missing.Add("PPDS_TEST_TENANT_ID");
- if (!HasClientSecretCredentials && !HasCertificateCredentials)
+ if (!HasClientSecretCredentials && !HasCertificateCredentials && !HasGitHubOidcCredentials)
{
if (string.IsNullOrWhiteSpace(ClientSecret))
missing.Add("PPDS_TEST_CLIENT_SECRET");
if (string.IsNullOrWhiteSpace(CertificateBase64))
missing.Add("PPDS_TEST_CERT_BASE64");
+ missing.Add("(or GitHub OIDC: ACTIONS_ID_TOKEN_REQUEST_URL)");
}
return missing.Count > 0
? $"Missing environment variables: {string.Join(", ", missing)}"
: "Unknown reason";
}
+
+ ///
+ /// Cleans up temporary files.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ if (_tempCertificatePath != null && File.Exists(_tempCertificatePath))
+ {
+ try
+ {
+ File.Delete(_tempCertificatePath);
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ _disposed = true;
+ }
}
diff --git a/tests/PPDS.LiveTests/Infrastructure/SkipIfNoCredentialsAttribute.cs b/tests/PPDS.LiveTests/Infrastructure/SkipIfNoCredentialsAttribute.cs
index cc809bac6..b6b222cf4 100644
--- a/tests/PPDS.LiveTests/Infrastructure/SkipIfNoCredentialsAttribute.cs
+++ b/tests/PPDS.LiveTests/Infrastructure/SkipIfNoCredentialsAttribute.cs
@@ -59,3 +59,23 @@ public SkipIfNoCertificateAttribute()
}
}
}
+
+///
+/// Skips the test if GitHub OIDC federated credentials are not configured.
+/// This is only available when running inside GitHub Actions with id-token permission.
+///
+public sealed class SkipIfNoGitHubOidcAttribute : FactAttribute
+{
+ private static readonly LiveTestConfiguration Configuration = new();
+
+ ///
+ /// Initializes a new instance that skips if GitHub OIDC credentials are missing.
+ ///
+ public SkipIfNoGitHubOidcAttribute()
+ {
+ if (!Configuration.HasGitHubOidcCredentials)
+ {
+ Skip = "GitHub OIDC not available. This test only runs in GitHub Actions with 'id-token: write' permission and federated credential configured in Azure.";
+ }
+ }
+}