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."; + } + } +}