diff --git a/.claude/commands/review-bot-comments.md b/.claude/commands/review-bot-comments.md index 5a3555b7d..8a370f537 100644 --- a/.claude/commands/review-bot-comments.md +++ b/.claude/commands/review-bot-comments.md @@ -15,15 +15,62 @@ gh api repos/joshsmithxrm/ppds-sdk/pulls/[PR]/comments ### 2. Triage Each Comment -For each bot comment, determine: +For each bot comment, determine verdict and rationale: -| Verdict | Action | -|---------|--------| -| **Valid** | Fix the issue, reply "Fixed in [commit]" | -| **False Positive** | Reply with reason, dismiss | -| **Unclear** | Investigate before deciding | +| Verdict | Meaning | +|---------|---------| +| **Valid** | Bot is correct, code should be changed | +| **False Positive** | Bot is wrong, explain why | +| **Unclear** | Need to investigate before deciding | -### 3. Common False Positives +### 3. Present Summary and WAIT FOR APPROVAL + +**CRITICAL: Do NOT implement fixes automatically.** + +Present a summary table to the user: + +```markdown +## Bot Review Triage - PR #XX + +| # | Bot | Finding | Verdict | Recommendation | Rationale | +|---|-----|---------|---------|----------------|-----------| +| 1 | Gemini | Missing Dispose | Valid | Add dispose call | Prevents resource leak | +| 2 | Copilot | Use .Where() | False Positive | Decline | Style preference | +``` + +**STOP HERE. Wait for user to review and approve before making ANY changes.** + +Bot suggestions can be wrong (e.g., suggesting methods that don't exist on an interface). +Always get user approval, then verify changes compile before committing. + +### 4. Implement Approved Changes + +After user approval: +1. Make the approved code changes +2. **Build and verify** - `dotnet build` must succeed +3. Run tests to confirm no regressions +4. Commit with descriptive message + +### 5. Reply to Each Comment Individually + +After changes are committed, reply to each bot comment. Do NOT batch responses into a single PR comment. + +```bash +# Reply to a specific review comment +gh api repos/joshsmithxrm/ppds-sdk/pulls/{pr}/comments \ + -f body="Fixed in abc123" \ + -F in_reply_to={comment_id} +``` + +**Important:** The `in_reply_to` parameter must be the comment ID (numeric), not a URL. Get IDs from the fetch step. + +| Verdict | Reply Template | +|---------|----------------| +| Valid (fixed) | `Fixed in {commit_sha} - {brief description}` | +| Declined | `Declining - {reason}` | +| False positive | `False positive - {explanation}` | + +## Common False Positives | Bot Claim | Why It's Often Wrong | |-----------|---------------------| @@ -31,31 +78,17 @@ For each bot comment, determine: | "Volatile needed with Interlocked" | Interlocked provides barriers | | "OR should be AND" | Logic may be intentionally inverted (DeMorgan) | | "Static field not thread-safe" | May be set once at startup | +| "Call Dispose on X" | Interface may not actually implement IDisposable | -### 4. Common Valid Findings +## Common Valid Findings | Pattern | Usually Valid | |---------|---------------| | Unused variable/parameter | Yes - dead code | | Missing null check | Check context | -| Resource not disposed | Yes - leak | +| Resource not disposed | Yes - but verify interface first | | Generic catch clause | Context-dependent | -## Output - -```markdown -## Bot Review Triage - PR #82 - -| # | Bot | Finding | Verdict | Action | -|---|-----|---------|---------|--------| -| 1 | Gemini | Use constants in dict | Valid | Fixed in abc123 | -| 2 | Copilot | Add validation tests | Valid | Fixed in def456 | -| 3 | Copilot | Use .Where() | False Positive | Style preference | -| 4 | CodeQL | Generic catch | Valid (low) | Acceptable for disposal | - -All findings addressed: Yes -``` - ## When to Use - After opening a PR (before requesting review) diff --git a/.claude/hooks/pre-commit-validate.py b/.claude/hooks/pre-commit-validate.py index 740170b13..5c4f86eb9 100644 --- a/.claude/hooks/pre-commit-validate.py +++ b/.claude/hooks/pre-commit-validate.py @@ -2,47 +2,36 @@ """ Pre-commit validation hook for PPDS SDK. Runs dotnet build and test before allowing git commit. + +Note: This hook is only triggered for 'git commit' commands via the +matcher in .claude/settings.json - no need to filter here. """ -import json -import shlex import subprocess import sys import os +import json -def main(): - try: - input_data = json.load(sys.stdin) - except json.JSONDecodeError: - print("⚠️ pre-commit-validate: Failed to parse input. Skipping validation.", file=sys.stderr) - sys.exit(0) - - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - command = tool_input.get("command", "") - # Only validate git commit commands (robust check using shlex) - if tool_name != "Bash": - sys.exit(0) +def main(): + # Read stdin (Claude Code sends JSON with tool info) try: - parts = shlex.split(command) - is_git_commit = len(parts) >= 2 and os.path.basename(parts[0]) == "git" and parts[1] == "commit" - except ValueError: - is_git_commit = False - if not is_git_commit: - sys.exit(0) # Allow non-commit commands + json.load(sys.stdin) + except (json.JSONDecodeError, EOFError): + pass # Input parsing is optional - matcher already filtered - # Get project directory + # Get project directory from environment or use current directory project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) try: + print("🔨 Running pre-commit validation...", file=sys.stderr) + # Run dotnet build - print("Running pre-commit validation...", file=sys.stderr) build_result = subprocess.run( ["dotnet", "build", "-c", "Release", "--nologo", "-v", "q"], cwd=project_dir, capture_output=True, text=True, - timeout=300 # 5 minute timeout + timeout=300 ) if build_result.returncode != 0: @@ -51,16 +40,16 @@ def main(): print(build_result.stdout, file=sys.stderr) if build_result.stderr: print(build_result.stderr, file=sys.stderr) - sys.exit(2) # Block commit + sys.exit(2) - # Run dotnet test (unit tests only - integration tests run on PR) + # Run unit tests only (integration tests run on PR) test_result = subprocess.run( ["dotnet", "test", "--no-build", "-c", "Release", "--nologo", "-v", "q", "--filter", "Category!=Integration"], cwd=project_dir, capture_output=True, text=True, - timeout=300 # 5 minute timeout + timeout=300 ) if test_result.returncode != 0: @@ -69,17 +58,18 @@ def main(): print(test_result.stdout, file=sys.stderr) if test_result.stderr: print(test_result.stderr, file=sys.stderr) - sys.exit(2) # Block commit + sys.exit(2) print("✅ Build and unit tests passed", file=sys.stderr) - sys.exit(0) # Allow commit + sys.exit(0) except FileNotFoundError: - print("⚠️ pre-commit-validate: dotnet not found in PATH. Skipping validation.", file=sys.stderr) + print("⚠️ dotnet not found in PATH. Skipping validation.", file=sys.stderr) sys.exit(0) except subprocess.TimeoutExpired: - print("⚠️ pre-commit-validate: Build/test timed out after 5 minutes. Skipping validation.", file=sys.stderr) + print("⚠️ Build/test timed out. Skipping validation.", file=sys.stderr) sys.exit(0) + if __name__ == "__main__": main() diff --git a/.claude/settings.json b/.claude/settings.json index 436598548..ea8beb4d3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,11 +2,11 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash", + "matcher": "Bash(git commit:*)", "hooks": [ { "type": "command", - "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-validate.py\"", + "command": "python .claude/hooks/pre-commit-validate.py", "timeout": 120 } ] diff --git a/.claude/settings.local.example.json b/.claude/settings.local.example.json index b0136bde8..0a1427a9a 100644 --- a/.claude/settings.local.example.json +++ b/.claude/settings.local.example.json @@ -79,7 +79,7 @@ "Bash(gh pr status:*)", "Bash(gh pr checkout:*)", "Bash(gh pr diff:*)", - "Bash(gh api:*", + "Bash(gh api:*)", "Bash(gh issue list:*)", "Bash(gh issue view:*)", "Bash(gh issue status:*)", diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6562f7f21..41160b21c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,6 +27,9 @@ updates: dataverse: patterns: - "Microsoft.PowerPlatform.Dataverse.*" + fakexrmeasy: + patterns: + - "FakeXrmEasy*" # Widen version constraints when needed (e.g., 1.1.* -> 1.2.*) versioning-strategy: increase diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 000000000..7fe6f435a --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,71 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit-tests: + name: Unit Tests + runs-on: windows-latest + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Run Unit Tests (exclude integration) + run: dotnet test --configuration Release --no-build --verbosity normal --filter "Category!=Integration" + + integration-tests: + name: Integration Tests + runs-on: windows-latest + # Only run on PRs from the same repo (not forks) or on main branch + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + environment: test-dataverse + needs: unit-tests + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Run Integration Tests + env: + DATAVERSE_URL: ${{ secrets.DATAVERSE_URL }} + PPDS_TEST_APP_ID: ${{ secrets.PPDS_TEST_APP_ID }} + 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/.github/workflows/test.yml b/.github/workflows/test.yml index 2e0fbeaf2..47e1517bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,5 +29,14 @@ jobs: - name: Build run: dotnet build --configuration Release --no-restore - - name: Test - run: dotnet test --configuration Release --no-build --verbosity normal + - name: Test with Coverage + run: | + dotnet test --configuration Release --no-build --verbosity normal --filter "Category!=Integration" --collect:"XPlat Code Coverage" --results-directory ./coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + directory: ./coverage + flags: unittests + fail_ci_if_error: false # Don't fail build if upload fails + verbose: true diff --git a/.gitignore b/.gitignore index 90da92daa..9dee43335 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ nul data.zip data/ schema.xml + +# Code coverage +coverage/ diff --git a/PPDS.Sdk.sln b/PPDS.Sdk.sln index 550693ff1..12aae2d83 100644 --- a/PPDS.Sdk.sln +++ b/PPDS.Sdk.sln @@ -23,6 +23,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PPDS.Migration", "src\PPDS. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PPDS.Auth", "src\PPDS.Auth\PPDS.Auth.csproj", "{5B3662DC-7BC8-4981-8F0F-30FB12CFD3F7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PPDS.Dataverse.IntegrationTests", "tests\PPDS.Dataverse.IntegrationTests\PPDS.Dataverse.IntegrationTests.csproj", "{4CA09022-5929-44B8-9777-CA824EC9A8EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PPDS.Auth.IntegrationTests", "tests\PPDS.Auth.IntegrationTests\PPDS.Auth.IntegrationTests.csproj", "{9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PPDS.LiveTests", "tests\PPDS.LiveTests\PPDS.LiveTests.csproj", "{EF193372-AD19-4918-87F2-5B875AC840E1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +135,42 @@ Global {5B3662DC-7BC8-4981-8F0F-30FB12CFD3F7}.Release|x64.Build.0 = Release|Any CPU {5B3662DC-7BC8-4981-8F0F-30FB12CFD3F7}.Release|x86.ActiveCfg = Release|Any CPU {5B3662DC-7BC8-4981-8F0F-30FB12CFD3F7}.Release|x86.Build.0 = Release|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Debug|x64.Build.0 = Debug|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Debug|x86.Build.0 = Debug|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Release|Any CPU.Build.0 = Release|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Release|x64.ActiveCfg = Release|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Release|x64.Build.0 = Release|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Release|x86.ActiveCfg = Release|Any CPU + {4CA09022-5929-44B8-9777-CA824EC9A8EE}.Release|x86.Build.0 = Release|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Debug|x64.Build.0 = Debug|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Debug|x86.Build.0 = Debug|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Release|Any CPU.Build.0 = Release|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Release|x64.ActiveCfg = Release|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Release|x64.Build.0 = Release|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Release|x86.ActiveCfg = Release|Any CPU + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876}.Release|x86.Build.0 = Release|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Debug|x64.Build.0 = Debug|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Debug|x86.Build.0 = Debug|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Release|Any CPU.Build.0 = Release|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Release|x64.ActiveCfg = Release|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Release|x64.Build.0 = Release|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Release|x86.ActiveCfg = Release|Any CPU + {EF193372-AD19-4918-87F2-5B875AC840E1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -142,5 +184,8 @@ Global {45DB0E17-0355-4342-8218-2FD8FA545157} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {1642C0BD-0B5B-476D-86EB-73BE3CD9BD67} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {5B3662DC-7BC8-4981-8F0F-30FB12CFD3F7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {4CA09022-5929-44B8-9777-CA824EC9A8EE} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9C52B513-E62D-49D4-9D5A-3F8C5BAB2876} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {EF193372-AD19-4918-87F2-5B875AC840E1} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..1ba3bcf31 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,84 @@ +# Codecov Configuration +# https://docs.codecov.com/docs/codecov-yaml + +coverage: + # Overall project coverage status + status: + project: + default: + target: 60% + threshold: 5% # Allow 5% drop before failing + informational: true # Don't block PRs (visibility only for now) + + # Patch coverage - new code should be well-tested + patch: + default: + target: 80% + informational: true # Don't block PRs (visibility only for now) + + # Per-package thresholds using component system + # These match the targets defined in issue #55 + +# Component-based coverage tracking +component_management: + default_rules: + statuses: + - type: project + informational: true + - type: patch + informational: true + + individual_components: + - component_id: plugins + name: PPDS.Plugins + paths: + - src/PPDS.Plugins/** + statuses: + - type: project + target: 95% + + - component_id: auth + name: PPDS.Auth + paths: + - src/PPDS.Auth/** + statuses: + - type: project + target: 70% + + - component_id: dataverse + name: PPDS.Dataverse + paths: + - src/PPDS.Dataverse/** + statuses: + - type: project + target: 60% + + - component_id: migration + name: PPDS.Migration + paths: + - src/PPDS.Migration/** + statuses: + - type: project + target: 50% + + - component_id: cli + name: PPDS.Cli + paths: + - src/PPDS.Cli/** + statuses: + - type: project + target: 60% + +# Ignore test projects and generated files +ignore: + - "tests/**" + - "**/obj/**" + - "**/bin/**" + - "**/*.g.cs" + - "**/*.Designer.cs" + +# PR comment configuration +comment: + layout: "header, diff, components, footer" + behavior: default + require_changes: true # Only comment when coverage changes diff --git a/scripts/install-local.ps1 b/scripts/Install-LocalCli.ps1 similarity index 100% rename from scripts/install-local.ps1 rename to scripts/Install-LocalCli.ps1 diff --git a/scripts/New-TestCertificate.ps1 b/scripts/New-TestCertificate.ps1 new file mode 100644 index 000000000..6aa620844 --- /dev/null +++ b/scripts/New-TestCertificate.ps1 @@ -0,0 +1,142 @@ +<# +.SYNOPSIS + Creates a self-signed certificate for PPDS integration testing. + +.DESCRIPTION + Generates a certificate for Azure App Registration authentication testing. + Outputs: + - .cer file (public key) - upload to Azure App Registration + - .pfx file (private key) - store as GitHub secret (base64 encoded) + - Base64 text file - copy to PPDS_TEST_CERT_BASE64 secret + +.PARAMETER CertName + Name for the certificate. Default: "PPDS-IntegrationTests" + +.PARAMETER Password + Password to protect the PFX file. If not provided, prompts for input. + +.PARAMETER OutputPath + Directory to export files. Default: current directory. + +.PARAMETER ValidYears + How many years the certificate is valid. Default: 2 + +.EXAMPLE + .\New-TestCertificate.ps1 + # Prompts for password, creates cert in current directory + +.EXAMPLE + .\New-TestCertificate.ps1 -Password "MySecurePass123!" -OutputPath C:\certs + # Creates cert with specified password in C:\certs + +.NOTES + After running this script: + 1. Upload the .cer file to your Azure App Registration + 2. Add PPDS_TEST_CERT_BASE64 secret (contents of -base64.txt file) + 3. Add PPDS_TEST_CERT_PASSWORD secret (the password you used) + 4. Delete the local files for security +#> +[CmdletBinding()] +param( + [Parameter()] + [string]$CertName = "PPDS-IntegrationTests", + + [Parameter()] + [string]$Password, + + [Parameter()] + [string]$OutputPath = (Get-Location).Path, + + [Parameter()] + [int]$ValidYears = 2 +) + +$ErrorActionPreference = "Stop" + +# Prompt for password if not provided +if (-not $Password) { + $securePassword = Read-Host -Prompt "Enter password for PFX file" -AsSecureString + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + $Password = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) +} + +if ([string]::IsNullOrWhiteSpace($Password)) { + Write-Error "Password cannot be empty" + exit 1 +} + +$securePass = ConvertTo-SecureString -String $Password -Force -AsPlainText + +# Create output directory if needed +if (-not (Test-Path $OutputPath)) { + New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +} + +# File paths +$pfxPath = Join-Path $OutputPath "$CertName.pfx" +$cerPath = Join-Path $OutputPath "$CertName.cer" +$base64Path = Join-Path $OutputPath "$CertName-base64.txt" + +Write-Host "Creating self-signed certificate..." -ForegroundColor Cyan + +# Create the certificate +$cert = New-SelfSignedCertificate ` + -Subject "CN=$CertName" ` + -DnsName "$CertName.local" ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -NotAfter (Get-Date).AddYears($ValidYears) ` + -KeySpec KeyExchange ` + -KeyExportPolicy Exportable ` + -KeyLength 2048 ` + -HashAlgorithm SHA256 + +Write-Host "Certificate created with thumbprint: $($cert.Thumbprint)" -ForegroundColor Green + +# Export PFX (with private key) +Write-Host "Exporting PFX (private key)..." -ForegroundColor Cyan +Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $securePass | Out-Null +Write-Host " -> $pfxPath" -ForegroundColor Gray + +# Export CER (public key only) +Write-Host "Exporting CER (public key)..." -ForegroundColor Cyan +Export-Certificate -Cert $cert -FilePath $cerPath | Out-Null +Write-Host " -> $cerPath" -ForegroundColor Gray + +# Convert PFX to Base64 +Write-Host "Converting PFX to Base64..." -ForegroundColor Cyan +$pfxBytes = [System.IO.File]::ReadAllBytes($pfxPath) +$pfxBase64 = [System.Convert]::ToBase64String($pfxBytes) +[System.IO.File]::WriteAllText($base64Path, $pfxBase64) +Write-Host " -> $base64Path" -ForegroundColor Gray + +# Copy to clipboard +$pfxBase64 | Set-Clipboard +Write-Host "Base64 copied to clipboard!" -ForegroundColor Green + +# Remove from cert store (optional - keep if you want to test locally) +$removeFromStore = Read-Host "Remove certificate from local store? (y/N)" +if ($removeFromStore -eq 'y') { + Remove-Item "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force + Write-Host "Removed from cert store" -ForegroundColor Gray +} + +# Summary +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Certificate created successfully!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Yellow +Write-Host "1. Upload to Azure:" -ForegroundColor White +Write-Host " Azure Portal -> App Registrations -> Your App -> Certificates & secrets" +Write-Host " Upload: $cerPath" -ForegroundColor Gray +Write-Host "" +Write-Host "2. Add GitHub Secrets:" -ForegroundColor White +Write-Host " PPDS_TEST_CERT_BASE64 = contents of $base64Path" -ForegroundColor Gray +Write-Host " PPDS_TEST_CERT_PASSWORD = $Password" -ForegroundColor Gray +Write-Host "" +Write-Host "3. Delete local files (security):" -ForegroundColor White +Write-Host " Remove-Item '$pfxPath', '$cerPath', '$base64Path'" -ForegroundColor Gray +Write-Host "" +Write-Host "Thumbprint: $($cert.Thumbprint)" -ForegroundColor Cyan diff --git a/scripts/ppds-dev.ps1 b/scripts/ppds-dev.ps1 deleted file mode 100644 index 644aab558..000000000 --- a/scripts/ppds-dev.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -<# -.SYNOPSIS - Run PPDS CLI directly from source (no install needed). -.EXAMPLE - .\scripts\ppds-dev.ps1 env who - .\scripts\ppds-dev.ps1 auth create --name dev - .\scripts\ppds-dev.ps1 data export --schema schema.xml -o data.zip -#> -param( - [Parameter(ValueFromRemainingArguments)] - [string[]]$Arguments -) - -$projectPath = Join-Path $PSScriptRoot "..\src\PPDS.Cli\PPDS.Cli.csproj" -dotnet run --project $projectPath -- @Arguments diff --git a/tests/PPDS.Auth.IntegrationTests/AuthenticationSmokeTests.cs b/tests/PPDS.Auth.IntegrationTests/AuthenticationSmokeTests.cs new file mode 100644 index 000000000..1badd8c89 --- /dev/null +++ b/tests/PPDS.Auth.IntegrationTests/AuthenticationSmokeTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using Xunit; + +namespace PPDS.Auth.IntegrationTests; + +/// +/// Smoke tests for authentication infrastructure. +/// These tests verify the auth library is correctly referenced and basic types work. +/// +public class AuthenticationSmokeTests +{ + [Fact] + public void ProfileStore_CanBeInstantiated() + { + // This test verifies the PPDS.Auth assembly is correctly referenced + // and basic types can be loaded. + var storeType = typeof(PPDS.Auth.Profiles.ProfileStore); + storeType.Should().NotBeNull(); + } + + [Fact] + public void CredentialProvider_TypesExist() + { + // Verify credential provider types are accessible + var clientSecretType = typeof(PPDS.Auth.Credentials.ClientSecretCredentialProvider); + clientSecretType.Should().NotBeNull(); + } + + [Fact] + public void ProfileStore_DefaultPath_IsValid() + { + // Arrange & Act + var store = new PPDS.Auth.Profiles.ProfileStore(); + + // Assert - just verify it can be created without throwing + store.Should().NotBeNull(); + } +} diff --git a/tests/PPDS.Auth.IntegrationTests/PPDS.Auth.IntegrationTests.csproj b/tests/PPDS.Auth.IntegrationTests/PPDS.Auth.IntegrationTests.csproj new file mode 100644 index 000000000..8343a3c0d --- /dev/null +++ b/tests/PPDS.Auth.IntegrationTests/PPDS.Auth.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net9.0;net10.0 + PPDS.Auth.IntegrationTests + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasySmokeTests.cs b/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasySmokeTests.cs new file mode 100644 index 000000000..e980014d9 --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasySmokeTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using Xunit; + +namespace PPDS.Dataverse.IntegrationTests; + +/// +/// Smoke tests to verify FakeXrmEasy infrastructure is working correctly. +/// +public class FakeXrmEasySmokeTests : FakeXrmEasyTestsBase +{ + [Fact] + public void Context_IsInitialized() + { + Context.Should().NotBeNull(); + } + + [Fact] + public void Service_IsInitialized() + { + Service.Should().NotBeNull(); + } + + [Fact] + public void Create_WithValidEntity_ReturnsId() + { + // Arrange + var account = new Entity("account") + { + ["name"] = "Test Account" + }; + + // Act + var id = Service.Create(account); + + // Assert + id.Should().NotBeEmpty(); + } + + [Fact] + public void Retrieve_AfterCreate_ReturnsEntity() + { + // Arrange + var account = new Entity("account") + { + ["name"] = "Test Account" + }; + var id = Service.Create(account); + + // Act + var retrieved = Service.Retrieve("account", id, new Microsoft.Xrm.Sdk.Query.ColumnSet(true)); + + // Assert + retrieved.Should().NotBeNull(); + retrieved.GetAttributeValue("name").Should().Be("Test Account"); + } + + [Fact] + public void InitializeWith_SeedsContext() + { + // Arrange + var accountId = Guid.NewGuid(); + var account = new Entity("account", accountId) + { + ["name"] = "Seeded Account" + }; + + // Act + InitializeWith(account); + var retrieved = Service.Retrieve("account", accountId, new Microsoft.Xrm.Sdk.Query.ColumnSet(true)); + + // Assert + retrieved.Should().NotBeNull(); + retrieved.GetAttributeValue("name").Should().Be("Seeded Account"); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasyTestsBase.cs b/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasyTestsBase.cs new file mode 100644 index 000000000..d017b21fc --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/FakeXrmEasyTestsBase.cs @@ -0,0 +1,70 @@ +using FakeXrmEasy.Abstractions; +using FakeXrmEasy.Abstractions.Enums; +using FakeXrmEasy.Middleware; +using FakeXrmEasy.Middleware.Crud; +using FakeXrmEasy.Middleware.Messages; +using Microsoft.Xrm.Sdk; + +namespace PPDS.Dataverse.IntegrationTests; + +/// +/// Base class for integration tests using FakeXrmEasy to mock Dataverse operations. +/// Provides an in-memory IOrganizationService for testing CRUD and message operations. +/// +public abstract class FakeXrmEasyTestsBase : IDisposable +{ + /// + /// The FakeXrmEasy context providing the mocked Dataverse environment. + /// + protected IXrmFakedContext Context { get; } + + /// + /// The mocked IOrganizationService for executing Dataverse operations. + /// + protected IOrganizationService Service { get; } + + /// + /// Initializes a new instance of the test base with FakeXrmEasy middleware. + /// + protected FakeXrmEasyTestsBase() + { + Context = MiddlewareBuilder + .New() + .AddCrud() + .AddFakeMessageExecutors() + .UseCrud() + .UseMessages() + .SetLicense(FakeXrmEasyLicense.RPL_1_5) + .Build(); + + Service = Context.GetOrganizationService(); + } + + /// + /// Initializes the context with a set of pre-existing entities. + /// + /// The entities to seed the context with. + protected void InitializeWith(params Entity[] entities) + { + Context.Initialize(entities); + } + + /// + /// Initializes the context with a collection of pre-existing entities. + /// + /// The entities to seed the context with. + protected void InitializeWith(IEnumerable entities) + { + Context.Initialize(entities); + } + + /// + /// Disposes of any resources used by the test. + /// + public virtual void Dispose() + { + // IXrmFakedContext doesn't declare IDisposable, but runtime type may implement it + (Context as IDisposable)?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/tests/PPDS.Dataverse.IntegrationTests/PPDS.Dataverse.IntegrationTests.csproj b/tests/PPDS.Dataverse.IntegrationTests/PPDS.Dataverse.IntegrationTests.csproj new file mode 100644 index 000000000..bd18fbfda --- /dev/null +++ b/tests/PPDS.Dataverse.IntegrationTests/PPDS.Dataverse.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net9.0;net10.0 + PPDS.Dataverse.IntegrationTests + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/tests/PPDS.LiveTests/Infrastructure/LiveTestBase.cs b/tests/PPDS.LiveTests/Infrastructure/LiveTestBase.cs new file mode 100644 index 000000000..cb28540ab --- /dev/null +++ b/tests/PPDS.LiveTests/Infrastructure/LiveTestBase.cs @@ -0,0 +1,88 @@ +using Xunit; + +namespace PPDS.LiveTests.Infrastructure; + +/// +/// Base class for live Dataverse integration tests. +/// Provides shared configuration and setup/teardown support. +/// +[Collection("LiveDataverse")] +[Trait("Category", "Integration")] +public abstract class LiveTestBase : IAsyncLifetime +{ + /// + /// Configuration containing credentials and connection details. + /// + protected LiveTestConfiguration Configuration { get; } + + /// + /// Initializes a new instance of the live test base. + /// + protected LiveTestBase() + { + Configuration = new LiveTestConfiguration(); + } + + /// + /// Async initialization called before each test. + /// Override to perform custom setup. + /// + public virtual Task InitializeAsync() + { + return Task.CompletedTask; + } + + /// + /// Async cleanup called after each test. + /// Override to perform custom teardown. + /// + public virtual Task DisposeAsync() + { + return Task.CompletedTask; + } +} + +/// +/// Collection definition for live Dataverse tests. +/// Tests in this collection run sequentially to avoid overwhelming the API. +/// +[CollectionDefinition("LiveDataverse")] +public class LiveDataverseCollection : ICollectionFixture +{ +} + +/// +/// Shared fixture for live Dataverse tests. +/// Created once per test collection and shared across all tests. +/// +public class LiveDataverseFixture : IAsyncLifetime +{ + /// + /// Configuration for the live tests. + /// + public LiveTestConfiguration Configuration { get; } + + /// + /// Initializes the fixture. + /// + public LiveDataverseFixture() + { + Configuration = new LiveTestConfiguration(); + } + + /// + /// Called once before any tests in the collection run. + /// + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + /// + /// Called once after all tests in the collection have run. + /// + public Task DisposeAsync() + { + return Task.CompletedTask; + } +} diff --git a/tests/PPDS.LiveTests/Infrastructure/LiveTestConfiguration.cs b/tests/PPDS.LiveTests/Infrastructure/LiveTestConfiguration.cs new file mode 100644 index 000000000..fd513406f --- /dev/null +++ b/tests/PPDS.LiveTests/Infrastructure/LiveTestConfiguration.cs @@ -0,0 +1,101 @@ +namespace PPDS.LiveTests.Infrastructure; + +/// +/// Configuration for live Dataverse integration tests. +/// Reads credentials from environment variables. +/// +public sealed class LiveTestConfiguration +{ + /// + /// The Dataverse environment URL (e.g., https://org.crm.dynamics.com). + /// + public string? DataverseUrl { get; } + + /// + /// The Entra ID Application (Client) ID. + /// + public string? ApplicationId { get; } + + /// + /// The client secret for client credential authentication. + /// + public string? ClientSecret { get; } + + /// + /// The Entra ID Tenant ID. + /// + public string? TenantId { get; } + + /// + /// Base64-encoded certificate for certificate-based authentication. + /// + public string? CertificateBase64 { get; } + + /// + /// Password for the certificate. + /// + public string? CertificatePassword { get; } + + /// + /// Gets a value indicating whether client secret credentials are available. + /// + public bool HasClientSecretCredentials => + !string.IsNullOrWhiteSpace(DataverseUrl) && + !string.IsNullOrWhiteSpace(ApplicationId) && + !string.IsNullOrWhiteSpace(ClientSecret) && + !string.IsNullOrWhiteSpace(TenantId); + + /// + /// Gets a value indicating whether certificate credentials are available. + /// + public bool HasCertificateCredentials => + !string.IsNullOrWhiteSpace(DataverseUrl) && + !string.IsNullOrWhiteSpace(ApplicationId) && + !string.IsNullOrWhiteSpace(CertificateBase64) && + !string.IsNullOrWhiteSpace(TenantId); + + /// + /// Gets a value indicating whether any live test credentials are available. + /// + public bool HasAnyCredentials => HasClientSecretCredentials || HasCertificateCredentials; + + /// + /// Initializes a new instance reading from environment variables. + /// + public LiveTestConfiguration() + { + DataverseUrl = Environment.GetEnvironmentVariable("DATAVERSE_URL"); + ApplicationId = Environment.GetEnvironmentVariable("PPDS_TEST_APP_ID"); + ClientSecret = Environment.GetEnvironmentVariable("PPDS_TEST_CLIENT_SECRET"); + TenantId = Environment.GetEnvironmentVariable("PPDS_TEST_TENANT_ID"); + CertificateBase64 = Environment.GetEnvironmentVariable("PPDS_TEST_CERT_BASE64"); + CertificatePassword = Environment.GetEnvironmentVariable("PPDS_TEST_CERT_PASSWORD"); + } + + /// + /// Gets the reason why credentials are not available, for skip messages. + /// + public string GetMissingCredentialsReason() + { + var missing = new List(); + + if (string.IsNullOrWhiteSpace(DataverseUrl)) + missing.Add("DATAVERSE_URL"); + if (string.IsNullOrWhiteSpace(ApplicationId)) + missing.Add("PPDS_TEST_APP_ID"); + if (string.IsNullOrWhiteSpace(TenantId)) + missing.Add("PPDS_TEST_TENANT_ID"); + + if (!HasClientSecretCredentials && !HasCertificateCredentials) + { + if (string.IsNullOrWhiteSpace(ClientSecret)) + missing.Add("PPDS_TEST_CLIENT_SECRET"); + if (string.IsNullOrWhiteSpace(CertificateBase64)) + missing.Add("PPDS_TEST_CERT_BASE64"); + } + + return missing.Count > 0 + ? $"Missing environment variables: {string.Join(", ", missing)}" + : "Unknown reason"; + } +} diff --git a/tests/PPDS.LiveTests/Infrastructure/SkipIfNoCredentialsAttribute.cs b/tests/PPDS.LiveTests/Infrastructure/SkipIfNoCredentialsAttribute.cs new file mode 100644 index 000000000..cc809bac6 --- /dev/null +++ b/tests/PPDS.LiveTests/Infrastructure/SkipIfNoCredentialsAttribute.cs @@ -0,0 +1,61 @@ +using Xunit; + +namespace PPDS.LiveTests.Infrastructure; + +/// +/// Skips the test if live Dataverse credentials are not configured. +/// Use this attribute on test methods that require a real Dataverse connection. +/// +public sealed class SkipIfNoCredentialsAttribute : FactAttribute +{ + private static readonly LiveTestConfiguration Configuration = new(); + + /// + /// Initializes a new instance that skips if credentials are missing. + /// + public SkipIfNoCredentialsAttribute() + { + if (!Configuration.HasAnyCredentials) + { + Skip = Configuration.GetMissingCredentialsReason(); + } + } +} + +/// +/// Skips the test if client secret credentials are not configured. +/// +public sealed class SkipIfNoClientSecretAttribute : FactAttribute +{ + private static readonly LiveTestConfiguration Configuration = new(); + + /// + /// Initializes a new instance that skips if client secret credentials are missing. + /// + public SkipIfNoClientSecretAttribute() + { + if (!Configuration.HasClientSecretCredentials) + { + Skip = "Client secret credentials not configured. Set DATAVERSE_URL, PPDS_TEST_APP_ID, PPDS_TEST_CLIENT_SECRET, and PPDS_TEST_TENANT_ID."; + } + } +} + +/// +/// Skips the test if certificate credentials are not configured. +/// +public sealed class SkipIfNoCertificateAttribute : FactAttribute +{ + private static readonly LiveTestConfiguration Configuration = new(); + + /// + /// Initializes a new instance that skips if certificate credentials are missing. + /// + public SkipIfNoCertificateAttribute() + { + if (!Configuration.HasCertificateCredentials) + { + Skip = "Certificate credentials not configured. Set DATAVERSE_URL, PPDS_TEST_APP_ID, PPDS_TEST_CERT_BASE64, and PPDS_TEST_TENANT_ID."; + } + } +} diff --git a/tests/PPDS.LiveTests/LiveDataverseSmokeTests.cs b/tests/PPDS.LiveTests/LiveDataverseSmokeTests.cs new file mode 100644 index 000000000..d54c78872 --- /dev/null +++ b/tests/PPDS.LiveTests/LiveDataverseSmokeTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using PPDS.LiveTests.Infrastructure; +using Xunit; + +namespace PPDS.LiveTests; + +/// +/// Smoke tests for live Dataverse integration. +/// These tests verify the test infrastructure works and skip gracefully when credentials are missing. +/// +public class LiveDataverseSmokeTests : LiveTestBase +{ + [Fact] + public void Configuration_IsAvailable() + { + Configuration.Should().NotBeNull(); + } + + [Fact] + public void Configuration_ReportsCredentialStatus() + { + // This test always runs and verifies the configuration class works + var hasAny = Configuration.HasAnyCredentials; + var hasSecret = Configuration.HasClientSecretCredentials; + var hasCert = Configuration.HasCertificateCredentials; + + // Assert that the aggregate property reflects the state of the specific properties + hasAny.Should().Be(hasSecret || hasCert); + } + + [SkipIfNoCredentials] + public void WhenCredentialsAvailable_CanConnect() + { + // This test only runs when credentials are available + Configuration.HasAnyCredentials.Should().BeTrue(); + Configuration.DataverseUrl.Should().NotBeNullOrWhiteSpace(); + } + + [SkipIfNoClientSecret] + public void WhenClientSecretAvailable_HasAllRequiredFields() + { + Configuration.HasClientSecretCredentials.Should().BeTrue(); + Configuration.ApplicationId.Should().NotBeNullOrWhiteSpace(); + Configuration.ClientSecret.Should().NotBeNullOrWhiteSpace(); + Configuration.TenantId.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void MissingCredentialsReason_DescribesMissingVars() + { + var config = new LiveTestConfiguration(); + var reason = config.GetMissingCredentialsReason(); + + if (!config.HasAnyCredentials) + { + reason.Should().Contain("Missing environment variables"); + } + } +} diff --git a/tests/PPDS.LiveTests/PPDS.LiveTests.csproj b/tests/PPDS.LiveTests/PPDS.LiveTests.csproj new file mode 100644 index 000000000..99af59f7f --- /dev/null +++ b/tests/PPDS.LiveTests/PPDS.LiveTests.csproj @@ -0,0 +1,31 @@ + + + + net8.0;net9.0;net10.0 + PPDS.LiveTests + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + +